Parcours
/
Julia
Julia
/
Programme
/
Opérations vectorielles
Op

Opérations vectorielles en Julia

2 exercices

À propos de Opérations vectorielles

Dans le concept Vectors, nous avons dit que « les tableaux sont au cœur du langage Julia » et qu'un vecteur est un tableau à une dimension.

Cela étant, on peut raisonnablement espérer que le langage offre de nombreuses façons polyvalentes et puissantes de faire des choses avec des vecteurs, quoi que cela veuille dire.

Une remarque sur la terminologie : bien que ce document parle beaucoup de « vecteurs », une grande partie s'applique aussi à tout type itérable : les intervalles, les tuples, les ensembles, et divers autres.

Les fonctions qui attendent un vecteur en entrée

Certaines fonctions très simples prennent un vecteur en entrée et, pour une entrée à une dimension, renvoient un résultat scalaire.

v = [2, 3, 4]
length(v)  # => 3
sum(v)  # => 9

Quand on arrivera au concept sur les tableaux multidimensionnels, il deviendra plus clair qu'il s'agit d'une réduction de dimension et non forcément du renvoi d'un scalaire. Si cela n'a aucun sens pour toi, ne t'en préoccupe pas pour l'instant.

Il existe beaucoup d'autres fonctions de ce type. Voir le concept Statistics pour quelques exemples.

Il existe aussi des fonctions qui agissent sur plusieurs vecteurs, comme la (très utile) zip.

julia> z = zip( 1:3, ['a', 'b', 'c'], ["I", "make", "tuples"] )
zip([1, 2, 3], ['a', 'b', 'c'], ["I", "make", "tuples"])

# convert iterator to vector
julia> collect(z)
3-element Vector{Tuple{Int64, Char, String}}:
 (1, 'a', "I")
 (2, 'b', "make")
 (3, 'c', "tuples")

zip() prend un nombre arbitraire d'entrées de type vecteur et renvoie un itérateur de tuples.

En général, les entrées ont toutes la même longueur. Si l'une est plus courte, les autres sont tronquées à la longueur de la plus courte : peut-être ce que tu voulais, mais le plus souvent un bug dans ton code.

L'arithmétique

Suppose que tu aies un vecteur numérique et que tu veuilles soustraire 0,5 à chaque valeur.

julia> v = [1.2, 1.5, 1.7]
3-element Vector{Float64}:
 1.2
 1.5
 1.7

julia> v - 0.5
ERROR: MethodError: no method matching -(::Vector{Float64}, ::Float64)

Ça ne marche pas. Et si on soustrait un autre vecteur ?

julia> v - [0.5, 0.5, 0.5]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

Ça marche, mais c'est assez fastidieux et gourmand en mémoire à mesure que les vecteurs s'allongent.

Selon l'endroit où tu es arrivé dans le programme, tu peux sans doute penser à d'autres approches :

  • Écrire une boucle, même si ce serait verbeux et poussif.
  • Utiliser une compréhension : [x - 0.5 for x in v] donne le résultat voulu (à la Python).
  • Utiliser une fonction d'ordre supérieur : map(x -> x - 0.5, v) fonctionne aussi (à la Haskell, même si c'est courant dans beaucoup de langages).

Heureusement, Julia a un point « magique » qui résout ce problème très simplement : v .- 0.5 suffit.

La section suivante explique pourquoi.

Broadcasting

Donc, v - 0.5 échoue mais v .- 0.5 réussit, et il faut comprendre ce que fait ce point.

Deux choses, qui se combinent pour donner le résultat voulu.

1) Une application élément par élément

Premièrement, ajouter un point avant un opérateur infixe signifie « appliquer cette opération à chaque élément séparément ».

De même, ajouter un point après le nom d'une fonction la « vectorise », même si la fonction a été écrite pour des entrées scalaires.

julia> sqrt.([1, 4, 9])
3-element Vector{Float64}:
 1.0
 2.0
 3.0

Soit dit en passant, les opérateurs infixes ne sont en réalité que du sucre syntaxique pour la fonction sous-jacente.

Cela signifie que, par exemple, [1, 5, 10] .% 3 est traduit en mod.([1, 5, 10], 3) par l'interprète, et la syntaxe mod. s'exécute ensuite (les deux versions renvoient [1, 2, 1]).

2) L'expansion des singletons

On a vu dans un exemple précédent qu'on peut soustraire des vecteurs de même longueur, mais comprends bien que .- est un opérateur plus sûr que -, car il rend explicite l'intention d'opérer élément par élément.

julia> v .- [0.5, 0.5, 0.5]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

Et pour des vecteurs de longueurs différentes ?

julia> v .- [0.5, 0.5]
ERROR: DimensionMismatch: arrays could not be broadcast to a common size

julia> v .- [0.5,]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

En général, des longueurs différentes provoquent une erreur, sauf si l'une vaut 1 (techniquement, une dimension « singleton »).

Les singletons comme [0.5,] ou simplement 0.5 sont automatiquement étendus à la longueur nécessaire par répétition. C'est le cœur du broadcasting.

Si l'usage de la mémoire lié à cette « répétition » t'inquiète, rassure-toi : elle est implémentée de manière très efficace, sans réellement copier les valeurs en mémoire.

Les programmeurs qui connaissent le broadcasting dans d'autres langages noteront que l'approche de Julia est (en grande partie) similaire à celle de NumPy, mais bien moins tolérante que R en cas de tailles incompatibles.

Le broadcasting en place

Si l'usage de la mémoire est une préoccupation, les opérations en place sont un moyen courant de chercher à réduire les allocations. Cependant, les opérations de broadcasting des exemples ci-dessus créent un nouveau Vector au lieu de modifier l'original.

julia> v = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v
3-element Vector{Int64}:
 1
 2
 3

Pour modifier v en place, il faut que la modification passe par le broadcasting.

julia> v .= v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v .+= 1  # equivalent operation to above
3-element Vector{Int64}:
 3
 4
 5

julia> v
3-element Vector{Int64}:
 3
 4
 5

Mais attention ! Le point avant l'opérateur d'affectation .= est important.

julia> v = [1, 2, 3];

julia> v = v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v
3-element Vector{Int64}:
 2
 3
 4

Cela semble avoir fonctionné de la même manière, mais ici v .+ 1 a créé un nouveau vecteur [2, 3, 4], puis l'a affecté à la variable v, laissant le vecteur initial [1, 2, 3] en mémoire en attente d'être récupéré par le ramasse-miettes. Au final, cela utilise deux fois plus de mémoire que l'exemple précédent, qui réutilise la mémoire allouée pour le vecteur initial.

De même, il existe des subtilités liées au broadcasting avec des vecteurs de même taille. Par exemple, avec deux vecteurs v et w de même taille :

  • v .= w produit une copy de w à l'emplacement mémoire de v. Les modifications ultérieures de v n'affectent pas w, et inversement.
  • v = w crée un autre pointeur vers l'emplacement mémoire de w, portant le nom v. Les modifications ultérieures de v se répercutent sur w, et inversement.

Les opérateurs sans point : une mise en garde

Cette sous-section est assez chargée en mathématiques, donc on ne s'attend pas à ce que la plupart des apprenants la comprenne vraiment. Elle constitue toutefois un avertissement utile qui pourra t'aider à déboguer lorsque tu verras des messages d'erreur inattendus.

julia> v = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> v * v
ERROR: MethodError: no method matching *(::Vector{Int64}, ::Vector{Int64})

# look, no commas
julia> u = [1 2 3]
1×3 Matrix{Int64}:
 1  2  3

julia> u * v
1-element Vector{Int64}:
 14

julia> v * u
3×3 Matrix{Int64}:
 1  2  3
 2  4  6
 3  6  9

Si tu as un bagage en algèbre linéaire, alors (1) tu n'es pas un utilisateur typique d'Exercism (mais tu es très bienvenu ici !) et (2) tu reconnaîtras peut-être que v est un vecteur colonne, u un vecteur ligne, u * v le produit scalaire et v * u le produit extérieur. Julia suit les règles des mathématiques, en ceci comme en tout.

Pour tous les autres : comprends simplement pourquoi nous te recommandons de toujours utiliser les opérateurs pointés pour les calculs élément par élément : v .* v fonctionne exactement comme tu t'y attends, pour donner [1, 4, 9].

L'indexation

La sélection des éléments d'un vecteur par leur numéro d'indice a été abordée dans les concepts précédents.

a = collect('A':'Z')  # => 26-element Vector{Char}

# index with an integer
a[2]  # => 'B'

# index with a range
 a[12:2:18]  # => ['L', 'N', 'P, 'R']
 
 # index with another vector
 a[ [1, 3, 5] ]  # => ['A', 'C', 'E']

L'indexation logique

Il est aussi possible de sélectionner les éléments qui satisfont une certaine expression logique (techniquement, un « prédicat »). Cela demande généralement du broadcasting.

julia> a[a .< 'D']
3-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)

Pour des expressions plus complexes, les points ont tendance à se multiplier (mais ils sont petits et faciles à taper).

julia> a[a .< 'D' .|| a .> 'W']
6-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
 'X': ASCII/Unicode U+0058 (category Lu: Letter, uppercase)
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'Z': ASCII/Unicode U+005A (category Lu: Letter, uppercase)

Rappel : le « vecteur » peut en fait être n'importe quel itérable ordonné approprié, comme un intervalle :

julia> n = 3:10
3:10

julia> n[isodd.(n)]
4-element Vector{Int64}:
 3
 5
 7
 9

En interne, le prédicat est converti en un BitVector, qui sert ensuite d'indice.

julia> condition = a .< 'D'
26-element BitVector:
 1
 1
 1
 0
 # display truncated

julia> a[condition]
3-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
Modifie via GitHub Le lien s'ouvre dans une nouvelle fenêtre ou un nouvel onglet

Apprends Opérations vectorielles