Cours Python · Module 8: Data science
NumPy - le tableau qui calcule toute une colonne d'un seul coup
Dans cette leçon18
Tu reviens d'une tournée de cinq réserves et ton carnet contient cinq nombres : 120, 450, 85, 200 et 330 individus. Les questions que le chef d'expédition va te poser sont les mêmes à chaque saison. Combien cela fait-il en moyenne par réserve ? De combien chaque population augmentera-t-elle si la croissance atteint dix pour cent ? Quels troupeaux sont passés sous la barre des cent individus ? Une simple liste Python conservera ces nombres sans le moindre problème, mais elle ne répondra directement à aucune de ces questions.
Avant de sortir un nouvel outil du sac, il vaut la peine de voir où l'ancien cesse exactement de suffire. La fonction native sum additionne les éléments d'une liste et len compte combien il y en a - l'un divisé par l'autre donne la moyenne, et jusque-là tout va bien. La conversion, en revanche, se passe beaucoup moins bien : multiplier une liste par un entier ne multiplie pas les valeurs, cela recopie la liste entière, et multiplier par un nombre à virgule ne passe pas du tout et se termine par une exception TypeError.
1populations = [120, 450, 85, 200, 330]
2
3print(sum(populations) / len(populations)) # 237.0
4
5print(populations * 2)
6# [120, 450, 85, 200, 330, 120, 450, 85, 200, 330]
7
8# populations * 1.1
9# TypeError: can't multiply sequence by non-int of type 'float'Trois lignes, trois comportements complètement différents. La moyenne est correcte parce que nous l'avons calculée à la main, étape par étape. Le deuxième affichage montre que, pour une liste, le signe de multiplication veut dire "répète le contenu" : au lieu d'effectifs doublés, nous avons obtenu dix éléments là où il y en avait cinq. La troisième ligne est mise en commentaire pour une bonne raison : si tu l'exécutais, le programme s'arrêterait sur une erreur, car Python ne sait pas recopier une liste un nombre de fois à virgule. Remarque surtout ce qui manque ici : pas un seul effectif augmenté de dix pour cent. Pour l'obtenir, il faudrait écrire une boucle, et avec cinq mille relevés de colliers GPS une boucle Python devient douloureusement lente.
NumPy, pour Numerical Python, est la bibliothèque qui règle les deux problèmes d'un coup. Elle introduit son propre type de données - le tableau - dans lequel tous les éléments sont de la même nature et se suivent en mémoire les uns à côté des autres. Grâce à cela, une opération arithmétique s'exécute sur le tableau entier en une fois, sans boucle, et le calcul lui-même est effectué par du code compilé en langage C et non par l'interpréteur Python. NumPy est aussi le socle sur lequel repose tout le reste de l'équipement du chercheur de données : Pandas, Matplotlib et les bibliothèques d'apprentissage automatique calculent en dessous avec ces mêmes tableaux NumPy.
Ce que tu vas apprendre
- Installer NumPy et l'introduire dans un programme sous l'abréviation adoptée dans le monde entier.
- Construire des tableaux à partir de tes propres mesures et commander des tableaux tout prêts remplis de zéros, de uns et de suites régulières.
- Lire la fiche signalétique d'un tableau : sa forme, son nombre de dimensions, son nombre d'éléments et son type de données.
- Calculer les statistiques de tout le troupeau en un seul appel : moyenne, médiane, écart-type, minimum, maximum et somme.
- Convertir toutes les mesures d'un coup, sans boucle, et n'en retenir que celles qui remplissent une condition.
- Découper des morceaux de tableaux, changer leur forme et effectuer des opérations sur les matrices.
Installation et abréviation np
NumPy ne fait pas partie de la bibliothèque standard de Python, il faut donc commencer par l'installer. C'est le travail de pip, le gestionnaire de paquets de Python - un programme qui télécharge la bibliothèque depuis le réseau et la dépose dans ton environnement. La commande se tape dans le terminal et non dans un fichier de code, et tu ne l'exécutes qu'une seule fois par environnement, pas à chaque lancement du script. Si tu travailles dans Google Colab ou dans la distribution Anaconda, NumPy est déjà en place et tu peux tranquillement sauter cette étape.
1pip install numpyAprès cette commande, le paquet est sur le disque, mais aucun de tes fichiers de code n'est encore au courant de son existence. Pour introduire la bibliothèque dans le programme, il faut une instruction distincte, cette fois en Python même. Le mot-clé import charge la bibliothèque en mémoire et le mot-clé as lui donne un alias, c'est-à-dire un second nom, plus court, qui désigne exactement la même chose. L'usage veut que NumPy soit abrégé en deux lettres, np, et cette convention est si répandue que chaque exemple de la documentation, chaque réponse trouvée en ligne et chaque exercice de ce module la supposent acquise.
1import numpy as npCette unique ligne se compose de quatre morceaux et leur ordre est figé : d'abord le mot import, ensuite le nom complet du paquet numpy, puis le mot as, et enfin l'abréviation np. Retiens bien ce quatuor, car tu l'écriras au début de chaque script d'analyse. Rien d'autre ne s'est produit : aucun tableau n'a été créé et rien n'a été calculé. La variante import numpy est correcte elle aussi, mais il faut alors écrire numpy.array en entier à chaque appel, ce qui devient fatigant au trentième appel du script. En revanche import np ne fonctionnera pas : le paquet s'appelle numpy, donc Python s'arrêtera sur l'erreur ModuleNotFoundError: No module named 'np'. L'abréviation, c'est toi qui la donnes, dans cette ligne précise, et elle ne commence à exister qu'à partir de là.
Premier tableau : np.array
Maintenant que la bibliothèque est sous la main, transformons le carnet en tableau. La fonction qui s'en charge s'appelle array, ce qui veut dire tout simplement "tableau", et elle prend un seul argument : une liste de valeurs. Elle renvoie un objet de la classe ndarray, abréviation de n-dimensional array, autrement dit un tableau ayant un nombre quelconque de dimensions. Nous allons le vérifier tout de suite avec la fonction native type, qui répond à la question "de quelle nature est cet objet". Le nom np.array mérite d'être gravé dans ta mémoire sous cette forme exacte, car c'est le seul chemin correct qui mène d'une liste à un tableau.
1import numpy as np
2
3arr = np.array([1, 2, 3, 4, 5])
4
5print(arr) # [1 2 3 4 5]
6print(type(arr)) # <class 'numpy.ndarray'>La première chose qui saute aux yeux dans l'affichage, c'est l'absence de virgules. Une liste Python s'affiche sous la forme [1, 2, 3, 4, 5] et un tableau NumPy sous la forme [1 2 3 4 5] : les éléments ne sont séparés que par des espaces. C'est le moyen le plus simple de distinguer l'un de l'autre au premier coup d'oeil dans la console. Le second affichage confirme que nous avons obtenu un nouveau type d'objet et non une liste améliorée. Et un point tout aussi important : la liste que tu as passée entre parenthèses n'a pas changé d'un iota. NumPy en a copié les valeurs dans sa propre structure et a laissé l'original tranquille.
Autour de la création des tableaux circulent quelques noms qui sonnent bien mais qui n'existent pas. Il n'y a pas de fonction np.list, car la liste est un type de Python et non de NumPy. Il n'y a ni np.create ni np.new, même si de tels noms se rencontrent dans d'autres bibliothèques. Il n'y a pas non plus de numpy.new, et cela pour deux raisons indépendantes à la fois : une telle fonction n'existe pas dans le paquet, et après import numpy as np le nom numpy n'est de toute façon pas défini dans ton programme. Tu peux le vérifier sans me croire sur parole, avec la fonction hasattr, qui répond à la question "cet objet contient-il un nom qui ressemble à cela".
1print(hasattr(np, "array")) # True
2print(hasattr(np, "list")) # False
3print(hasattr(np, "create")) # False
4print(hasattr(np, "new")) # FalseUne seule vérité et trois mensonges, noir sur blanc. L'appel np.list([1, 2, 3]) se termine par le message AttributeError: module 'numpy' has no attribute 'list' et les deux autres variantes finiront de la même façon. Retiens donc un seul chemin : le tableau se crée à partir d'une liste avec np.array, et rien d'autre que np.array. L'appel de hasattr lui-même n'a rien construit ni rien modifié au passage - il a seulement vérifié ce qui se trouve dans la bibliothèque et ce qui n'y est pas. Pense aussi aux crochets à l'intérieur : np.array accepte une seule liste, donc np.array(1, 2, 3) donnera une erreur pour cause d'arguments trop nombreux.
Un tableau, un seul type
Le tableau NumPy ne se distingue pas d'une liste seulement par son affichage. Une liste Python peut héberger côte à côte un nombre, du texte et une valeur logique, alors qu'un tableau doit avoir un seul type commun à tous ses éléments - et c'est précisément cette contrainte qui lui donne sa vitesse, car l'ordinateur sait d'avance combien d'octets occupe chaque élément. Le type du tableau se lit dans l'attribut dtype, pour data type. Voyons ce qui se passe quand on ajoute un seul nombre à virgule à des entiers.
1whole = np.array([1, 2, 3])
2mixed = np.array([1, 2, 3.5])
3
4print(whole.dtype) # int64
5print(mixed.dtype) # float64
6print(mixed) # [1. 2. 3.5]Le premier tableau a reçu le type int64, c'est-à-dire des nombres entiers codés sur soixante-quatre bits. Le second a reçu float64, des nombres à virgule flottante, alors que tu n'y as mis que deux entiers et un seul nombre décimal. NumPy a choisi le type capable d'accueillir toutes les valeurs sans perte et a discrètement transformé le un et le deux en 1.0 et 2.0 : cela se voit dans le dernier affichage, aux points sans chiffre derrière. Tes données n'ont pas été abîmées, mais elles ont changé de forme, il vaut donc mieux y jeter un oeil. Petite remarque pratique : sur certains systèmes, principalement sous Windows, le type entier par défaut est int32 plutôt que int64. C'est le même nombre dans une plus petite boîte et cela ne change rien à ton code.
Cette règle du type unique paraît inoffensive tant que tu ne comptes que des mesures. À la leçon suivante, tu verras que pour un carnet de terrain, où l'effectif voisine avec le nom de l'espèce et l'information sur la menace, elle devient une limite dure - et c'est exactement pour cela que la bibliothèque Pandas est née.
La fiche signalétique du tableau : shape, ndim, size
Un tableau n'est pas forcément une seule rangée de nombres. En passant une liste de listes, tu obtiens un tableau à deux dimensions, c'est-à-dire une matrice : la première liste intérieure devient la première ligne, la deuxième la deuxième, et ainsi de suite. Trois attributs servent à décrire une telle structure. L'attribut shape renvoie un tuple, une suite de valeurs non modifiable écrite entre parenthèses rondes, dans l'ordre lignes, colonnes. L'attribut ndim dit combien de dimensions a le tableau, et size combien il compte d'éléments au total. Tous les trois sont des attributs et non des méthodes, tu les écris donc sans parenthèses.
1arr = np.array([[1, 2, 3], [4, 5, 6]])
2
3print(arr.shape) # (2, 3)
4print(arr.ndim) # 2
5print(arr.size) # 6
6print(arr.dtype) # int64Quatre nombres faciles à confondre, lisons-les donc lentement. La forme est (2, 3), ce qui se lit toujours de la même façon : deux lignes sur trois colonnes, jamais l'inverse. Tu as fourni deux listes intérieures de trois nombres chacune, il y a donc deux lignes et trois colonnes : la réponse (3, 2) décrirait un tableau tout à fait différent, avec trois lignes et deux colonnes. Le nombre 6, c'est size, le total des éléments, et non la forme. Regarde aussi les signes : la forme revient entre parenthèses rondes, parce que c'est un tuple, et non entre crochets, parce que ce n'est pas une liste. Le tableau lui-même est exactement le même après ces quatre affichages qu'avant : lire un attribut ne change rien.
Puisque la forme est un tuple, on peut y accéder par position, exactement comme dans une liste. L'écriture arr.shape[0] donne le nombre de lignes et arr.shape[1] le nombre de colonnes, et tu utiliseras la première sans arrêt pour savoir combien d'observations contient ton jeu de données.
1print(type(arr.shape)) # <class 'tuple'>
2print(arr.shape[0]) # 2
3print(arr.shape[1]) # 3
4
5# arr.shape()
6# TypeError: 'tuple' object is not callableLes deux dernières lignes sont mises en commentaire exprès et méritent d'être retenues comme un avertissement. Ajouter des parenthèses à shape se termine par une erreur, parce que tu essaies d'appeler comme une fonction quelque chose qui est déjà un tuple tout fait. C'est la confusion la plus fréquente au premier contact avec NumPy : les méthodes s'appellent avec des parenthèses, les attributs se lisent sans. Les trois premiers affichages confirment en revanche que shape contient bien un tuple Python ordinaire, avec tout ce que cela implique - tu peux le déballer dans deux variables ou le comparer à un autre tuple.
Des tableaux sur commande : zeros et ones
Tous les tableaux ne viennent pas de mesures. Très souvent tu as besoin d'une feuille vide d'une taille donnée, que tu rempliras ensuite avec des résultats - par exemple une grille de trois espèces sur quatre trimestres. La fonction np.zeros crée un tel tableau rempli de zéros, et son unique argument est un tuple de dimensions, de nouveau dans l'ordre lignes, colonnes. C'est un détail important : le tuple (3, 4) tout entier constitue un seul argument et non deux, et c'est pour cela que tu vois deux paires de parenthèses dans l'appel.
1grid = np.zeros((3, 4))
2
3print(grid)
4# [[0. 0. 0. 0.]
5# [0. 0. 0. 0.]
6# [0. 0. 0. 0.]]
7
8print(grid.shape) # (3, 4)
9print(grid.dtype) # float64
10print(type(grid)) # <class 'numpy.ndarray'>Le résultat est une matrice de trois lignes et quatre colonnes, soit douze zéros disposés en rectangle. Relis l'affichage et compte : trois lignes de quatre zéros chacune. La lecture inverse, c'est-à-dire une matrice quatre sur trois, décrirait un autre tableau, avec quatre lignes de trois zéros. Ce n'est pas non plus une liste de trois listes Python, même si les crochets se ressemblent : le dernier affichage montre noir sur blanc la classe numpy.ndarray. Il n'y a par ailleurs aucune erreur ici, passer un tuple comme argument unique est exactement ce que np.zeros attend. Remarque enfin les points à côté des zéros et le type float64 : les zéros sont à virgule flottante, car c'est le type par défaut de ces fonctions.
La fonction jumelle np.ones fonctionne à l'identique, sauf qu'elle remplit le tableau de uns, et elle sert quand tu construis des multiplicateurs ou des poids. À part se tient np.eye, qui crée une matrice identité : un tableau carré avec des uns sur la diagonale et des zéros partout ailleurs. Cette dernière prend un seul nombre, car une matrice identité a par définition autant de lignes que de colonnes.
1print(np.ones((2, 3)))
2# [[1. 1. 1.]
3# [1. 1. 1.]]
4
5print(np.eye(3))
6# [[1. 0. 0.]
7# [0. 1. 0.]
8# [0. 0. 1.]]Deux lignes de trois uns et un carré trois sur trois avec une diagonale de uns, exactement ce que la prose annonçait. La matrice identité joue en algèbre le même rôle que le nombre un dans une multiplication ordinaire : multiplier par elle ne change rien, c'est pourquoi on s'en sert comme point de départ pour inverser une matrice. Note que np.eye accepte un simple nombre, sans tuple, parce que la seconde dimension découle de la première. Aucune de ces fonctions n'a besoin de tes données : elles construisent toutes le tableau à partir de rien, sur la seule base de la taille demandée.
Suites de nombres : arange et linspace
Pour décrire un axe du temps ou les numéros d'observations successives, ce ne sont pas des zéros qu'il te faut mais une suite croissante de nombres. NumPy en propose deux fonctions et la différence entre elles est la source de bien des méprises. La fonction np.arange fonctionne comme le range natif : tu donnes un début, une fin et un pas, et elle remplit le tableau de valeurs espacées de ce pas, en excluant la fin de l'intervalle. La fonction np.linspace demande autre chose : un début, une fin et le nombre de points, puis elle choisit elle-même l'écart pour découper le segment en parts égales, la fin étant cette fois incluse dans le résultat.
1print(np.arange(0, 10, 2)) # [0 2 4 6 8]
2print(np.linspace(0, 1, 5)) # [0. 0.25 0.5 0.75 1. ]Le premier tableau compte cinq éléments et s'arrête à huit, car le dix, en tant que borne de l'intervalle, n'entre pas dans le résultat. Le second compte lui aussi cinq éléments, mais le dernier vaut exactement un, parce que linspace traite la fin comme une valeur à atteindre et non comme une limite. Le troisième argument veut dire quelque chose de complètement différent dans les deux fonctions : dans arange c'est l'écart entre les valeurs, dans linspace c'est le nombre de valeurs. Mon conseil : quand tu connais le pas et que tu veux numéroter des mesures, prends arange, et quand il te faut exactement cent points répartis régulièrement de zéro à un, prends linspace - sinon tu finiras par découper le segment à la main, avec une erreur d'arrondi au bout.
Données aléatoires : np.random.rand
Avant que les vrais relevés des colliers n'arrivent, un jeu de données inventé est bien utile pour tester tout le script. Les nombres aléatoires sont produits par le module np.random, et la plus simple de ses fonctions est rand, qui renvoie des valeurs comprises entre zéro et un. Ses dimensions se donnent en arguments séparés et non sous forme de tuple : c'est une exception à la règle que tu viens de voir avec np.zeros, et il est facile de trébucher dessus. Pour que les résultats soient reproductibles, nous fixons d'abord la graine du générateur avec np.random.seed, et pour raccourcir l'affichage nous utilisons la méthode round, qui arrondit toutes les valeurs du tableau d'un coup.
1np.random.seed(42)
2sample = np.random.rand(2, 3)
3
4print(sample.round(2))
5# [[0.37 0.95 0.73]
6# [0.6 0.16 0.16]]Six nombres aléatoires disposés en deux lignes de trois colonnes, chacun inférieur à un. Grâce à la graine fixée à quarante-deux, tu obtiendras exactement les mêmes valeurs sur ta machine : ce n'est pas de la magie, seulement un générateur reproductible, et c'est justement l'intérêt quand on teste. Sans l'appel à seed, les nombres seraient différents à chaque exécution et tu ne saurais pas reproduire le résultat d'il y a une semaine. Remarque que round n'a rien abîmé dans sample : elle a renvoyé un nouveau tableau arrondi, tandis que l'original conserve ses décimales complètes. Dans les projets neufs, je recommande l'écriture plus récente rng = np.random.default_rng(42), qui garde l'état du générateur dans un objet séparé au lieu de le stocker globalement, mais tu dois connaître la version avec seed, car tu la croiseras dans des milliers de scripts existants.
Les statistiques en un seul appel
Revenons au carnet de la première page de la leçon. Cinq effectifs, un tableau et toute une série de questions auxquelles il faut répondre. NumPy a une fonction distincte pour chacune. np.mean calcule la moyenne arithmétique, np.median la médiane, c'est-à-dire la valeur du milieu une fois les nombres rangés dans l'ordre, et np.std l'écart-type, qui dit à quel point les mesures s'éparpillent autour de la moyenne. S'y ajoutent trois évidences : np.min, np.max et np.sum. Chacune prend un tableau et renvoie un seul nombre.
1populations = np.array([120, 450, 85, 200, 330])
2
3print(np.mean(populations)) # 237.0
4print(np.median(populations)) # 200.0
5print(np.std(populations)) # 135.77923257994942
6print(np.min(populations)) # 85
7print(np.max(populations)) # 450
8print(np.sum(populations)) # 1185Six nombres qui décrivent tout le troupeau mieux que la liste brute. Une moyenne de deux cent trente-sept et une médiane de deux cents : l'écart vient de ce que les quatre cent cinquante éléphants tirent la moyenne vers le haut sans déplacer la médiane, qui ne regarde que ce qui se tient au milieu du rang. Un écart-type d'environ cent trente-six pour une moyenne de deux cent trente-sept est le signe que les réserves diffèrent énormément entre elles. Une précision pour être complet : np.std calcule par défaut l'écart-type de population, en divisant par le nombre de mesures, alors que Pandas au même endroit calcule l'écart-type d'échantillon, en divisant par ce nombre diminué de un. Pour ces mêmes cinq valeurs cela donne 151.81 au lieu de 135.78, ne t'étonne donc pas de voir deux résultats différents. Le tableau populations lui-même contient toujours ses cinq nombres après ces six affichages.
L'appel de la moyenne mérite d'être décomposé, car tu l'écriras plus souvent que n'importe quel autre. Il se compose de six éléments dans un ordre figé : np, ., mean, (, arr, ). D'abord l'abréviation de la bibliothèque, puis le point, puis le nom de la fonction, la parenthèse ouvrante, le nom du tableau et la parenthèse fermante. Il existe aussi une seconde forme, dans laquelle la moyenne est une méthode du tableau lui-même.
1arr = np.array([1, 2, 3, 4, 5])
2
3print(np.mean(arr)) # 3.0
4print(arr.mean()) # 3.0Le même résultat par deux chemins, et ce n'est pas un hasard : c'est littéralement la même opération écrite autrement. La forme np.mean(arr) a toutefois un avantage que je te recommande d'exploiter : elle fonctionne aussi sur une liste Python ordinaire, si bien que np.mean([1, 2, 3, 4, 5]) renverra également trois, alors que [1, 2, 3, 4, 5].mean() se terminera par une erreur, car la liste n'a pas cette méthode. Quand tu écris un script qui reçoit ses données de sources variées, cette robustesse vaut de l'or. Aucun de ces appels ne modifie le tableau : calculer une statistique est une pure lecture.
Vectorisation : une opération sur tout le tableau
Revenons maintenant à la question sur laquelle la liste a trébuché, celle de la croissance de dix pour cent. En NumPy, cela s'écrit exactement comme cela se dit : tu multiplies le tableau par 1.1. Une telle écriture porte un nom, la vectorisation : un nombre unique est étendu à tous les éléments du tableau et l'opération est effectuée séparément pour chacun d'eux, sans la moindre boucle. NumPy appelle ce mécanisme le broadcasting, autrement dit la diffusion.
1growth = populations * 1.1
2
3print(growth) # [132. 495. 93.5 220. 363. ]
4print(populations) # [120 450 85 200 330]
5print(growth.dtype) # float64Cinq nouveaux effectifs à partir d'une seule ligne de code, sans boucle et sans indices. Le deuxième affichage est ici le plus important : populations contient toujours les mesures d'origine, car une opération arithmétique renvoie un nouveau tableau et ne corrige pas l'ancien. Le troisième affichage montre l'effet secondaire de la multiplication par un nombre décimal : le résultat est passé au type float64, parce que quatre-vingt-treize et demi n'entre pas dans les entiers. Biologiquement, un demi-guépard n'a aucun sens, mais mathématiquement la multiplication fonctionne ainsi, et c'est à round de faire l'arrondi. Les autres opérations se comportent de la même façon : populations + 10 ajoutera dix à chaque valeur et populations / 2 coupera chacune en deux.
La vectorisation gère aussi une expression composée de plusieurs étapes à la fois. L'exemple classique est la normalisation, qui ramène les mesures à une échelle commune : de chaque valeur tu retranches la moyenne, puis tu divises par l'écart-type. Le résultat dit de combien d'écarts-types une population se situe au-dessus ou en dessous de l'ordinaire, ce qui permet de comparer des grandeurs pourtant complètement différentes, un effectif de troupeau et une pluviométrie en millimètres.
1normalized = (populations - np.mean(populations)) / np.std(populations)
2
3print(normalized.round(2)) # [-0.86 1.57 -1.12 -0.27 0.68]
4print(np.mean(normalized).round(10)) # 0.0
5print(np.std(normalized).round(10)) # 1.0Cinq nombres, cette fois de part et d'autre de zéro. Les valeurs négatives sont les réserves en dessous de la moyenne, les positives celles au-dessus, et l'éléphant avec son 1.57 se tient à un écart-type et demi au-dessus du lot. Les deux derniers affichages confirment que la transformation a fonctionné conformément à sa définition : un jeu normalisé a une moyenne de zéro et un écart-type de un. Toute l'opération tient en trois appels et une division menée sur le tableau entier d'un coup ; en Python pur, elle demanderait une boucle et deux passages sur les données. Le populations d'origine est là encore resté intact, car chacune de ces opérations a construit un nouveau tableau à côté.
Indexation et tranches
Pour atteindre une valeur isolée, tu procèdes exactement comme dans une liste Python : en donnant la position entre crochets, en comptant à partir de zéro. Un indice négatif compte depuis la fin, donc moins un désigne le dernier élément. Une tranche, en anglais slice, s'écrit avec deux nombres séparés par un deux-points : le premier est la position de départ, le second la position devant laquelle la tranche s'arrête, car la fin de l'intervalle n'entre pas dans le résultat. Omettre le nombre avant le deux-points veut dire "depuis le début", et après le deux-points "jusqu'à la fin". Un troisième nombre, après un second deux-points, indique le pas.
1arr = np.array([10, 20, 30, 40, 50])
2
3print(arr[0]) # 10
4print(arr[-1]) # 50
5print(arr[1:4]) # [20 30 40]
6print(arr[:3]) # [10 20 30]
7print(arr[::2]) # [10 30 50]Cinq écritures et cinq réponses différentes tirées du même tableau. La tranche arr[1:4] donne trois éléments et non quatre, car la position quatre sert de borne et n'entre pas elle-même dans le résultat : c'est la règle qui trompe tout le monde au début. L'écriture arr[:3] prend les positions zéro, un et deux, soit les trois premières valeurs. La dernière, arr[::2], parcourt tout le tableau un élément sur deux et renvoie donc dix, trente et cinquante. Remarque que tous ces affichages sont des tableaux NumPy et non des listes, tu peux donc en calculer la moyenne immédiatement.
Il existe cependant une différence avec les listes Python qui peut te coûter une heure de recherche de bug. Une tranche de liste crée une copie, alors qu'une tranche de tableau NumPy crée une vue, une fenêtre ouverte sur les mêmes données en mémoire. Modifier une valeur dans la vue modifie l'original. Vérifions-le sur un tableau à part, pour voir l'effet sans le moindre doute.
1readings = np.array([10, 20, 30, 40, 50])
2window = readings[1:4]
3window[0] = 999
4
5print(readings) # [ 10 999 30 40 50]
6
7safe = readings[1:4].copy()
8safe[0] = 111
9
10print(readings) # [ 10 999 30 40 50]
11print(safe) # [111 30 40]La première partie du code abîme les données en silence : l'affectation dans window a remplacé une valeur de readings, alors que personne n'a touché ce tableau directement. La seconde partie montre le remède : la méthode copy crée une copie indépendante de la tranche et, à partir de là, les deux tableaux mènent leur vie séparément, ce que l'on voit au fait que readings n'a pas bougé après l'affectation de 111. Mon conseil est ici sans ambiguïté : si tu comptes écrire quoi que ce soit dans une tranche, ajoute copy tout de suite, sans te demander si la vue posera problème à cet endroit précis. En simple lecture, la vue est préférable, car elle ne recopie pas la mémoire et travaille à toute vitesse.
Masque booléen : choisir selon une condition
La question des troupeaux passés sous la barre des cent individus se résout en NumPy en deux temps, et les deux méritent d'être vus séparément. Comparer un tableau à un nombre ne renvoie pas un unique True : la comparaison est faite pour chaque élément séparément et le résultat est un tableau de valeurs logiques de même longueur. Un tel tableau s'appelle un masque booléen. Remis entre crochets, il ne retient que les éléments auxquels correspond True.
1print(populations < 100)
2# [False False True False False]
3
4print(populations[populations < 100]) # [85]
5print(populations) # [120 450 85 200 330]Le premier affichage n'est que la décision : cinq réponses par oui ou par non, dans le même ordre que les mesures, et pas un seul effectif. Le masque ne conserve pas les données, seulement le verdict porté sur chaque élément. Le deuxième affichage l'applique et laisse la seule réserve sous le seuil, les quatre-vingt-cinq guépards. Le troisième affichage dit le plus important : populations contient toujours ses cinq valeurs, car le filtrage a renvoyé un nouveau tableau. Ce mécanisme, exactement le même, tu le reverras à la leçon suivante pour filtrer des tables dans Pandas ; autant le comprendre maintenant, sur cinq nombres, plutôt que plus tard, sur trois mille lignes.
Les tableaux à deux dimensions à la loupe
Dans une matrice, une seule position ne suffit pas : il faut désigner la ligne et la colonne. Tu les donnes entre les mêmes crochets, séparées par une virgule, toujours dans l'ordre ligne, colonne. Un deux-points seul à la place de l'une des deux veut dire "toutes" ; c'est ainsi que [:, 0] se lit "toutes les lignes, colonne zéro", autrement dit la première colonne entière.
1matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
2
3print(matrix[0, 1]) # 2
4print(matrix[:, 0]) # [1 4 7]
5print(matrix[1, :]) # [4 5 6]Trois questions et trois formes de réponse différentes. La première renvoie un nombre isolé, car tu as désigné exactement une case : ligne zéro, colonne un, donc le deux. La deuxième renvoie un tableau de trois nombres pris de haut en bas ; c'est une colonne, tu vois donc dans le résultat des valeurs issues de trois lignes différentes. La troisième renvoie la ligne du milieu en entier. L'écriture matrix[0][1] fonctionne aussi, car elle extrait d'abord la ligne puis l'élément à l'intérieur, mais je recommande la version avec la virgule : elle est plus courte, ne crée pas de tableau intermédiaire en chemin et permet de découper les deux dimensions à la fois, par exemple matrix[0:2, 1:3].
Changer de forme : reshape, flatten et T
Les mêmes nombres peuvent se ranger de plusieurs manières et NumPy permet de le faire sans réécrire les données. La méthode reshape réarrange le tableau selon une forme donnée, à une condition : le produit des nouvelles dimensions doit correspondre au nombre d'éléments. La méthode flatten fait l'inverse, elle aplatit n'importe quel tableau en une seule rangée. L'attribut T est la transposition, l'échange des lignes et des colonnes, et comme shape il s'écrit sans parenthèses.
1arr = np.arange(12)
2matrix = arr.reshape(3, 4)
3
4print(matrix)
5# [[ 0 1 2 3]
6# [ 4 5 6 7]
7# [ 8 9 10 11]]
8
9print(matrix.flatten()) # [ 0 1 2 3 4 5 6 7 8 9 10 11]
10print(matrix.T.shape) # (4, 3)Douze nombres de zéro à onze se sont rangés en trois lignes de quatre, remplies ligne par ligne de gauche à droite. L'aplatissement a ramené une seule rangée dans le même ordre, et la transposition a retourné la forme de (3, 4) en (4, 3) : une nouvelle occasion de te rappeler que le premier nombre de la forme, ce sont toujours les lignes. L'appel arr.reshape(5, 4) se terminerait par l'erreur ValueError: cannot reshape array of size 12 into shape (5,4), car cinq fois quatre font vingt alors qu'il n'y a que douze éléments. Il existe un raccourci commode : mets moins un à la place de l'une des dimensions et NumPy la calculera lui-même, si bien que arr.reshape(3, -1) donnera la même forme (3, 4). Le tableau arr lui-même reste après tout cela une rangée plate de douze nombres.
Opérations sur les matrices
Avec deux matrices apparaît une distinction fondamentale en mathématiques et qui, dans le code, tient à un seul signe. L'addition et la multiplication écrites avec les signes habituels travaillent élément par élément : NumPy met en vis-à-vis les cases occupant la même position. La multiplication matricielle, celle de l'algèbre linéaire, s'écrit avec l'opérateur @ ou avec la fonction np.dot et se calcule tout autrement : chaque élément du résultat naît de la multiplication d'une ligne par une colonne, suivie de la somme des produits.
1A = np.array([[1, 2], [3, 4]])
2B = np.array([[5, 6], [7, 8]])
3
4print(A + B)
5# [[ 6 8]
6# [10 12]]
7
8print(A * B)
9# [[ 5 12]
10# [21 32]]
11
12print(A @ B)
13# [[19 22]
14# [43 50]]Compare le deuxième et le troisième résultat, car c'est l'un des pièges les plus fréquents dans le code d'apprentissage automatique. La multiplication par l'astérisque a donné cinq dans le coin supérieur gauche, soit un fois cinq, un simple appariement de cases. La multiplication par l'opérateur @ a donné dix-neuf au même endroit, car elle a calculé un fois cinq plus deux fois sept. Les deux sont des opérations valables, elles répondent seulement à des questions différentes, et le programme ne signalera aucune erreur si tu choisis celle qui n'était pas la tienne. Retiens : l'astérisque agit sur des paires de cases, @ fait la multiplication matricielle. La fonction np.dot(A, B) renvoie exactement la même chose que A @ B, mais l'opérateur est plus lisible et c'est lui que je recommande dans du code neuf.
Algèbre linéaire : le module linalg
Les outils d'algèbre plus sérieux se trouvent dans le sous-module np.linalg, pour linear algebra. La fonction det calcule le déterminant d'une matrice, un seul nombre qui dit entre autres si la matrice peut être inversée. La fonction inv renvoie la matrice inverse, celle qui, multipliée par l'originale, donne la matrice identité, celle aux uns sur la diagonale. Un déterminant égal à zéro signifie que l'inverse n'existe pas, on le vérifie donc en pratique avant d'inverser.
1A = np.array([[1, 2], [3, 4]])
2
3print(np.linalg.det(A)) # -2.0000000000000004
4print(round(np.linalg.det(A), 2)) # -2.0
5print(np.linalg.inv(A))
6# [[-2. 1. ]
7# [ 1.5 -0.5]]Le premier affichage est une bonne leçon d'humilité face à l'ordinateur : le déterminant de cette matrice vaut exactement moins deux, et NumPy montre moins deux et encore un tout petit peu. Ce n'est pas un défaut de la bibliothèque, mais la conséquence de l'écriture des fractions en binaire, où beaucoup de nombres n'ont pas d'équivalent exact. C'est pourquoi on ne compare pas des résultats à virgule flottante avec un signe d'égalité : on les arrondit ou on les vérifie avec une tolérance, et le deuxième affichage montre la manière la plus simple. La matrice inverse est sortie conforme au manuel, et A après ces deux appels est toujours la même matrice que celle que nous avons saisie. Dans ce même module habite encore np.linalg.eig, qui renvoie deux résultats à la fois, les valeurs propres et les vecteurs propres de la matrice. Tu la croiseras quand tu arriveras à la réduction de dimensions en apprentissage automatique.
Pourquoi c'est rapide
Il vaut la peine, pour finir, de comprendre d'où vient l'avantage de NumPy, car cela explique toutes ses bizarreries. Une liste Python stocke des pointeurs vers des objets éparpillés en mémoire, et chacun de ces objets porte sa propre information de type. Un tableau NumPy garde des nombres bruts les uns à côté des autres, dans un bloc de mémoire continu, et le type est décrit une seule fois pour tout le tableau : c'est précisément ce dtype que nous regardions plus haut. Grâce à cela, la boucle sur les éléments s'exécute en code C compilé et le processeur peut traiter plusieurs nombres par cycle. De là vient l'exigence du type unique, de là la forme figée, de là les vues plutôt que les copies pour les tranches ; chacune de ces règles achète de la vitesse. Sur cinq nombres tu ne verras pas la différence, sur cinq millions c'est l'écart entre une seconde et un quart d'heure.
Récapitulatif
- La bibliothèque s'introduit en quatre mots dans un ordre fixe :
import,numpy,as,np. - Seul
np.arraycrée un tableau à partir d'une liste ; les fonctionsnp.list,np.createetnp.newn'existent pas dans NumPy. np.zeros((3, 4))renvoie un tableaundarrayde trois lignes et quatre colonnes, rempli de zéros. Le tuple de dimensions est un seul argument, il n'y a donc aucune erreur ici.- La forme se lit dans l'attribut
shape, sans parenthèses, et le résultat est un tuple dans l'ordre lignes, colonnes : pournp.array([[1,2,3], [4,5,6]])c'est(2, 3). Le nombre6, c'estsizeet non la forme. - La moyenne s'écrit en six morceaux :
np,.,mean,(,arr,). À côté d'elle se tiennentmedian,std,min,maxetsum. - Une opération arithmétique sur un tableau s'applique à tous les éléments d'un coup et renvoie un nouveau tableau, en laissant l'original intact.
- Un masque booléen, par exemple
populations < 100, renvoie un tableau deTrueet deFalse, et remis entre crochets il filtre les données. - Une tranche de tableau est une vue sur les mêmes données et non une copie ; quand tu comptes y écrire, ajoute
copy. - L'astérisque multiplie élément par élément,
@effectue la multiplication matricielle. Ce sont deux opérations différentes et non deux écritures de la même chose.
À la prochaine leçon, nous ajouterons à ces nombres des noms de colonnes et des types différents dans une même table, car un carnet de terrain, c'est bien plus qu'une colonne de mesures. Pour l'instant, retiens une chose : le tableau NumPy est le mousqueton du pisteur - un seul type, une seule forme, aucun ornement, mais tout le troupeau recalculé d'un seul geste.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Comment crée-t-on un tableau NumPy à partir d'une liste Python [1, 2, 3, 4, 5] ?
2. Que renverra np.zeros((3, 4)) ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Remets dans l'ordre l'import standard de la bibliothèque NumPy :
- Éditeur de code
Crée un tableau 'populations' avec les valeurs [120, 450, 85, 200, 330] et utilise np.mean() pour calculer la moyenne.
- Clique dans l'ordre
Clique sur les boutons dans l'ordre pour calculer la moyenne d'un tableau : np.mean(arr)