Давно, у концепції Вектори, ми зауважили, що «масиви можуть бути довільного розміру (обмежені лише памʼяттю нашого обладнання) і мати довільну кількість вимірів».
Відтоді ми здебільшого оминали масиви з більш ніж одним виміром, щоб усе було простіше. Це рішення стане зрозумілішим, якщо спробувати прочитати довідкові документи Julia в усій їхній складності.
Однак багатовимірні масиви дуже, дуже важливі в наукових обчисленнях, тож нам потрібно їх розуміти.
Номенклатура: Наслідуючи століття математичних традицій, ми називаємо одновимірні масиви Vectors, а двовимірні - Matrices.
Приклади в цьому документі здебільшого будуть матрицями. Робота з трьома чи більше вимірами синтаксично майже ідентична, але вихідні дані важко й заплутано читати (на двовимірному екрані).
Тип type N-вимірного масиву з eltype T - це Array{T, N}.
Для зручності та узгодженості з математичною номенклатурою Julia визначає кілька псевдонімів типів: Vector{T} для Array{T, 1} і Matrix{T} для Array{T, 2}.
# 3-D array
julia> m3 = ones(2, 3, 4);
julia> typeof(m3)
Array{Float64, 3}
# Vector
julia> v = [1, 2]
2-element Vector{Int64}:
1
2
julia> typeof(v) == Array{Int64, 1}
true
# Matrix
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> typeof(m)
Matrix{Int64} (alias for Array{Int64, 2})
julia> typeof(m) == Array{Int64, 2}
true
Ми створювали безліч векторів, вписуючи список, розділений комами, у квадратні дужки. Роздільником також може слугувати крапка з комою.
julia> v = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> w = [1; 2; 3]
3-element Vector{Int64}:
1
2
3
julia> v == w
true
Якщо використати пробіл (або табуляцію) як роздільник, результат буде іншим.
julia> u = [1 2 3]
1×3 Matrix{Int64}:
1 2 3
Тепер Julia визначає це як матрицю 1×3 (в інших контекстах ми назвали б її row vector).
Загалом пробіли поєднують елементи горизонтально, а крапки з комою (або символи нового рядка) - вертикально.
Згадка про «речі» навмисно нечітка, бо Julia спробує працювати з тим, що ми їй дамо.
julia> [v 2v]
3×2 Matrix{Int64}:
1 2
2 4
3 6
julia> [v; 2v]
6-element Vector{Int64}:
1
2
3
2
4
6
Є функції hcat() і vcat(), які роблять те саме, лише чіткіше показуючи, що це горизонтальна та вертикальна конкатенація.
Багатовимірним узагальненням цього є функція cat().
jjulia> hcat(v, 2v)
3×2 Matrix{Int64}:
1 2
2 4
3 6
julia> vcat(v, 2v)
6-element Vector{Int64}:
1
2
3
2
4
6
Явні матриці зручно вводити в порядку за рядками, бо це відповідає людській інтуїції (і легше читати для культур із горизонтальним письмом):
julia> m = [1 2 3; 4 5 6]
2×3 Matrix{Int64}:
1 2 3
4 5 6
Однак варто памʼятати, що Julia (як Fortran, R і Matlab, але на відміну від C/C++ чи NumPy) зберігає N-вимірні масиви в порядку за стовпцями, і це може спричинити величезну різницю в продуктивності, якщо ми проходимо циклом по елементах. Допоможімо кешу процесора допомогти нам!
# put these integers in 2 rows and 3 columns
julia> reshape(collect(1:6), 2, 3)
2×3 Matrix{Int64}:
1 3 5
2 4 6
Наведений вище приклад бере цілі числа від 1 до 6 і заповнює ними матрицю 2×3 за стовпцями.
Існують різноманітні допоміжні функції для створення поширених типів масивів (однорідних або випадкових).
julia> zeros(2, 3) # see also ones()
2×3 Matrix{Float64}:
0.0 0.0 0.0
0.0 0.0 0.0
julia> falses(2, 2) # booleans, see also trues()
2×2 BitMatrix:
0 0
0 0
julia> rand(Float32, 2, 3) # random numbers in the interval [0, 1)
2×3 Matrix{Float32}:
0.768823 0.169633 0.632565
0.388451 0.109176 0.850381
Шанувальники NumPy знають, що np.linspace - це широко вживана функція для створення масиву заданої довжини із заданими кінцевими точками та рівномірно розподіленими значеннями.
Зазвичай її використовують як вісь x графіка або як незалежну змінну в лінійних моделях.
У Julia немає точного відповідника, але дуже гнучка функція range() може його імітувати, якщо вказати нижню й верхню межі, а також іменований аргумент length.
Той, хто працює локально й має доступ до пакета Plots, може запустити цей код:
julia> using Plots
julia> x = range(0, 2π; length=100)
0.0:0.06346651825433926:6.283185307179586
julia> typeof(x)
StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}
julia> vals = [x sin.(x) cos.(x)]
100×3 Matrix{Float64}:
0.0 0.0 1.0
0.0634665 0.0634239 0.997987
0.126933 0.126592 0.991955
0.1904 0.189251 0.981929
(...truncated)
julia> plot(vals[:, 1], vals[:, 2:3])
StepRangeLen можна використовувати як вектор, зокрема перетворювати на стовпець матриці.
Також є відповідна функція logrange(), яка генерує значення, рівномірно розподілені на логарифмічній осі.
Для двовимірного масиву ми зазвичай використовуємо два індекси в порядку [row, col].
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m[1, 2] # row 1, col 2
2
# Stay within bounds! There is no row 3.
julia> m[3, 1]
ERROR: BoundsError: attempt to access 2×3 Matrix{Int64} at index [3, 1]
julia> m[3]
2
Останній приклад, можливо, дивує: один індекс не є помилкою і повертає один елемент.
Пояснення повертає нас до зауваження про порядок за стовпцями: Julia рухається вниз стовпцем 1, потім стовпцем 2, доки не знайде третій елемент у памʼяті.
Будьмо обережні: це має деяке застосування під час написання бібліотек загального призначення, але частіше заплутує!
Схожа проблема виникає, коли ми запитуємо розмір масиву.
length() повертає загальну кількість елементів, size() повертає кортеж із ndims() елементів, кожен з яких - довжина відповідного виміру.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> length(m)
6
julia> size(m) # 2 rows, 3 cols
(2, 3)
julia> ndims(m) # how many dimensions? Like `m |> size |> length`
2
Ми легко можемо скопіювати підматрицю.
У наведеному нижче прикладі функція reshape() приводить масив до заданих розмірів, заповнюючи його за стовпцями, а потім ми беремо його зріз.
julia> m12 = reshape(collect(1:12), 4, 3)
4×3 Matrix{Int64}:
1 5 9
2 6 10
3 7 11
4 8 12
julia> m12[2:4, 1:2]
3×2 Matrix{Int64}:
2 6
3 7
4 8
# some rows, all columns
julia> m12[2:4, :]
3×3 Matrix{Int64}:
2 6 10
3 7 11
4 8 12
Сам символ : означає «скопіювати все в цьому вимірі».
Для несуміжних рядків чи стовпців використовуємо вектори:
julia> m12[[1, 3], :] # rows 1 and 3
2×3 Matrix{Int64}:
1 5 9
3 7 11
Сам символ : перетворює будь-який масив на вектор за стовпцями.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m[:]
6-element Vector{Int64}:
1
4
2
5
3
6
Раніше ми бачили агрегатні функції, як-от sum() і maximum(), застосовані до одновимірних колекцій, де вони опрацьовують усі елементи й повертають скалярний результат.
Це також працює у вищих вимірах.
Однак іноді ми хочемо застосувати функцію лише до одного виміру, наприклад підсумувати вниз або впоперек, щоб повернути масив з singleton dimension розміру 1.
Для цього є необовʼязковий іменований аргумент dims.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> sum(m) # sum everything
21
julia> sum(m; dims=1) # sum down
1×3 Matrix{Int64}:
5 7 9
julia> sum(m; dims=2) # sum across
2×1 Matrix{Int64}:
6
15
# other aggregation functions are similar
julia> maximum(m; dims=2)
2×1 Matrix{Int64}:
3
6
Значення dims - це вимір, який стає одиничним, тож у наведених вище прикладах dims=1 => 1×3, а dims=2 => 2×1.
Відповідно, багатовимірні масиви можуть згортати кілька вимірів одразу, якщо dims задано як масив або діапазон (знову ж таки, щоб зрозуміти вихідні дані, доведеться подумати!).
Іменований аргумент dims часто трапляється у вбудованих функціях, як-от sum(), але як написати щось подібне у власному коді?
Один із добрих варіантів - скористатися функціями вищого порядку, як-от reduce(), і це буде докладніше розглянуто в одній із наступних концепцій.
Крім того, Julia надає кілька функцій, які дають змогу працювати з N-вимірними масивами так, ніби це вкладені вектори векторів.
Для матриць зручними є eachrow() і eachcol(), але загальнішою є функція eachslice(), яка працює з довільними вимірами.
# m is as in the previous examples
julia> eachrow(m)
2-element RowSlices{Matrix{Int64}, Tuple{Base.OneTo{Int64}}, SubArray{Int64, 1, Matrix{Int64}, Tuple{Int64, Base.Slice{Base.OneTo{Int64}}}, true}}:
[1, 2, 3]
[4, 5, 6]
julia> eachcol(m)
3-element ColumnSlices{Matrix{Int64}, Tuple{Base.OneTo{Int64}}, SubArray{Int64, 1, Matrix{Int64}, Tuple{Base.Slice{Base.OneTo{Int64}}, Int64}, true}}:
[1, 4]
[2, 5]
[3, 6]
Тип трохи лякає, але лише тому, що це подання вихідного масиву, яке дає змогу працювати з ним без копіювання. Масиви Julia можуть сягати терабайтів, тож копіювання може стати справжнім кошмаром для продуктивності!
Таке подання можна використовувати для циклів, трансляції чи будь-яких інших операцій, які ми бачили в силабусі дотепер.
Крім того, включення можуть бути потужними й універсальними з масивом на вході. Прості випадки згадувалися в концепції Цикли, але ширше обговорення буде в одній із наступних концепцій.
Одновимірний випадок ми розглядали в концепції Операції з векторами.
julia> v = [1.2, 1.5, 1.7]
3-element Vector{Float64}:
1.2
1.5
1.7
julia> v .- 0.5
3-element Vector{Float64}:
0.7
1.0
1.2
Оператор із крапкою .- вважає одиничне значення 0.5 рівнозначним [0.5, 0.5, 0.5], застосовуючи broadcasting для узгодження розмірів, а потім виконує віднімання поелементно.
Поширення цього на вищі виміри - насправді просто те саме, лише більше.
Наприклад, коли ми транслюємо множення рядкового вектора 2x1 [1.0 1.5 2.0] на матрицю 2x3 [1 2 3; 4 5 6], це рівнозначно трансляції [1.0 1.5 2.0] до [1.0 1.5 2.0; 1.0 1.5 2.0] із подальшим поелементним множенням.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m .* [1.0 1.5 2.0] # broadcast a 1x3 row vector down
2×3 Matrix{Float64}:
1.0 3.0 6.0
4.0 7.5 12.0
julia> m .* [1.0 1.5 2.0; 1.0 1.5 2.0] # 2x3 elementwise multiplication
2×3 Matrix{Float64}:
1.0 3.0 6.0
4.0 7.5 12.0
julia> m ./ [1, 2] # broadcast a 2x1 column vector across
2×3 Matrix{Float64}:
1.0 2.0 3.0
2.0 2.5 3.0
julia> m ./ [1 1 1; 2 2 2] # 2x3 elementwise division
2×3 Matrix{Float64}:
1.0 2.0 3.0
2.0 2.5 3.0
Примітка: для трансляції саме неодиничні виміри мають збігатися за розміром (наприклад, 2x3 Matrix .* 2x1 Matrix).
Більше того, функцію можна транслювати на кожен елемент Matrix точно так само, як це роблять із Vector.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> (x -> x^2).(m) # broadcast a function to all elements
2×3 Matrix{Int64}:
1 4 9
16 25 36
Спершу може здатися, що можливості для плутанини програміста зростають експоненційно з кількістю вимірів, але практика дуже допомагає. До того ж знайомство з масивами NumPy досить добре переноситься на Julia, попри інший синтаксис.
Багато поширених операцій над векторами й матрицями належать до загальної галузі лінійної алгебри.
На сайті Exercism (наразі) є дві додаткові концепції, які поки що існують лише як документи, доки ми не розробимо вправи, які їх доповнять.
Можливо, у майбутньому зʼявиться третя концепція про факторизації матриць.
Однак варто мати на увазі, що «Основи лінійної алгебри» досить математичні й можуть підійти не всім студентам.
Пізніші концепції (розвʼязування рівнянь і факторизації) просунутіші й насправді розраховані лише на людей, яким зручно з математикою університетського рівня.
Перепрошуємо, але така вже природа цієї теми, і це важливий сценарій використання Julia.