Mucho antes, en el concepto Vectores, señalamos que «los arrays pueden ser de cualquier tamaño (sujetos solo a las restricciones de memoria de tu hardware), y pueden tener cualquier cantidad de dimensiones».
Desde entonces, hemos ignorado en gran medida los arrays con más de una dimensión, solo para mantener las cosas simples. Esta decisión tendrá más sentido si intentas leer los documentos de referencia de Julia, con toda su complejidad.
Sin embargo, los arrays de más dimensiones son muy, muy importantes en la computación científica, así que necesitamos entenderlos.
Nomenclatura: Siguiendo siglos de precedente matemático, nos referimos a los arrays de 1-D como Vectors y a los arrays de 2-D como Matrices.
Los ejemplos de este documento serán en su mayoría matrices. Trabajar con 3 o más dimensiones es sintácticamente casi idéntico, pero la salida es difícil y confusa de leer (en una pantalla 2-D).
El type de un array N-dimensional con eltype T es Array{T, N}.
Por comodidad, y para mantener la consistencia con la nomenclatura matemática, Julia define algunos alias de tipo: Vector{T} para Array{T, 1} y Matrix{T} para 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
Hemos creado muchos vectores colocando una lista separada por comas entre corchetes. Los puntos y coma también se pueden usar como separador.
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
Si usamos un espacio (o un tabulador) como separador, el resultado es distinto.
julia> u = [1 2 3]
1×3 Matrix{Int64}:
1 2 3
Ahora Julia define esto como una matriz de 1×3 (en otros contextos, la llamaríamos row vector).
En general, los espacios unen cosas de forma horizontal; los puntos y coma (o los saltos de línea) unen cosas de forma vertical.
La referencia a «cosas» es deliberadamente vaga, ya que Julia intentará trabajar con lo que le des.
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
Existen las funciones hcat() y vcat() que hacen lo mismo, dejando más explícito que se trata de concatenaciones horizontales y verticales.
La generalización a más dimensiones es la función 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
Escribir matrices explícitas se hace cómodamente en orden por filas, porque eso encaja con la intuición humana (es más fácil de mirar, para culturas con texto horizontal):
julia> m = [1 2 3; 4 5 6]
2×3 Matrix{Int64}:
1 2 3
4 5 6
Sin embargo, ten en cuenta que Julia (como Fortran, R y Matlab, pero a diferencia de C/C++ o NumPy) almacena los arrays N-dimensionales en orden por columnas, y esto puede marcar una gran diferencia de rendimiento si recorres los elementos en un bucle. ¡Ayuda a la caché de tu CPU para que te ayude!
# 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
El ejemplo anterior toma los enteros del 1 al 6 y llena con ellos una matriz de 2×3 por columnas.
Hay varias funciones de utilidad para construir tipos comunes de array (uniformes o aleatorios).
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
Quienes usan NumPy sabrán que np.linspace es una función muy usada para generar un array de una longitud determinada, con extremos especificados y valores espaciados uniformemente.
Normalmente se usa como el eje x de una gráfica, o como la variable independiente en modelos lineales.
Julia no tiene un equivalente exacto, pero la muy flexible función range() puede imitarla si especificas el límite inferior y el superior, además del argumento de palabra clave length.
Cualquier persona que trabaje de forma local, con acceso al paquete Plots, puede ejecutar este código:
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])
El StepRangeLen se puede usar como un vector, incluida su conversión a una columna de matriz.
Consulta también la función equivalente logrange(), para generar valores espaciados uniformemente en un eje logarítmico.
Para un array 2-D, generalmente usamos dos índices en el orden [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
El último ejemplo quizá te sorprenda: un índice único no es un error, y devuelve un solo elemento.
La explicación se remonta al comentario sobre el orden por columnas: Julia recorre la columna 1 hacia abajo, luego la columna 2, hasta encontrar el tercer elemento en memoria.
Ten cuidado: esto tiene algunos usos cuando escribes bibliotecas de propósito general, pero es más probable que resulte confuso.
Surge un problema similar cuando consultas el tamaño de un array.
length() da el número total de elementos; size() da una tupla de ndims() elementos con la longitud de cada dimensión.
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
Podemos copiar fácilmente una submatriz.
En el ejemplo de abajo, la función reshape() fuerza un array a las dimensiones especificadas llenándolo por columnas, y luego lo cortamos.
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
Un : por sí solo significa «copia todo en esta dimensión».
Usa vectores para filas o columnas no consecutivas:
julia> m12[[1, 3], :] # rows 1 and 3
2×3 Matrix{Int64}:
1 5 9
3 7 11
Un : por sí solo aplana cualquier array a un Vector, por columnas.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m[:]
6-element Vector{Int64}:
1
4
2
5
3
6
Ya hemos visto funciones de agregación como sum() y maximum() aplicadas a colecciones de 1-D, donde operan sobre todos los elementos y devuelven un resultado escalar.
Esto también funciona en dimensiones superiores.
Sin embargo, puede que queramos aplicar la función a una sola dimensión, por ejemplo sumando hacia abajo o a lo ancho para devolver un array con una singleton dimension de tamaño 1.
Para esto existe el argumento de palabra clave opcional 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
El valor de dims es la dimensión que se vuelve singleton, así que dims=1 => 1×3 y dims=2 => 2×1 en los ejemplos de arriba.
Por extensión, los arrays de más dimensiones pueden reducir varias dimensiones, si dims se establece a un array o un rango (otra vez, entender la salida puede requerir algo de reflexión).
El argumento de palabra clave dims es común en funciones integradas como sum(), pero ¿cómo escribimos algo equivalente en nuestro propio código?
Una buena respuesta es usar funciones de orden superior como reduce(), y esto se verá con cierto detalle en un concepto posterior.
Como alternativa, Julia ofrece varias funciones que nos permiten tratar arrays N-dimensionales como si fueran vectores de vectores anidados.
Para matrices, eachrow() y eachcol() son convenientes, pero la función más general es eachslice(), que puede manejar dimensiones arbitrarias.
# 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]
El tipo es un poco alarmante, pero solo porque esto es una vista del array original, que nos permite trabajar con él sin copiarlo. Los arrays de Julia pueden llegar a medir terabytes, así que copiarlos puede ser una pesadilla de rendimiento.
Una vista como esta se puede usar para hacer bucles, broadcasting, o todas las demás operaciones que vimos en el temario hasta ahora.
Además, las comprensiones pueden ser potentes y versátiles cuando la entrada es un array. Los casos simples se mencionaron en el concepto Bucles, pero habrá una discusión más amplia en un concepto posterior.
Vimos el caso de 1-D en el concepto Operaciones con vectores.
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
El operador con punto .- trata el 0.5 singleton como equivalente a [0.5, 0.5, 0.5] al hacerle broadcasting para que coincidan las dimensiones, y después hace la resta elemento a elemento.
Extender esto a dimensiones superiores es, en realidad, solo más de lo mismo.
Por ejemplo, al hacer broadcasting de la multiplicación de un vector fila de 2x1 [1.0 1.5 2.0] con la matriz de 2x3 [1 2 3; 4 5 6], es equivalente a hacer broadcasting de [1.0 1.5 2.0] a [1.0 1.5 2.0; 1.0 1.5 2.0] y después hacer la multiplicación elemento a elemento.
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
Nota: son las dimensiones no singleton las que deben coincidir en tamaño (por ejemplo, 2x3 Matrix .* 2x1 Matrix) para el broadcasting.
Además, se puede hacer broadcasting de una función a cada elemento de una Matrix exactamente como se hace con un 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
Al principio puede que sientas que el margen de confusión para quien programa crece de forma exponencial con el número de dimensiones, pero la práctica ayuda mucho. Además, la familiaridad con los arrays de NumPy se traslada bastante bien a Julia, a pesar de la sintaxis diferente.
Muchas operaciones comunes sobre vectores y matrices entran dentro del encabezado general de álgebra lineal.
Actualmente hay dos conceptos adicionales en el sitio web de Exercism, que son solo documentos hasta que podamos diseñar ejercicios que los acompañen.
Es posible que en el futuro haya un tercer concepto sobre factorizaciones de matrices.
Sin embargo, ten en cuenta que Fundamentos de álgebra lineal es bastante matemático y puede que no les venga bien a todas las personas.
Los conceptos posteriores (resolución de ecuaciones y factorizaciones) son más avanzados, y están realmente pensados para quienes se sienten cómodos con matemáticas de nivel universitario.
Disculpas, pero esta es la naturaleza del tema: es un caso de uso importante para Julia.