Il y a longtemps, dans le concept Vecteurs, on a noté que « les tableaux peuvent être de taille arbitraire (seule la mémoire de ton matériel les limite) et avoir un nombre arbitraire de dimensions. »
Depuis, on a largement ignoré les tableaux à plus d'une dimension, histoire de ne pas compliquer les choses. Cette décision te paraîtra plus logique si tu essaies de lire la documentation de référence de Julia, dans toute sa complexité.
Cependant, les tableaux de dimension supérieure sont très, très importants en calcul scientifique, donc il faut apprendre à les comprendre.
Nomenclature : Suivant des siècles de précédent mathématique, on appelle les tableaux 1-D des Vectors et les tableaux 2-D des Matrices.
Les exemples de ce document seront surtout des matrices. Travailler avec 3 dimensions ou plus est syntaxiquement presque identique, mais la sortie est difficile à lire et prête à confusion (sur un écran 2-D).
Le type d'un tableau à N dimensions d'eltype T est Array{T, N}.
Par commodité, et pour rester cohérent avec la nomenclature mathématique, Julia définit quelques alias de type : Vector{T} pour Array{T, 1} et Matrix{T} pour 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
On a déjà créé beaucoup de vecteurs en mettant une liste séparée par des virgules entre crochets. On peut aussi utiliser des points-virgules comme séparateur.
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 on utilise un espace (ou une tabulation) comme séparateur, le résultat est différent.
julia> u = [1 2 3]
1×3 Matrix{Int64}:
1 2 3
Julia considère maintenant cela comme une Matrix 1×3 (dans d'autres contextes, on parlerait d'un row vector).
En général, les espaces joignent les choses horizontalement, et les points-virgules (ou les sauts de ligne) les joignent verticalement.
La référence à « des choses » est volontairement vague, car Julia essaiera de travailler avec ce que tu lui donnes.
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
Les fonctions hcat() et vcat() font la même chose, en précisant mieux qu'il s'agit de concaténations horizontale et verticale.
La généralisation aux dimensions supérieures est la fonction 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
Saisir des matrices explicites se fait commodément ligne par ligne, car cela correspond à l'intuition humaine (plus facile à regarder, pour les cultures à écriture horizontale) :
julia> m = [1 2 3; 4 5 6]
2×3 Matrix{Int64}:
1 2 3
4 5 6
Cependant, sache que Julia (comme Fortran, R et Matlab, mais contrairement à C/C++ ou NumPy) stocke les tableaux à N dimensions en ordre colonne par colonne, ce qui peut faire une énorme différence de performance si tu parcours les éléments dans une boucle. Aide le cache de ton processeur à t'aider !
# 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
L'exemple ci-dessus prend les entiers de 1 à 6 et remplit une Matrix 2×3 avec eux, colonne par colonne.
Il existe diverses fonctions utilitaires pour construire les types de tableaux courants (uniformes ou aléatoires).
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
Les amateurs de NumPy savent que np.linspace est une fonction très utilisée pour générer un tableau de longueur donnée, avec des bornes données et des valeurs régulièrement espacées.
On l'utilise typiquement comme axe des x d'un graphique, ou comme variable indépendante dans des modèles linéaires.
Julia n'a pas d'équivalent exact, mais la fonction très souple range() peut l'imiter en précisant les bornes inférieure et supérieure, plus l'argument nommé length.
Si tu travailles en local, avec accès au paquet Plots, tu peux exécuter ce code :
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])
Le StepRangeLen s'utilise comme un vecteur, y compris pour le convertir en colonne de matrice.
Voir aussi la fonction équivalente logrange(), pour générer des valeurs régulièrement espacées sur un axe logarithmique.
Pour un tableau 2-D, on utilise en général deux indices dans l'ordre [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
Le dernier exemple est peut-être surprenant : un seul indice n'est pas une erreur, et renvoie un seul élément.
L'explication remonte au point sur l'ordre colonne par colonne : Julia descend la colonne 1, puis la colonne 2, jusqu'à trouver le 3e élément en mémoire.
Fais attention : cela a quelques usages quand on écrit des bibliothèques à usage général, mais c'est le plus souvent source de confusion !
Un problème similaire se pose quand on interroge la taille d'un tableau.
length() donne le nombre total d'éléments, size() donne un tuple de ndims() éléments contenant la longueur de chaque dimension.
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
On peut facilement copier une sous-matrice.
Dans l'exemple ci-dessous, la fonction reshape() force un tableau aux dimensions données en le remplissant colonne par colonne, puis on en découpe une tranche.
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 : tout seul signifie « copier tout dans cette dimension ».
Utilise des vecteurs pour des lignes ou des colonnes non consécutives :
julia> m12[[1, 3], :] # rows 1 and 3
2×3 Matrix{Int64}:
1 5 9
3 7 11
Un : tout seul aplatit n'importe quel tableau en un Vector, colonne par colonne.
julia> m
2×3 Matrix{Int64}:
1 2 3
4 5 6
julia> m[:]
6-element Vector{Int64}:
1
4
2
5
3
6
On a déjà vu des fonctions d'agrégation comme sum() et maximum() appliquées à des collections 1-D, où elles opèrent sur tous les éléments et renvoient un résultat scalaire.
Cela fonctionne aussi dans les dimensions supérieures.
Cependant, on peut vouloir appliquer la fonction à une seule dimension, par exemple en sommant verticalement ou horizontalement pour renvoyer un tableau avec une singleton dimension de taille 1.
Pour cela, il existe un argument nommé facultatif 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
La valeur de dims est la dimension qui devient singleton, donc dims=1 => 1×3 et dims=2 => 2×1 dans les exemples ci-dessus.
Par extension, les tableaux de dimension supérieure peuvent réduire plusieurs dimensions, si dims est un tableau ou une plage (là encore, comprendre la sortie peut demander un peu de réflexion !).
L'argument nommé dims est courant dans les fonctions natives comme sum(), mais comment écrire un équivalent dans notre propre code ?
Une bonne réponse est d'utiliser des fonctions d'ordre supérieur comme reduce(), ce que nous verrons plus en détail dans un concept ultérieur.
Autrement, Julia fournit plusieurs fonctions qui permettent de traiter les tableaux à N dimensions comme s'il s'agissait de vecteurs de vecteurs imbriqués.
Pour les matrices, eachrow() et eachcol() sont pratiques, mais la fonction plus générale est eachslice(), qui gère des dimensions quelconques.
# 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]
Le type est un peu effrayant, mais uniquement parce qu'il s'agit d'une vue sur le tableau d'origine, ce qui permet de travailler dessus sans le copier. Les tableaux Julia peuvent atteindre des téraoctets, donc une copie peut être un cauchemar de performance !
Une vue comme celle-ci peut servir à parcourir des boucles, à faire du broadcasting, ou pour toutes les autres opérations vues jusqu'ici dans le programme.
De plus, les compréhensions peuvent être puissantes et polyvalentes avec un tableau en entrée. Des cas simples ont été mentionnés dans le concept Boucles, mais une discussion plus développée viendra dans un concept ultérieur.
Nous avons abordé le cas 1-D dans le concept Opérations sur les vecteurs.
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
L'opérateur pointé .- traite le singleton 0.5 comme équivalent à [0.5, 0.5, 0.5] grâce au broadcasting, qui l'étend pour correspondre aux dimensions, puis effectue la soustraction élément par élément.
Étendre cela aux dimensions supérieures n'est vraiment qu'une répétition du même principe.
Par exemple, quand on fait du broadcasting sur la multiplication d'un vecteur ligne 2x1 [1.0 1.5 2.0] par la matrice 2x3 [1 2 3; 4 5 6], cela équivaut à étendre [1.0 1.5 2.0] en [1.0 1.5 2.0; 1.0 1.5 2.0], puis à faire la multiplication élément par élément.
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
Remarque : pour le broadcasting, ce sont les dimensions qui ne sont pas de taille 1 qui doivent avoir la même taille (par exemple 2x3 Matrix .* 2x1 Matrix).
De plus, on peut appliquer une fonction à chaque élément d'une Matrix exactement comme on le fait avec 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
Tu peux avoir l'impression au début que le potentiel de confusion pour le programmeur augmente exponentiellement avec le nombre de dimensions, mais la pratique aide beaucoup. De plus, si tu connais bien les tableaux NumPy, cela se transpose assez bien à Julia, malgré la syntaxe différente.
Beaucoup d'opérations courantes sur les Vectors et les Matrices relèvent de l'algèbre linéaire.
Il existe (pour l'instant) deux concepts supplémentaires sur le site d'Exercism, qui restent uniquement documentaires tant que nous n'avons pas conçu d'exercices pour les accompagner.
Il y aura peut-être un jour un troisième concept sur les factorisations de matrices.
Cependant, sache que Les bases de l'algèbre linéaire est assez mathématique et ne conviendra peut-être pas à tous les apprenants.
Les concepts suivants (résolution d'équations et factorisations) sont plus avancés et ne s'adressent vraiment qu'aux personnes à l'aise avec les mathématiques de niveau universitaire.
Désolé, mais c'est la nature du sujet, et un cas d'usage important pour Julia.