NumPy + TD1

Vous pouvez tester en ligne les exemples grâce aux sites suivant :

Vous pouvez retrouver les thèmes abordés dans cette page dans la documentation de NumPy au chapitre NumPy fundamentals . La libraire NumPy offre des facilités d’écriture permettant d’effectuer des traitements complexes sur des tableaux en une seule ligne de code. Cette syntaxe compacte permet d’atteindre des temps d’exécution très performants en Python ce qui n’aurait pas été possible si l’on avait du écrire de nombreuses boucles imbriquées.

Les tableaux NumPy

Pour stocker des données (images, sons, textes…), nous utilisons des tableaux multidimensionnels. Par exemple, un lot de 50 000 images de résolution 36x36 sera stocké dans un tableau de dimension 50 000x36x36. Les listes Python, même si elles peuvent faire office de tableaux multidimensionnels, ne sont pas des objets optimisés en mémoire et en vitesse, elles doivent donc être évitées. Pour stocker des données, le choix se porte vers les tableaux de la librairie NumPy.

Les tableaux NumPy offrent des performances équivalentes aux tableaux du langage C, mais à travers l’interpréteur Python. Contrairement aux listes Python qui sont des containers dynamiques stockant des objets de types hétérogènes, les tableaux Numpy ont une taille fixe et stockent des valeurs de même type. La performance et la célébrité grandissante de NumPy ont amené les tableaux NumPy à devenir les briques de base d’autres librairies comme TensorFlow ou OpenCV. Étant quasiment devenu un standard, les tenseurs des librairies Keras, TensorFlow ou Pytorch ont repris les principes de fonctionnement des tableaux NumPy.

Note

La documentation des tableaux NumPy est disponible en ligne, elle est claire et bien organisée.

Création

De nombreuses fonctions permettent de créer et d’initialiser un tableau:

import numpy as np

np.zeros(shape=(3,2))     # crée un tableau de taille (3,2) rempli de float64 valant 0
>> array([[0., 0.],[0., 0.],[0., 0.]])

np.zeros(shape=(3,2),dtype=np.float32)     # crée un tableau de taille (3,2) rempli de float32 valant 0
>> array([[0., 0.],[0., 0.],[0., 0.]])

np.zeros(shape=(3,2),dtype=np.int32)     # crée un tableau de taille (3,2) rempli de int32 valant 0
>> array([[0, 0],[0, 0],[0, 0]])

np.ones(shape=(3,2))            # crée un tableau de taille (3,2) rempli de 1
>> array([[1., 1.], [1., 1.], [1., 1.]])

np.empty(shape=(3,2))     # crée un tableau sans initialiser les valeurs

np.random.randint(low=3, high=8, size=(2,4))   # tableau avec valeurs aléatoires parmi 3, 5, 5, 6 et 7.
>> array([[5, 5, 3, 6],  [7, 4, 6, 3]])

np.random.rand(3,2)    # tableau avec valeurs aléatoires dans l'intervalle [0,1[ avec distribution uniforme,
>> array([[0.5337, 0.42173, 0.66, 0.012], [0.2962, 0.88, 0.08020, 0.061]])


np.random.normal(loc=0,scale=1,size=(3,2))  # tableau avec valeurs aléatoires suivant une loi normale(0,1)
>> array([[-0.9797,  1.54788 ], [ 0.1402, -0.217], [ 0.3213571, -0.751978]])

np.arange(10)   # crée un tableau contenant les nombres de 0 à 10 (non compris)
>> array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Avertissement

La taille d’un tableau lors de sa création est donnée par des arguments ayant des noms différents: size pour np.random.randint(), shape pour np.ones() ou d0, d1, … pour np.random.rand().

On peut créer un nouveau tableau par recopie d’un tableau existant avec la fonction copy(). Les données du nouveau tableau sont alors indépendantes du tableau précédent :

Il est préférable de toujours fournir le type des données car par défaut Numpy utilise des float64. Pour connaître le format des données utilisé dans un tableau on peut utiliser le paramètres dtype: :

import numpy as np

T = np.zeros(shape=(3,2))
print(T.dtype)
>> float64
import numpy as np
A = np.zeros(10)
B = A.copy()         # création du tableau B par recopie du tableau A
B[1] = 7

A
>> array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])

B
>> array([0., 7., 0., 0., 0., 0., 0., 0., 0., 0.])

Conversion

Les fonctions de conversion et de redimensionnement sont listées dans la documention au chapitre Array manipulation routines.

Rappel sur les listes

Bien que nous n’utilisons pas les listes pour stocker les données d’apprentissage, on en trouve souvent dans les tutoriaux car elles permettent d’écrire facilement des données. En Python, les listes sont définies avec des paires de crochets englobant une série de nombres.

L = [ 1, 2, 3, 5, 8]         # liste
L = [ [1,2,3,4], [5,6,7,8]]  # liste de listes

Conversion liste/tableau

Il est possible de convertir une liste en tableau et réciproquement :

A = np.array(L)    # convertit une liste Python en NumPy Array
L = A.tolist()     # convertit un NumPy array en liste Python

Conversion de type

Il est possible de copier et de caster les valeurs d’un tableau grâce à la fonction astype :

A = np.array([0,0,0,0])

A.astype(np.float32)

>> array([0., 0., 0., 0.], dtype=float32)

Changement de dimension

Il est possible de modifier les dimensions d’un tableau si le nouveau tableau comporte exactement le même nombre d’éléments que l’ancien.

A = np.arange(6)
>> array([0, 1, 2, 3, 4, 5])

B = A.reshape(2,3)          # A n'est pas modifié,
B
>> array([[0, 1, 2],
      [3, 4, 5]])

C = B.reshape((3,2))
C
>> array([[0, 1],
          [2, 3],
          [4, 5]])

Note

Si vous lisez les éléments des trois tableaux de gauche à droite et de haut en bas, vous lisez les mêmes valeurs dans le même ordre : 0, 1, 2, 3, 4 et 5.

Quelle fonction crée un tableau NumPy initialisé avec des 1 ? A: init() - B: one() - C: set() - D: ones()

Quelle fonction crée un tableau NumPy initialisé avec des 0 ? A: init() - B: zeros() - C: set() - D: zero()

Quel nom d’argument permet de sélectionner le type des données ? A: type - B: dtype - C: id - D: np

Quel est le nom du package contenant la fonction rand() ?

Quelle fonction crée un tableau NumPy depuis une liste Python ? A: fromList() - B: arange() - C: array() - D: initFrom()

Quel est le nom de la fonction de conversion de type ? A: astype() - B: reshape() - C: array() - D: from()

Quelle fonction permet de changer les dimensions d’un tableau ? A: setshape() - B: reshape() - C: range() - D: redim()

Peut-on effectuer un reshape((2,3)) sur un tableau A = np.arange(5) ?

Les vues

Dans un problème de classification d’images, la base d’images sera chargée dans un tableau Numpy. Ainsi, les tableaux NumPy permettent de stocker plusieurs Gigas de données. Lorsque l’on extrait des données d’un tableau, par exemple en prenant 20% de la base pour crée un set de validation, on prend le risque de dédoubler les données. Afin d’éviter cette situation, un mécanisme spécial a été mis en place afin d’optimiser les ressources mémoire. Ainsi, un tableau NumPy a deux états possibles : soit il dispose de son propre buffer de données, soit il fait un lien vers le buffer d’un autre tableau et dans ce cas précis, le tableau Numpy s’appelle une vue. La non-connaissance de ce mécanisme peut entraîner de nombreux problèmes pour le développeur, surtout dans nos exercices. En effet, comme la vue et le tableau associé à la vue partagent le même buffer de données, toute modification depuis la vue modifie le tableau d’origine et inversement. Pour tester si un tableau NumPy correspond à une vue, on utilise l’attribut base du tableau qui indique si un lien existe vers au autre tableau. Ainsi le test x.base is not None permet de savoir si le tableau est une copie.

x = np.arange(4)
>> array([0, 1, 2, 3])

x.base is not None
>> False                  # ce tableau dispose de son propre buffer

y = x.view()              # y est une vue du tableau x
y
>> array([0, 1, 2, 3])

y.base is not None
>> True                   # y est est une vue

y[0] = 8
y
>> array([8, 1, 2, 3])

x
>> array([8, 1, 2, 3])    # le tableau x sous-jacent a été aussi modifié

x[3] = 9
array([8, 1, 2, 9])

y                         # et la vue y a été impactée
>> array([8, 1, 2, 9])

Prenons maintenant le cas de la fonction reshape(). Cette fonction permet de construire à partir d’un tableau existant un nouveau tableau de dimensions différentes. Dans ce cas précis, les données ne changent pas, ainsi la fonction reshape() a tout intérêt à retourner une vue et, en pratique, dans les cas simples, la fonction reshape() va effectivement fournir une vue.

x = np.arange(4)
>> array([0, 1, 2, 3])

y = x.reshape((2,2))
>> array([[0, 1],
          [2, 3]])

y.base is not None
>> True              # y est une vue

y[0,0] = 9
y
>> array([[9, 1],
          [2, 3]])

x                    # le tableau référencé a été modifié dans la foulée
>> array([9, 1, 2, 3])

Mais, dans des situations plus complexes (des vues sur des sélections de vues), il est possible que la fonction reshape() n’arrive pas à construire une vue et qu’elle doive se résoudre à construire un tableau ayant son propre buffer de données. La documentation de NumPy exprime clairement cette situation : The NumPy.reshape() function creates a view where possible or a copy otherwise. Pour la première fois en informatique, on se retrouve dans une situation assez inhabituelle : une fonction retourne un nouvel objet qui peut être une copie (donc indépendant) ou une vue (similaire à une référence). Ce choix est guidé par le bon sens, mais la décision de ce mécanisme semble complètement opaque. En ce qui concerne l’apprentissage, ne nous rencontrerons généralement pas de problème, car en effet, les tableaux sont souvent accédés en lecture, cette ambiguïté reste alors sans conséquence. Dans ce chapitre, les fonctions retournant toujours une copie ou toujours une vue seront précisées à titre indicatif.

Note

Pour les tenseurs des librairies d’IA comme Pytorch ou TensorFlow, la logique des vues est reprise à l’identique car la taille mémoire d’un GPU est encore plus contrainte que celle d’un ordinateur. Les besoins d’optimisation mémoire sont donc tout autant présent.

Propriétés

Taille

La propriété shape nous permet de connaître la taille d’un tableau NumPy:

import numpy as np

A = np.array([ [[0,0,0,0],[1,1,1,1],[2,2,2,2]], [[0,0,0,0],[1,1,1,1],[2,2,2,2]] ])
print(A.shape)

==> (2, 3, 4)

Si nous examinons la liste de listes de listes Python : [ [ [0,0,0,0], [1,1,1,1], [2,2,2,2] ], [ [0,0,0,0], [1,1,1,1], [2,2,2,2] ] ], le plus bas niveau est constitué de listes de 4 entiers. Cette taille correspond à la valeur la plus à droite dans la dimension (2,3,4). Plus on imbrique de niveaux, plus on obtient de dimensions :

  • 1 liste de 4 entiers est associée à un tableau de taille (4)

  • 3 listes de listes de 4 entiers sont associées à un tableau de taille (3,4).

  • 2 listes de 3 listes de listes de 4 entiers sont associées à un tableau de taille (2,3,4).

A noter que la fonction size retourne le nombre d’éléments présents dans le tableau.

Type de données

Pour connaître le type utilisé pour stocker les valeurs, il est possible d’utiliser la propriété dtype :

import numpy as np

A = np.array( [0,1,2,3] )
print(A.dtype)

==> int32

La fonction reshape crée toujours une vue.

La fonction reshape crée toujours une copie.

Les vues permettent d’optimiser l’utilisation de la mémoire.

Les vues sont implémentées dans les tableaux NumPy mais pas pour les tenseurs de PyTorch ou de Tensorflow.

Il n’existe aucun moyen de savoir si un tableau NumPy est associé à une vue.

Quelle fonction/propriété n’appartient pas à un tableau Numpy : dim, size ou shape ?

Quelle propriété retourne le type des données stockées dans un tableau : type, dtype, ntype, xtype ?

La documentation officielle NumPy

On peut trouver dans la documentation officielle les très nombreuses fonctions fournies par la librairie NumPy classées par catégorie :

Le paramètre axis

Les tableaux NumPy fournissent de nombreuses fonctions de calcul sur leurs valeurs internes. Par exemple, la fonction membre mean() calcule la moyenne des valeurs d’un tableau :

import numpy as np
A = np.array([[1,2,3,4],[6,7,8,9]])
A.mean()

>> 5.0

Jusque là rien d’impressionnant, cependant, la fonction mean() grâce à l’argument axis permet d’effectuer le calcul dans une direction donnée, ce qui est très intéressant :

import numpy as np
A = np.array([  [[0,0,0],[2,2,2],[4,4,4],[6,6,6]], [[1,1,1],[3,3,3],[5,5,5],[7,7,7]] ])
A.shape
>> (2, 4, 3)

B = A.mean(axis=2)
>> array([[0., 2., 4., 6.],
          [1., 3., 5., 7.]])

B.shape
>> (2,4)

La taille du tableau étant (2,4,3), l’axe 0 correspond au 2, l’axe 1 au 4 et l’axe 2 au 3. Ainsi l’argument axis=2 calcule la moyenne des éléments présents sur l’axe 2 ie la dernière dimension :

\[axis=2 \rightarrow B[x_0,x_1] = \underset{x_2}{\mathrm{mean}} (A[x_0,x_1,x_2])\]

Voici d’autres exemples pour \(axis = 0\) et \(axis = 1\) :

B = A.mean(axis=0)
>> array([[0.5, 0.5, 0.5],      # B[0,0] = (A[0,0,0] + A[1,0,0]) / 2
          [2.5, 2.5, 2.5],
          [4.5, 4.5, 4.5],
          [6.5, 6.5, 6.5]])
B.shape
>> (4, 3)

B = A.mean(axis=1)
>> array([[3., 3., 3.],         # B[0,0] = (A[0,0,0] + A[0,1,0] + A[0,2,0] + A[0,3,0]) / 4
          [4., 4., 4.]])
B.shape
>> (2,3)

Cette logique fonctionne aussi pour plusieurs dimensions, ainsi pour \(axis = (1,2)\), nous avons :

\[axis=(1,2) \rightarrow B[x_0] = \underset{x_1,x_2}{\mathrm{mean}} (A[x_0,x_1,x_2])\]
B = A.mean(axis=(1,2))
>> array([3., 4.])               # taille (2,4,3) = > (2)

B.shape
>> (2)

Note

De nombreuses fonctions proposent un paramètre axis, on peut citer déjà toutes celles similaires à mean : max, min, sum ou std (écart type).

Pour un tableau T = np.array([[1,2,3,4],[5,6,7,8]]), que retourne : T.max(axis=1) ?

réponse A: [5 6 7 8] ou réponse B: [4 8]

Pour un tableau T = np.array([[0,1,2],[3,4,5],[6,7,8]]), que faut-il écrire pour obtenir : [2 5 8] ?

réponse A: T.max(axis=0) ou réponse B: T.max(axis=1)

Pour T = np.array([ [[11,12],[13,14]], [[21,22],[23,24]], [[31,32],[33,34]] ]), que faut-il écrire pour obtenir [ [13,14], [23,24], [33,34] ] comme réponse ?

réponse A: T.max(axis=0) ou réponse B: T.max(axis=1) ou réponse C: T.max(axis=2)

Que faut-il écrire pour obtenir [33, 34] comme réponse avec le même tableau T ?

réponse A: T.max(axis=(0,1)) ou réponse B: T.max(axis=(1,2)) ou réponse C: T.max(axis=(0,2))

Indexation

Vous pouvez retrouver ce chapitre dans dans la documentation officielle

Indexation simple

L’indexation sur un tableau se fait en utilisant un tuple ou l’écriture compacte d’un tuple (sans les parenthèses) :

import numpy as np

A = np.array([ [[0,1,2],[3,4,5],[6,7,8]], [[10,11,12],[13,14,15],[16,17,18]] ])


print(A[(0,1,2)])  # avec un tuple
=> 5

print(A[0,1,2])    # forme allégée sans les ( )
=> 5

Avertissement

Faîtes attention, car une autre syntaxe sans virgule existe : A[0][1][2]. Elle fournit le même résultat, mais elle a un rôle différent que nous vous présenterons juste après.

Note

L’index le plus à droite correspond au plus bas niveau dans la liste.

Il est possible d’utiliser des valeurs négatives, dans ce cas, la valeur -1 correspond au dernier élément, -2 à l’avant dernier et ainsi de suite :

import numpy as np

A = np.array([[0,1,2],[3,4,5],[6,7,8]])

A[1,-1]
>> 5

A[-1,1]
>> 7

A[-1,-1]
>> 8

Sous-tableau

Si la dimension du tuple est inférieure à la dimension du tableau, alors l’indexation permet d’extraire un sous-tableau correspondant à une vue :

import numpy as np
A = np.array([[[1,2],[3,4]],[[6,7],[8,9]]])

B = A[0]
>> array([[1,2],[3,4]])

B.base is not None
>> True              # B est une vue

C = A[0,1]
>> array([3, 4])

C.base is not None
>> True              # C est une vue

Plage d’indices - slicing

Il est possible d’utiliser la syntaxe start:stop ou start:stop:step pour représenter une plage d’indices. Ce mécanisme, appelé slicing, permet d’éviter l’utilisation de boucles et facilite la lecture du code. Le tableau retourné correspond toujours à une vue.

A = np.arange(10)
>> array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

A[0:5]
>> array([0, 1, 2, 3, 4])

A[0:8:2]
>> array([0, 2, 4, 6])

A[:5]                      # tous les éléments jusqu'à l'indice 5 compris
>> array([0, 1, 2, 3, 4])

A[5:]                      # tous les éléments après l'indice 5
>> array([5, 6, 7, 8, 9])

A[:]                       # Le symbole : représente tous les indices possibles
>> array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Avec des tableaux 2 dimensions :

A = np.array(  [ [10,11,12,13],
                 [20,21,22,23],
                 [30,31,32,33],
                 [40,41,42,43] ] )
A[1:3,1:3]        # sélection des A[i,j] avec 1≤i<3 et 1≤j<3

>> array([[21, 22],
          [31, 32]])


A[1:3,1:3] = 99   # affectation des indices sélectionnées

Ou encore :

>> array([ [10, 11, 12, 13],
           [20, 99, 99, 23],
           [30, 99, 99, 33],
           [40, 41, 42, 43]])

A = np.array([ [1,2,3,4],
               [6,7,8,9] ])

A[:,:2]                 # toutes les lignes, tous les indices < 2

>> array([[1, 2], [6, 7]])

Ou encore :

A = np.array( [ [10,11,12,13,14,15],
                [20,21,22,23,24,25],
                [30,31,32,33,34,35],
                [40,41,42,43,44,45] ] )

A[:, 0:6:2 ] = 99    # affection des colonnes 0 2 4

==>  array ([ [99, 11, 99, 13, 99, 15],
              [99, 21, 99, 23, 99, 25],
              [99, 31, 99, 33, 99, 35],
              [99, 41, 99, 43, 99, 45]])

on peut utiliser à la fois la technique des sous-tableaux avec le slicing :

A = np.array([ [1,2,3,4],
               [6,7,8,9] ])

A[1]        # 2eme ligne
A[1,:]      # idem
>> array([6, 7, 8, 9])

A[0,2:]     # 1ere ligne, tous les indices >=2
>> array([3, 4])


A[1,:2]     # 2ème ligne, tous les indices < 2
>> array([6, 7])

Indexage avancé

Cette technique se déclenche lorsque l’indexation se fait à partir de listes. Elle permet de sélectionner certains éléments \((x_i,y_i)\) du tableau en transmettant un liste de coordonnées \((x_i)\) et un liste de coordonnées \((y_i)\) pour désigner tous les index \((x_i,y_i)\) traités par l’opération :

A = np.array( [ [10,11,12,13,14,15],
                [20,21,22,23,24,25],
                [30,31,32,33,34,35],
                [40,41,42,43,44,45] ] )

A[[0,0,3,3],[0,5,0,5]]       # extrait les valeurs aux positions [0,0], [0,5], [3,0] et [3,5]

>> array([10, 15, 40, 45])

A[[0,0,3,3],[0,5,0,5]] = 99  # affecte les valeurs aux positions [0,0], [0,5], [3,0] et [3,5]

>> [[99 11 12 13 14 99]
    [20 21 22 23 24 25]
    [30 31 32 33 34 35]
    [99 41 42 43 44 99]]

Note

Cette technique retourne toujours une copie contrairement à la syntaxe des sous-tableaux qui retourne toujours une vue.

On peut cumuler la technique d’indexage avancé avec d’autres :

A = np.array( [ [10,11,12,13,14,15],
                [20,21,22,23,24,25],
                [30,31,32,33,34,35],
                [40,41,42,43,44,45] ] )

A[[1,2],:]   # extrait la 2ème et la 3ème ligne du tableau

>>array([[20, 21, 22, 23, 24, 25],
         [30, 31, 32, 33, 34, 35]])

A[:,[1,2]]   # extrait la 2ème et la 3ème colonne du tableau

array([[11, 12],
       [21, 22],
       [31, 32],
       [41, 42]])

Avertissement

Comment savoir si la librairie NumPy a implémenté la syntaxe à laquelle vous pensez ? Réponse difficile, car c’est du cas par cas, mieux vaut tester pour être sûr !

Pour A = np.array([ [0,1],[2,3],[4,5]]), donnez l’indexation de A retournant la valeur 2 sous la forme : x,y.

Pour A = np.array([[[0,1],[2,3],[4,5]]]), donnez l’indexation de A retournant la valeur 5 (indices positifs).

Pour un tableau 1D, si A[-3] désigne la même cellule que A[3], quelle est la taille de ce tableau ?

Pour le tableau A : [[0,1,2],[4,5,6],[7,8,9]], quels sont les chiffres extraits par : A[[0,2,0,2],[0,0,2,2]]. Donnez les chiffres séparés par des espaces.

TD1 Numpy Array

Le source du Notebook

Vous devez effectuer ce TD et le faire valider à votre responsable de salle.