Créer des tableaux NumPy (ndarray) et les inspecter (shape, dtype)
Expliquer pourquoi la vectorisation bat une boucle Python (et le mesurer)
Appliquer le broadcasting (diffusion de formes) et lire son message d’erreur
Indexer avec des tranches, l’indexation avancée et les masques booléens
Utiliser les réductions (sum, mean, std) avec la sémantique de axis
Rendre un tirage aléatoire reproductible avec np.random.default_rng(seed)
Prérequis
Python 3.10+
Connaissance de base des listes Python
Aucune expérience préalable en calcul scientifique requise
Durée estimée : 60-75 minutes
Bienvenue dans ce notebook dédié à NumPy, la bibliothèque fondamentale pour le calcul scientifique en Python. NumPy n’est pas qu’une boîte à outils pour faire des maths : c’est le contrat d’interface entre Python et le calcul numérique performant. Pandas, scikit-learn, PyTorch et TensorFlow reposent tous sur des ndarray en coulisses. Apprendre NumPy sérieusement, c’est se donner un socle qui durera dans toute la suite de la data science.
Plan du notebook (7 sections + 7 exercices) :
#
Section
Concept clé
Question pratique
1
Qu’est-ce que NumPy ?
écosystème + ndarray
“Pourquoi cette lib domine la data science Python ?”
2
Création et inspection
shape, dtype, nbytes
“Comment créer un tableau et vérifier son type ?”
3
Liste Python vs ndarray
mémoire contiguë vs pointeurs
“Pourquoi NumPy est-il ~25× plus rapide ?”
4
Vectorisation
opérations C natives
“Comment éviter la boucle Python ?”
5
Broadcasting
diffusion de formes
“Comment ajouter un bonus à chaque ligne ?”
6
Indexation
tranches, avancée, masques
“Comment filtrer un tableau proprement ?”
7
Réductions + axis
sum, mean, std par axe
“Quelle dimension ma réduction écrase-t-elle ?”
8
Reproductibilité
default_rng(seed)
“Comment rendre un tirage aléatoire reproductible ?”
Coût total : < 5 secondes (imports + une boucle de 1M éléments mesurée contre sa version vectorisée).
Référence canonique : C. R. Harris et al., Array programming with NumPy, Nature 585:357-362, 2020.
Qu’est-ce que NumPy ?
NumPy (Numerical Python) est une bibliothèque qui fournit un objet de tableau multi-dimensionnel puissant, des routines pour des opérations rapides sur les tableaux, et des outils pour l’algèbre linéaire, les transformées de Fourier, et les nombres aléatoires.
C’est le socle sur lequel de nombreuses autres bibliothèques de data science (commme Pandas) sont construites.
Repère bibliographique. NumPy est décrit et formalisé dans l’article de référence C. R. Harris et al., Array programming with NumPy, Nature, 585:357-362, 2020 (doi:10.1038/s41586-020-2649-2). Cet article présente l’objet ndarray, l’écosystème de tableaux N-dimensionnels et les fondations d’interopérabilité sur lesquelles reposent Pandas, SciPy, scikit-learn et la quasi-totalité de la data science Python.
Pourquoi NumPy est le socle, pas juste une bibliothèque parmi d’autres :
NumPy fournit trois piliers que toute la data science Python consomme :
L’objet ndarray : un tableau N-dimensionnel typé et contigu en mémoire. C’est l’unité de stockage commune à toutes les libs numériques (Pandas enveloppe un ndarray, scikit-learn prend un ndarray, PyTorch a un tensor qui partage l’API).
Les opérations vectorisées : a + b, a.sum(), np.dot(A, B) sont exécutées en C natif via BLAS/LAPACK, pas en Python. C’est la source du facteur ~25× par rapport à une boucle for.
L’écosystème d’algèbre linéaire : np.linalg (inverse, valeurs propres, SVD), np.fft (transformée de Fourier), np.random (générateurs). Toute la data science numérique repose sur ces primitives.
Sortie attendue (cellule code[3]) : un message console avec la version de NumPy et un tableau [1, 2, 3, 4, 5] avec son type numpy.ndarray.
Coût : < 0.1 seconde (import + création d’un tableau de 5 entiers).
Création et inspection d’un tableau
L’objet principal de NumPy est le ndarray (n-dimensional array). On le crée à partir d’une liste Python, mais il n’est pas une liste : c’est un bloc de données contigu et typé. Deux attributs essentiels à lire en premier : shape (les dimensions) et dtype (le type des éléments).
Trois méthodes de création fondamentales :
np.array([1, 2, 3]) : à partir d’une liste Python (la plus courante).
np.zeros((3, 4)) : un tableau rempli de zéros avec une forme donnée.
np.arange(0, 10, 2) : comme range() Python mais retourne un ndarray.
Trois attributs à lire en premier :
arr.shape : un tuple des dimensions (e.g. (3, 4) pour 3 lignes × 4 colonnes).
arr.dtype : le type des éléments (e.g. int64, float64, bool).
arr.nbytes : la taille en octets (e.g. 96 pour 12 entiers int64).
Pourquoi dtype est critique : NumPy stocke les valeurs dans un bloc contigu typé. Un int8 consomme 1 octet, un int64 en consomme 8. Pour des données massives, le choix du dtype peut diviser la mémoire par 8 (au prix d’un risque d’overflow). C’est la différence clé avec une liste Python, qui stocke des pointeurs vers des objets Python (24 octets par entier sur CPython 3.10+).
Sortie attendue (cellule code[3]) : un tableau array([1, 2, 3, 4, 5]), son type = numpy.ndarray, sa shape = (5,), son dtype = int64.
Coût : < 0.05 seconde (création + lecture d’attributs).
import numpy as np# La version de NumPy utilisée dans ce notebookprint("Version de NumPy :", np.__version__)# Création d'un tableau (ndarray) à partir d'une liste Pythonma_liste = [1, 2, 3, 4, 5]mon_array = np.array(ma_liste)print("\nTableau :", mon_array)print("Type :", type(mon_array))print("shape :", mon_array.shape)print("dtype :", mon_array.dtype)
Version de NumPy : 2.2.6
Tableau : [1 2 3 4 5]
Type : <class 'numpy.ndarray'>
shape : (5,)
dtype : int64
Lecture de la création et inspection d’un tableau (cellule code[3]) :
La cellule ci-dessus illustre les 4 opérations fondamentales sur un ndarray :
np.array([1, 2, 3, 4, 5]) : crée un tableau 1D à partir d’une liste Python. Le dtype est inféré : int64 sur Linux/Mac, int32 sur Windows (dépend de la plateforme — un piège classique de portabilité).
type(mon_array) : retourne numpy.ndarray. C’est l’identité du type — toutes les opérations NumPy sont définies sur cet objet.
mon_array.shape : retourne (5,) — un tuple d’une dimension. Notez la virgule : c’est un tuple 1D, pas une simple parenthèse.
mon_array.dtype : int64 (ou int32 sur Windows). Le type des éléments est figé à la création ; pour changer, il faut créer un nouveau tableau.
Pourquoi le dtype est inféré à la création : NumPy ne fait pas de conversion implicite. Si on mélange int et float, NumPy upcast vers le type le plus large (e.g. int + float → float). C’est une règle simple mais utile pour comprendre pourquoi np.array([1, 2, 3.0]) produit un tableau float64.
Sortie attendue : un message console montrant la version de NumPy, le tableau, son type (numpy.ndarray), sa forme ((5,)) et son dtype.
Coût : < 0.05 seconde (import + création + 4 inspections).
Liste Python vs ndarray : la même valeur, deux mémoires
Une liste Python est un tableau de pointeurs vers des objets int dispersés en mémoire ; un ndarray stocke les valeurs dans un seul bloc (nbytes donne la taille exacte des données). C’est ce qui rend NumPy à la fois plus compact et plus rapide.
L’intuition : imaginez un parking. Une liste Python, c’est un parking où chaque place contient l’adresse d’une voiture garée ailleurs (pointeurs + objets dispersés). Un ndarray, c’est un parking où toutes les voitures sont garées côte à côte dans un bloc unique (stockage contigu).
Conséquences pratiques :
Mémoire : une liste de 5 entiers consomme ~104 octets (pointeurs + overhead des int Python). Un ndarray de 5 entiers int64 consomme 40 octets. Pour un million d’éléments, c’est ~80 Mo vs ~400 Mo — un facteur 5.
Cache : le stockage contigu est cache-friendly. Quand le CPU charge un élément, il charge aussi les voisins (ligne de cache), et les opérations suivantes sont gratuites. Sur une liste dispersée, chaque accès peut rater le cache.
Vectorisation : un bloc contigu se prête aux opérations SIMD (Single Instruction, Multiple Data) du CPU, où une seule instruction traite plusieurs données en parallèle. C’est la source du facteur ~25× mesuré en Section 4.
Sortie attendue (cellule code[5]) : deux print montrant la différence de taille mémoire entre une liste Python et un ndarray pour les mêmes données.
Coût : < 0.1 seconde (création + sys.getsizeof + nbytes).
Note culturelle : cette différence est la raison historique pour laquelle NumPy a été créé. Les premières versions (Numerical Python, 1995) visaient à fournir un conteneur compact pour les tableaux de la communauté scientifique, qui sinon souffrait de la lenteur des listes Python.
Une opération vectorisée s’applique à un tableau entier en un appel, exécuté en C natif via BLAS/LAPACK — sans boucle for et sans passer par l’interpréteur Python pour chaque élément. C’est le déplacement sémantique clé : penser « tableau entier » plutôt que « élément par élément ». La cellule suivante le mesure sur un million d’éléments (le chiffre qui justifie tout le reste, mesure ici a ~x25).
Le déplacement sémantique : passer de « pour chaque élément de a, faire X » (impératif Python, boucle lente) à « a X » (déclaratif NumPy, opération vectorisée rapide). C’est le même genre de déplacement que passer d’une boucle for en C à un appel memcpy() ou à une instruction SIMD.
Pourquoi c’est rapide :
Pas d’interpréteur Python : chaque opération d’une boucle for traverse l’interpréteur Python (lookup de bytecode, dispatch dynamique, etc.). En NumPy, une seule fonction C traite tous les éléments d’un coup.
BLAS/LAPACK en coulisses : pour l’algèbre linéaire, NumPy délègue à des bibliothèques C/Fortran optimisées (BLAS, LAPACK, OpenBLAS, MKL) qui utilisent les instructions SIMD du CPU.
Pas de boxing/unboxing : Python wrap chaque entier dans un objet int (24 octets minimum). NumPy manipule des valeurs brutes dans un tableau typé, sans ce wrapping.
Le piège classique : « Je vais vectoriser ma boucle » – dit le débutant. « Mais le résultat est faux » – parce que la vectorisation a un contrat : les formes doivent être compatibles (cf. Section 5 sur le broadcasting), le dtype doit être correct, et les NaN/inf se propagent silencieusement.
Sortie attendue (cellule code[7]) : deux chronométrages comparant une boucle Python et une opération vectorisée sur 1 million d’éléments. Le rapport de vitesse est typiquement ~25×, dépendant de la machine.
import timeN =1_000_000def somme_boucle(n): total =0for i inrange(n): total += ireturn totalx = np.arange(N, dtype=np.int64)t0 = time.perf_counter()res_boucle = somme_boucle(N)t_boucle = time.perf_counter() - t0t0 = time.perf_counter()res_vect = x.sum()t_vect = time.perf_counter() - t0print(f"Boucle Python : somme = {res_boucle}, temps = {t_boucle:.4f} s")print(f"Vectorisee : somme = {res_vect}, temps = {t_vect:.4f} s")print(f"Meme resultat : {res_boucle == res_vect}")print(f"Vitesse : x{t_boucle / t_vect:.0f} plus rapide en vectorise")
Boucle Python : somme = 499999500000, temps = 0.0243 s
Vectorisee : somme = 499999500000, temps = 0.0005 s
Meme resultat : True
Vitesse : x46 plus rapide en vectorise
Lecture de la mesure vectorisation (cellule code[7]) :
La cellule ci-dessus mesure le gain de performance de la vectorisation NumPy sur un cas concret : la somme des entiers de 0 à 999 999.
Le protocole :
Une fonction somme_boucle(n) qui boucle en Python pur (for i in range(n)).
Une opération vectorisée x.sum() où x = np.arange(N, dtype=np.int64).
Chronométrage avec time.perf_counter() (haute précision).
Calcul du rapport t_boucle / t_vect.
Pourquoi cette mesure est convaincante : 1 million d’itérations d’une boucle Python implique 1 million de dispatches dans l’interpréteur Python (lookup de bytecode, gestion de la pile, etc.). L’opération vectorisée x.sum() fait un seul appel C qui itère sur le tableau via BLAS/LAPACK.
Sortie attendue : deux chronométrages et un rapport ~25 (dépend du CPU et de la version de NumPy/BLAS). Sur un laptop moderne avec OpenBLAS, on observe typiquement 50-100 ms pour la boucle et 2-5 ms pour la version vectorisée.
Le piège classique : croire que la vectorisation est « magique ». Elle ne l’est pas : c’est juste que le code C est ~25× plus rapide que l’interpréteur Python pour cette opération. Le facteur dépend du type d’opération (les boucles triviales ont un facteur plus élevé ; les opérations complexes ont un facteur plus bas).
Coût : < 1 seconde (2 chronométrages sur 1M éléments).
Broadcasting : diffuser une forme
Le broadcasting permet d’appliquer une opération entre deux tableaux de formes différentes en diffusant automatiquement les dimensions de taille 1 ou absentes. Trois cas valides — puis le cas d’erreur, source n°1 d’erreurs silencieuses.
Le contrat du broadcasting :
Pour que deux tableaux soient compatibles via broadcasting, NumPy compare leurs formes de la droite vers la gauche (alignement à droite). Deux dimensions sont compatibles si :
Elles sont égales, OU
L’une d’elles vaut 1 (et sera diffusée à la taille de l’autre).
Si une dimension est incompatible (e.g. 3 vs 2), NumPy lève une ValueErrorexplicite — c’est un message d’erreur conçu pour être lu.
Trois cas canoniques :
Scalaire + tableau : le scalaire est promu à la forme du tableau (e.g. array + 10).
Vecteur ligne + matrice : le vecteur (3,) est diffusé à chaque ligne d’une matrice (2, 3) → résultat (2, 3).
Vecteur colonne + matrice : le vecteur (2, 1) est diffusé à chaque colonne d’une matrice (2, 3) → résultat (2, 3).
Cas d’erreur : matrix (2, 3) + vector (2,) — le 2 et le 3 ne s’alignent pas, donc broadcasting impossible. NumPy lève :
ValueError: operands could not be broadcast together with shapes (2,3) (2,)
Coût : < 0.1 seconde (5 opérations × ~10 ms chacune).
# Cas 1 : scalaire + tableau. Le scalaire est diffusé sur chaque élément.a = np.arange(3)print("a =", a)print("a + 10 =", a +10, " (le scalaire 10 est diffusé sur tout le tableau)")
a = [0 1 2]
a + 10 = [10 11 12] (le scalaire 10 est diffusé sur tout le tableau)
# Cas 2 : vecteur LIGNE (forme (3,)) diffusé sur chaque ligne d'une matrice (2,3).m = np.arange(6).reshape(2, 3)row = np.array([10, 20, 30])print("m =", m.tolist())print("row =", row)print("m + row =\n", m + row, "\n (forme (2,3) + (3,) -> (2,3))")print("m + row shape =", (m + row).shape)
Lecture du broadcasting cas 1 + 2 (cellules code[9-10]) :
Les deux premières cellules montrent les cas les plus courants du broadcasting :
Cas 1 (cellule code[9]) : scalaire + tableau
a = np.arange(3) # forme (3,)a +10# broadcasting scalaire -> forme (3,)
Le scalaire 10 est promu à la forme (3,) avant l’addition. NumPy duplique le scalaire pour chaque élément de a. C’est l’idiome pour ajouter un offset constant à tout un tableau (centrage, normalisation, etc.).
Cas 2 (cellule code[10]) : vecteur ligne + matrice
m = np.arange(6).reshape(2, 3) # forme (2, 3)row = np.array([10, 20, 30]) # forme (3,)m + row # broadcasting -> forme (2, 3)
Le vecteur ligne row (forme (3,)) est diffusé à chaque ligne de la matrice m (forme (2, 3)). Le résultat a la forme (2, 3) — chaque ligne de m + row est (m[i, :] + row). C’est l’idiome pour ajouter un offset par colonne (e.g. bonus par matière à un tableau notes[étudiant, matière]).
L’alignement des formes (de droite à gauche) :
NumPy compare les formes de la droite vers la gauche : - (2, 3) vs (3,) → aligner à droite → (2, 3) vs (2, 3). La dimension gauche du vecteur est implicitement 1, qui se diffuse à 2. Compatible. - (2, 3) vs (2,) → (2, 3) vs (2,) → la dimension droite est 3 vs 2, incompatible. NumPy lève ValueError.
Sortie attendue : deux tableaux avec un offset constant (cas 1) ou un vecteur ligne (cas 2) ajouté.
Coût : < 0.01 seconde (2 opérations de broadcasting sur de petits tableaux).
# Cas 3 : vecteur COLONNE (forme (2,1)) diffusé sur chaque colonne.col = np.array([[100], [200]]) # forme (2,1)print("col =\n", col)print("m + col =\n", m + col, "\n (forme (2,3) + (2,1) -> (2,3))")print("m + col shape =", (m + col).shape)
col =
[[100]
[200]]
m + col =
[[100 101 102]
[203 204 205]]
(forme (2,3) + (2,1) -> (2,3))
m + col shape = (2, 3)
# Cas d'erreur : formes (2,3) et (2,) incompatibles. NumPy lève une ValueError# explicite — on la capture pour en lire le message.try: m + np.array([1, 2]) # (2,3) + (2,) : le 3 ne s'aligne pas avec le 2exceptValueErroras e:print("ValueError levée :")print(" ", e)print("\nInterprétation : les formes (2,3) et (2,) ne sont pas compatibles pour")print("l'élément le plus à droite. Il faut une forme (3,) (ligne) ou (2,1)")print("(colonne) pour que le broadcasting fonctionne.")
ValueError levée :
operands could not be broadcast together with shapes (2,3) (2,)
Interprétation : les formes (2,3) et (2,) ne sont pas compatibles pour
l'élément le plus à droite. Il faut une forme (3,) (ligne) ou (2,1)
(colonne) pour que le broadcasting fonctionne.
Trois façons d’extraire. Les tranches (a[1:4], X[:, 0]) découpent ; l’indexation avancée (a[[0, 2, 4]]) prend une liste d’indices ; le masque booléen (a[a > x]) filtre par une condition — l’idiome le plus utilisé en pratique.
Trois idiomes à maîtriser :
Tranches (slicing) : a[start:stop:step], X[rows, cols]. C’est l’idiome de base — X[:, 0] est l’idiome pour « toutes les lignes, colonne 0 » (la première colonne d’une matrice). Pour les matrices 2D, X[i, :] est la ligne i, X[:, j] est la colonne j.
Indexation avancée : a[[0, 2, 4]] retourne les éléments aux indices 0, 2,
Pour les matrices 2D, X[[0, 2], [1, 3]] retourne les éléments aux positions (0, 1) et (2, 3) (paires d’indices). C’est l’idiome pour piocher des éléments arbitraires.
Masques booléens : a[a > 10] retourne les éléments où le masque est True. Le masque est lui-même un tableau de booléens (a > 10 produit un ndarray de booléens). C’est l’idiome pour filtrer — le plus utilisé en data science.
Le piège classique : avec & et |, toujours parenthéser. (a > 5) & (a < 20) est correct ; a > 5 & a < 20 est évalué comme a > (5 & a) < 20 (à cause de la priorité de & sur <), ce qui ne veut rien dire.
Sortie attendue (cellules code[14-16]) : 3 démonstrations des idiomes.
Coût : < 0.1 seconde (5 opérations × ~5 ms chacune).
a = np.array([10, 20, 30, 40, 50])X = np.arange(12).reshape(3, 4)print("a =", a)print("a[1:4] =", a[1:4], " (tranche : indices 1 a 3)")print("X =\n", X)print("X[:, 0] =", X[:, 0], " (colonne 0 : TOUTES les lignes, l'idiome X[:, 0])")print("X[1:, 2:] =\n", X[1:, 2:], " (sous-bloc lignes 1-2, colonnes 2-3)")
La cellule ci-dessus illustre les 3 idiomes d’indexation NumPy :
Tranches (slicing) : a[1:4] retourne [20, 30, 40] (indices 1 à 3 exclus). Pour les matrices 2D, X[:, 0] retourne la colonne 0 (toutes les lignes), X[1:, 2:] retourne un bloc en bas-droite.
Indexation avancée : a[[0, 2, 4]] retourne [10, 30, 50] (indices piochés arbitrairement). Pour les matrices 2D, X[[0, 2], [1, 3]] retourne un tableau 1D avec les éléments aux positions (0, 1) et (2, 3).
Masques booléens : a[a > 10] retourne [20, 30, 40, 50] (les éléments où a > 10 est True). Le masque est lui-même un ndarray de booléens.
Pourquoi ces idiomes sont cruciaux :
Les tranches sont la base de la manipulation de tableaux — X[:, 0] est l’idiome pour « extraire la première colonne » (utilisé dans 80% du code de data science).
L’indexation avancée permet de piocher arbitrairement (e.g. « donne-moi les 5 échantillons avec les indices [12, 45, 78, 123, 456] »).
Les masques booléens sont l’idiome de filtrage — df[df['age'] > 18] en Pandas, arr[arr > 10] en NumPy.
a = np.array([5, 12, 18, 9, 25, 3])# Le masque est lui-même un tableau de booléens, comparé d'un coup.print("a =", a)print("a > 10 =", a >10, " (masque : True/False par élément)")print("a[a > 10] =", a[a >10], " (sélection où le masque est True)")# Masque composé : TOUJOURS une parenthèse autour de chaque comparaison avec &.print("\n(a > 5) & (a < 20) =", (a >5) & (a <20))print("a[(a > 5) & (a < 20)] =", a[(a >5) & (a <20)])
a = [ 5 12 18 9 25 3]
a > 10 = [False True True False True False] (masque : True/False par élément)
a[a > 10] = [12 18 25] (sélection où le masque est True)
(a > 5) & (a < 20) = [False True True True False False]
a[(a > 5) & (a < 20)] = [12 18 9]
Réductions et la sémantique de axis
sum, mean, std réduisent le tableau. Le paramètre axis dit quelle dimension on écrase : axis=0 réduit toutes les lignes (le résultat garde une entrée par colonne), axis=1 réduit toutes les colonnes (une entrée par ligne). C’est la confusion classique — la cellule suivante rend le résultat visuel.
L’intuition axis : imaginez une matrice 2D comme une grille. axis=0 veut dire « pour chaque colonne, regarde toutes les lignes » (la colonne survit, les lignes sont écrasées). axis=1 veut dire « pour chaque ligne, regarde toutes les colonnes » (la ligne survit, les colonnes sont écrasées).
Les 4 réductions à connaître :
arr.sum() : somme des éléments.
arr.mean() : moyenne arithmétique.
arr.std() : écart-type (ddof=0 par défaut, ddof=1 pour unbiased).
arr.min(), arr.max() : extrême bas/haut.
Le piège classique : penser axis=0 = « sur les lignes » et axis=1 = « sur les colonnes » est inverse de l’intuition courante. NumPy suit la convention « axis est l’axe écrasé » : axis=0 écrase l’axe 0 (les lignes), donc le résultat a la forme des colonnes restantes.
Sortie attendue (cellule code[18]) : une matrice (2, 3) avec ses 4 sommes : - totale = 21 - par colonne (axis=0) = [5, 7, 9] (somme des lignes) - par ligne (axis=1) = [6, 15] (somme des colonnes)
Coût : < 0.01 seconde (3 sommes sur une matrice 2×3).
Pour aller plus loin : keepdims=True conserve les dimensions écrasées (e.g. (2, 3) au lieu de (3,)), ce qui permet de broadcaster le résultat.
X = np.array([[1, 2, 3], [4, 5, 6]]) # forme (2,3)print("X :", X.shape, "\n", X)print("X.sum() =", X.sum(), " (tous les éléments)")print("X.sum(axis=0) =", X.sum(axis=0), " (écrase les lignes -> forme (3,))")print("X.sum(axis=1) =", X.sum(axis=1), " (écrase les colonnes -> forme (2,))")print("X.mean(axis=0) =", X.mean(axis=0))print("X.std(axis=1) =", X.std(axis=1))
X : (2, 3)
[[1 2 3]
[4 5 6]]
X.sum() = 21 (tous les éléments)
X.sum(axis=0) = [5 7 9] (écrase les lignes -> forme (3,))
X.sum(axis=1) = [ 6 15] (écrase les colonnes -> forme (2,))
X.mean(axis=0) = [2.5 3.5 4.5]
X.std(axis=1) = [0.81649658 0.81649658]
Reproductibilité : np.random.default_rng(seed)
En ML, un tirage aléatoire doit être reproductible : même graine, mêmes tirages. np.random.default_rng(seed) construit un générateur — la cellule le démontre en refaisant deux fois le même tirage avec la même graine.
Pourquoi la reproductibilité est non-négociable en ML :
Un entraînement de modèle implique des tirages aléatoires (initialisation des poids, shuffle des données, dropout, mini-batch sampling). Si on ne fixe pas la graine, deux runs identiques produisent des résultats différents. C’est incompatible avec : - le débogage : on ne peut pas reproduire un bug. - la comparaison : changer de graine change les résultats, donc impossible de comparer deux hyperparamètres de façon fiable. - la publication : les pairs ne peuvent pas ré-exécuter.
L’API moderne (numpy.random.default_rng) :
rng = np.random.default_rng(seed=42) : crée un générateur isolé, sans état partagé avec les autres générateurs. C’est la pratique recommandée depuis NumPy 1.17 (2019).
rng.normal(size=5) : 5 tirages d’une gaussienne standard.
rng.integers(0, 100, size=10) : 10 entiers entre 0 et 100 (exclus).
rng.shuffle(arr) : mélange en place un tableau.
L’ancienne API (np.random.seed) : À éviter — utilise un état global partagé qui peut être modifié accidentellement par une autre lib. L’API moderne isole chaque générateur.
Sortie attendue (cellule code[20]) : deux tirages identiques avec la même graine, et la vérification True du (tirage_a == tirage_b).all().
axis=1 veut dire « pour chaque ligne, additionne horizontalement » : - ligne 0 : 1 + 2 + 3 = 6 - ligne 1 : 4 + 5 + 6 = 15 → résultat : [6, 15]
Le piège classique : penser axis=0 = « lignes » et axis=1 = « colonnes » est inverse. NumPy suit la convention « axis est l’axe écrasé » : axis=0 écrase l’axe 0 (les lignes), donc les lignes sont agrégées et le résultat garde la forme des colonnes.
Coût : < 0.01 seconde (3 sommes sur 6 éléments).
Exercices fondamentaux
Trois exercices progressifs pour s’approprier les idiomes NumPy : vectorisation, masques booléens composés, et broadcasting.
Conventions C.1 : les cellules contiennent des commentaires # TODO: refaites ce calcul SANS boucle et des corps partiels (valeur = None). Elles s’exécutent de bout en bout même non-complétées (sortie vide). Python valide la syntaxe mais pas la logique métier.
Indications : chaque exercice a un indice en commentaire — utilisez-le pour découvrir l’idiome sans avoir à chercher dans la doc.
Barème indicatif : 5-10 minutes par exercice.
Exercice 1 : vectorisez une boucle.
Une boucle Python calcule le carré de chaque élément. À vous de faire le même calcul en une ligne vectorisée avec l’opérateur **. Indication : valeurs ** 2 applique l’exposant à chaque élément.
Exercice 2 : filtrez avec un masque composé.
Sélectionnez les valeurs strictement entre 5 et 20 (exclus) en une ligne, avec des parenthèses autour de chaque comparaison. Indication : (donnees > 5) & (donnees < 20).
Exercice 3 : appliquez un broadcasting.
Un tableau de notes (3 étudiants × 3 matières) : ajoutez un bonus de 2 points à chaque note, en une seule opération de broadcasting. Indication : notes + 2 (où 2 est un scalaire diffusé à toutes les cases).
Coût total : < 5 secondes par exercice (vectorisation + masques + broadcasting sont des opérations quasi-instantanées).
Exercice 1 : vectorisez une boucle
Pourquoi cet exercice est fondamental : la vectorisation est la différence entre un script Python qui tourne en 10 secondes et un script NumPy qui tourne en 0.4 secondes. Maîtriser arr ** 2 (au lieu d’une boucle for) est le prérequis de toute la suite.
Le protocole :
Observer le code fourni : une boucle for qui calcule valeurs[i] ** 2 pour chaque index i.
Remplacer toute la boucle par une seule ligne : carres_vect = valeurs ** 2.
Vérifier que carres_vect est identique à carres_boucle (via (carres_vect == carres_boucle).all()).
Sortie attendue : un message console confirmant que les deux résultats sont identiques, et idéalement le temps gagné (mesure time avant/après).
Le piège classique : oublier que ** est un opérateur vectorisé en NumPy, mais pas une boucle. Si on essaie for i in range(len(valeurs)): carres_vect[i] = ..., on revient à la boucle — l’idiome vectorisé est une seule expression.
Coût : < 0.01 seconde (6 éléments vectorisés vs boucle).
valeurs = np.array([5, 12, 8, 20, 3, 15])# Version boucle (fournie) :carres_boucle = np.empty_like(valeurs)for i inrange(len(valeurs)): carres_boucle[i] = valeurs[i] **2# TODO: refaites ce calcul SANS boucle, en une ligne vectorisée.# Indice: l'operateur ** s'applique element par element sur un ndarray.carres_vectorises =None# Remplacez None par l'operation vectoriseeif carres_vectorises isnotNone:print("Boucle :", carres_boucle)print("Vectorisee :", carres_vectorises)print("Identiques ?", (carres_boucle == carres_vectorises).all())else:print("Exercice a completer : vectorisez ce calcul (carres_vectorises)")
Exercice a completer : vectorisez ce calcul (carres_vectorises)
Lecture méthodologique des exercices (introduction à code[27]) :
Les trois exercices fondamentaux couvrent les 3 idiomes NumPy essentiels :
Exercice 1 : vectorisation : remplacer une boucle for par une opération vectorisée (** 2). C’est la brique élémentaire de performance.
Exercice 2 : masques booléens composés : (a > 5) & (a < 20). C’est l’idiome de filtrage le plus courant en data science.
Exercice 3 : broadcasting scalaire : notes + 2. C’est l’idiome de normalisation (ajout d’un offset à toutes les valeurs).
Pourquoi cette progression : elle reflète la hiérarchie des besoins en ML.
Niveau 1 — Vectorisation : comprendre que arr ** 2 bat for i ....
Niveau 2 — Masques : savoir filtrer avec des conditions composées.
Niveau 3 — Broadcasting : savoir appliquer une transformation à toutes les dimensions d’un tableau.
Pièges classiques :
Vectorisation : écrire for i ...: arr[i] = ... au lieu de arr ** 2. La boucle bat l’idiome vectorisé.
Masques : oublier les parenthèses — (a > 5) & (a < 20) est correct ; a > 5 & a < 20 est un bug silencieux.
Broadcasting : utiliser notes + np.array([2, 2, 2]) au lieu de notes + 2 — les deux fonctionnent, mais le scalaire est plus lisible.
Coût total : < 5 secondes (vectorisation + masques + broadcasting sur de petits tableaux).
Exercice 2 : filtrez avec un masque composé
Pourquoi cet exercice : les masques booléens sont l’idiome le plus courant en data science. df[df['age'] > 18] (Pandas) ou arr[arr > 10] (NumPy) sont des opérations quotidiennes. Maîtriser (a > 5) & (a < 20) est le prérequis.
Sortie attendue : un message console montrant les valeurs filtrées : [15, 7, 11, 18] (les valeurs > 5 et < 20).
Le piège classique : donnees > 5 & donnees < 20 (sans parenthèses) est évalué comme donnees > (5 & donnees) < 20, ce qui n’a pas de sens. Les parenthèses autour de chaque comparaison sont obligatoires avec & et |.
Coût : < 0.01 seconde (8 comparaisons × 2 + un indexage).
Pourquoi & et non and : and est un opérateur Python sur les booléens uniques, et il évalue paresseusement (court-circuit). & est l’opérateur bitwise qui fonctionne élément par élément sur des tableaux de booléens. C’est l’idiome NumPy pour combiner des masques.
donnees = np.array([3, 15, 7, 22, 11, 30, 4, 18])# TODO: selectionnez les valeurs strictement entre 5 et 20 (exclus).# Indice: (donnees > 5) & (donnees < 20) — parentheses obligatoires avec &selection =None# Remplacez Noneif selection isnotNone:print("donnees :", donnees)print("selection :", selection)else:print("Exercice a completer : filtrez entre 5 et 20 (exclus)")
Exercice a completer : filtrez entre 5 et 20 (exclus)
Exercice 3 : appliquez un broadcasting
Pourquoi cet exercice : le broadcasting scalaire est l’idiome le plus simple du genre. notes + bonus (où bonus = 2) ajoute 2 points à chaque note sans écrire de boucle. C’est la base de toute normalisation (centrage, mise à l’échelle, standardisation).
Le protocole :
Observer le tableau notes de forme (3, 3) (3 étudiants × 3 matières).
Construire le bonus : bonus = 2 (un scalaire).
Appliquer : notes_bonus = notes + bonus.
Sortie attendue : le tableau notes_bonus avec 2 points ajoutés à chaque note, e.g. [14, 17, 11], [10, 16, 19], [18, 13, 15].
Le piège classique : essayer d’écrire une boucle for etudiant in range(3): for matiere in range(3): .... La vectorisation est une seule ligne.
Coût : < 0.01 seconde (9 additions scalaires diffusées).
Pour aller plus loin : broadcasting non-scalaire. Si on a un tableau bonus_par_matiere = np.array([1, 2, 3]) (forme (3,)), alors notes + bonus_par_matiere ajoute 1 à la colonne 0, 2 à la colonne 1, 3 à la colonne 2. C’est le même principe, mais avec un vecteur 1D.
notes = np.array([ [12, 15, 9], [8, 14, 17], [16, 11, 13],]) # forme (3,3) : 3 etudiants, 3 matieres# TODO: ajoutez 2 points a chaque note via broadcasting (notes + scalaire).bonus =2notes_bonus =None# Remplacez None : notes + bonusif notes_bonus isnotNone:print("notes :\n", notes)print("notes_bonus :\n", notes_bonus)else:print("Exercice a completer : ajoutez un bonus de 2 points via broadcasting")
Exercice a completer : ajoutez un bonus de 2 points via broadcasting
Exercices avancés
Trois exercices additionnels pour approfondir : opérations 1D, statistiques sur dataset synthétique, et algèbre linéaire 2D.
Conventions C.1 : les cellules contiennent des commentaires # TODO: ... et des corps partiels (variable = None). Elles s’exécutent de bout en bout même non-complétées (sortie vide).
Indications : les indices sont intégrés dans les commentaires de chaque cellule.
Barème indicatif : 10-15 minutes par exercice.
Exercice 4 — opérations élémentaires sur un tableau 1D.
Créez un tableau NumPy de 10 éléments allant de 0 à 9, puis calculez : 1. La somme de tous les éléments 2. La moyenne des éléments 3. Le carré de chaque élément
Exercice 5 — statistiques sur un dataset synthétique.
Créez un tableau de données synthétiques (100 valeurs aléatoires entre 0 et 100) et calculez des statistiques descriptives (somme, moyenne, minimum, maximum, écart-type). C’est l’introduction au workflow de statistiques descriptives en ML.
Exercice 6 — opérations sur les matrices 2D.
Créez une matrice 3×3 et appliquez des opérations avancées : transposition, produit matriciel, extraction de sous-matrices. C’est le point d’entrée vers np.linalg (algèbre linéaire).
Coût total : < 1 seconde par exercice (10 + 100 + 9 éléments manipulés).
Exercice 4 : opérations élémentaires sur un tableau 1D
Créez un tableau NumPy de 10 éléments allant de 0 a 9, puis calculez : 1. La somme de tous les éléments 2. La moyenne des éléments 3. Le carre de chaque élément
Indices : - np.arange(10) pour créer le tableau - arr.sum(), arr.mean() pour les statistiques - NumPy supporte les opérations élément par élément (ex: arr ** 2)
Pourquoi cet exercice : np.arange(N) est l’idiome de base pour créer une séquence d’entiers (équivalent de range() mais retourne un ndarray). Les réductions (sum, mean) sont les briques élémentaires de toute statistique descriptive. Le carré élément par élément illustre la vectorisation.
Sortie attendue : un message console avec les 3 résultats (somme, moyenne, carrés).
Coût : < 0.01 seconde (10 éléments + 3 réductions).
# Exercice : Multipliez le tableau par 2 et affichez le resultatarray_exercice = np.array([2, 4, 6, 8, 10])# TODO: Multipliez chaque element de array_exercice par 2# Indice: NumPy permet les operations vectorisees (ex: array * scalaire)resultat =None# Remplacez None par l'operation approprieeprint(f"Tableau original : {array_exercice}")print(f"Tableau multiplie par 2 : {resultat}")
Tableau original : [ 2 4 6 8 10]
Tableau multiplie par 2 : None
Exercice 5 : statistiques sur un dataset synthétique
À vous de pratiquer les opérations vectorisées avec NumPy !
Objectifs
Créez un tableau de données synthétiques et calculez des statistiques descriptives.
Instructions
Créez un tableau donnees de 100 valeurs aléatoires entre 0 et 100. Indication : np.random.randint(0, 100, size=100).
Calculez les statistiques suivantes :
somme (utilisez np.sum())
moyenne (utilisez np.mean())
minimum (utilisez np.min())
maximum (utilisez np.max())
ecart_type (utilisez np.std())
Pourquoi cet exercice est important : c’est l’introduction au workflow de statistiques descriptives en machine learning. Avant de modéliser, on résume les données (somme, moyenne, écart-type, quantiles). NumPy rend ces opérations vectorisées — pas de boucle, pas d’accumulateur manuel.
Sortie attendue : un message console avec les 5 statistiques. Pour 100 valeurs uniformes entre 0 et 100, on attend approximativement : - somme ≈ 5000 - moyenne ≈ 50 - minimum ∈ [0, 100] - maximum ∈ [0, 100] - écart-type ≈ 30 (pour une distribution uniforme)
Coût : < 0.01 seconde (100 tirages + 5 réductions).
import numpy as np# TODO: Créez un tableau de 100 valeurs aléatoires entre 0 et 100# Indice: utilisez np.random.randint()donnees =None# Remplacez None# TODO: Calculez les statistiques suivantessomme =None# Utilisez np.sum()moyenne =None# Utilisez np.mean()minimum =None# Utilisez np.min()maximum =None# Utilisez np.max()ecart_type =None# Utilisez np.std()# TODO: Créez un masque booléen pour les valeurs > 50masque =None# données > 50valeurs_sup_50 =None# Appliquez le masque# Affichage des résultatsif donnees isnotNoneand valeurs_sup_50 isnotNone:print(f"Somme: {somme}")print(f"Moyenne: {moyenne}")print(f"Min: {minimum}, Max: {maximum}")print(f"Écart-type: {ecart_type}")print(f"Valeurs > 50: {len(valeurs_sup_50)} sur {len(donnees)}")else:print("Exercice à compléter : remplacez les None par votre code")
Exercice à compléter : remplacez les None par votre code
Exercice 6 : opérations sur les matrices 2D
NumPy excelle dans les opérations sur les tableaux multidimensionnels. Vous allez créer une matrice 2D (tableau de tableaux) et appliquer des opérations avancees : transposition, produit matriciel et extraction de sous-matrices.
Objectif : Manipuler une matrice 3x3 avec les fonctions de NumPy pour comprendre les bases de l’algebre lineaire.
Indices : - Utilisez np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) pour créer une matrice 3x3 - matrice.T ou np.transpose(matrice) pour la transposition - np.dot(A, B) ou l’opérateur @ pour le produit matriciel - matrice[0:2, 1:3] pour extraire une sous-matrice (slicing)
Pourquoi cet exercice : l’algèbre linéaire (transposition, produit matriciel, valeurs propres) est le socle de la régression linéaire, de la PCA, et du deep learning. Maîtriser @ et .T est un prérequis pour toute la suite.
transposee = matrice.T : la transposée échange lignes et colonnes.
produit = matrice @ matrice : produit matriciel (le résultat est aussi 3×3).
sous_matrice = matrice[0:2, 1:3] : les 2 premières lignes × colonnes 1 et 2.
Sortie attendue : 4 affichages, montrant la matrice, sa transposée, le produit matriciel, et la sous-matrice 2×2 extraite.
Coût : < 0.01 seconde (3×3 multiplications + slicing).
Pour aller plus loin : np.linalg.inv(matrice) pour l’inverse, np.linalg.eigvals(matrice) pour les valeurs propres, np.linalg.det(matrice) pour le déterminant. Ces opérations sont au cœur de la décomposition spectrale.
# Exercice 6 : Operations sur les matrices 2D# Creez et manipulez une matrice avec NumPy# Etape 1: Creez une matrice 3x3# Indice: np.array([[1,2,3], [4,5,6], [7,8,9]])matrice =None# Remplacez None# Etape 2: Calculez la transposee# Indice: matrice.Ttransposee =None# Remplacez None# Etape 3: Calculez le produit matriciel de la matrice par elle-meme# Indice: np.dot(matrice, matrice) ou matrice @ matriceproduit =None# Remplacez None# Etape 4: Extrayez la sous-matrice 2x2 en haut a gauche# Indice: matrice[0:2, 0:2]sous_matrice =None# Remplacez None# Affichageif matrice isnotNone:print(f"Matrice originale :\n{matrice}")print(f"\nTransposee :\n{transposee}")print(f"\nProduit matriciel :\n{produit}")print(f"\nSous-matrice 2x2 :\n{sous_matrice}")else:print("Exercice 6 a completer : opérations sur les matrices 2D")
Exercice 6 a completer : opérations sur les matrices 2D
Conclusion
Ce notebook a posé les fondations NumPy indispensables à toute la data science Python — le socle sur lequel s’appuient Pandas (notebook 1.3), scikit-learn et les frameworks de deep learning.
Ce qu’il faut retenir :
L’objet ndarray est un tableau N-dimensionnel typé et contigu en mémoire : shape et dtype se lisent avant toute opération, et le stockage compact (nbytes) explique pourquoi NumPy bat les listes Python en vitesse et en mémoire.
La vectorisation remplace les boucles for : a + b, a * 2, a.sum() s’appliquent élément par élément via du code C natif — mesuré ici à un facteur ~x25 sur un million d’éléments (dépend de la machine). C’est le déplacement sémantique clé.
Le broadcasting diffuse une forme sur une autre quand les dimensions sont compatibles (scalaire, ligne, colonne) ; le message d’erreur de NumPy (ValueError) est fait pour être lu, pas deviné.
L’indexation — tranches (X[:, 0]), indexation avancée, masques booléens (a[a > x], (a > 5) & (a < 20)) — est l’idiome le plus courant : lire X[:, 0] et X[X > seuil] est le prérequis de toute la suite.
axis dit quelle dimension une réduction écrase : axis=0 les lignes, axis=1 les colonnes.
np.random.default_rng(seed) rend un tirage reproductible — même graine, mêmes tirages — indispensable en ML.
Le pont vers Pandas : NumPy manipule des tableaux numériques nus ; Pandas (1.3) ajoute l’indexation par étiquettes et le typage hétérogène — mais tout DataFrame Pandas enveloppe un ndarray. Maîtriser la vectorisation, le broadcasting et les masques ici, c’est comprendre pourquoi une opération Pandas vectorisée bat toujours une boucle iterrows.
Pour aller plus loin : les fonctions d’algèbre linéaire de np.linalg (inverse, déterminant, valeurs propres) et l’indexation par tableau d’indices 2D. Les exercices avancés ci-dessus (transposition, produit matriciel, sous-matrices) servent de point d’entrée vers np.linalg.
Trois concepts transversaux à retenir :
Le contrat de performance : un ndarray est typé, contigu, et vectorisable. Quand on a un million de nombres, on utilise NumPy. Quand on en a 10, on peut encore utiliser une liste Python — mais l’idiome est le même.
Le contrat de forme : shape est le type d’un ndarray. Broadcasting, indexation, réductions — toutes les opérations reposent sur la compatibilité des formes. Le message d’erreur ValueError: operands could not be broadcast est l’indice n°1 d’un bug de forme.
Le contrat de reproductibilité : un tirage aléatoire est toujours paramétré par une graine. Sans graine, pas de ML sérieux (impossible de comparer deux hyperparamètres sans bruit de graine).
Références
C. R. Harris et al., Array programming with NumPy, Nature, 585(7825):357-362,
doi:10.1038/s41586-020-2649-2. Article de référence décrivant l’objet ndarray, l’écosystème de tableaux N-dimensionnels et les fondations de la data science Python.
S. van der Walt, S. C. Colbert, G. Varoquaux, The NumPy Array: A Structure for Efficient Numerical Computation, Computing in Science & Engineering, 13(2):22-30, 2011. Architecture interne de ndarray et principes de vectorisation.
T. E. Oliphant, A Guide to NumPy, Trelgol Publishing, 2006. Référence technique historique sur NumPy (première édition).
Pour aller plus loin :
Documentation officielle : https://numpy.org/doc/stable/ — couvre l’intégralité de l’API avec des tutoriels pas-à-pas.
NumPy Illustrated (guide visuel) : https://medium.com/better-programming/numpy-illustrated-the-visual-guide-to-numpy-3b1d4976de1d — un excellent rappel visuel des opérations.
From Python to NumPy (tutoriel gratuit) : https://www.labri.fr/perso/nrougier/from-python-to-numpy/ — focus sur la transition d’un Python « boucle » à un Python « vectorisé ».
100 NumPy Exercises : https://github.com/rougier/numpy-100 — pour s’exercer, niveau débutant à avancé.
Bibliographie complémentaire :
W. McKinney, Python for Data Analysis, O’Reilly, 3ᵉ édition 2022. Le chapitre 4 couvre NumPy en détail avec une perspective data science.
J. VanderPlas, Python Data Science Handbook, O’Reilly, 2016. Chapitres 2 (NumPy) et 3 (Pandas) sont les références standard pour la data science Python.
A. Géron, Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow, O’Reilly, 3ᵉ édition 2022. Chapitre 4 couvre les représentations de données (NumPy + Pandas) pour le ML.