Pourquoi NumPy reste incontournable en 2026
Si vous faites du Python pour la data science ou le calcul scientifique, vous avez forcément croisé NumPy. Et pour cause : c'est la brique de base sur laquelle repose tout l'écosystème. Pandas, Scikit-learn, TensorFlow, PyTorch... tous dépendent de NumPy d'une manière ou d'une autre.
En mars 2026, la version 2.4.4 est sortie avec son lot de nouveautés. Que vous débutiez ou que vous soyez un vétéran du ndarray, ce guide couvre tout ce qu'il faut savoir pour tirer le meilleur de NumPy aujourd'hui.
Allez, on plonge dedans.
Installation et configuration de NumPy 2.4
Installer NumPy avec pip ou conda
NumPy 2.4.4 est compatible avec Python 3.11 à 3.14. L'installation est classique :
# Installation avec pip
pip install numpy==2.4.4
# Installation avec conda
conda install numpy=2.4.4
# Vérifier la version installée
python -c "import numpy; print(numpy.__version__)"
Si vous êtes sur Anaconda ou Miniconda, bonne nouvelle : NumPy est déjà inclus. Sinon, je vous recommande de créer un environnement virtuel dédié (croyez-moi, ça évite pas mal de prises de tête avec les conflits de versions) :
python -m venv mon_env_numpy
source mon_env_numpy/bin/activate # Linux/macOS
pip install numpy
Importer NumPy
La convention, c'est d'importer NumPy avec l'alias np. Tout le monde fait comme ça, et ça rend le code beaucoup plus lisible :
import numpy as np
print(np.__version__) # 2.4.4
Comprendre le ndarray : le coeur de NumPy
Qu'est-ce qu'un ndarray ?
Le ndarray (N-dimensional array), c'est LA structure de données de NumPy. Contrairement aux listes Python qui peuvent contenir n'importe quoi, un ndarray stocke des éléments du même type dans des blocs de mémoire contigus. Résultat : des opérations jusqu'à 35 fois plus rapides qu'avec des listes classiques. Oui, 35 fois.
import numpy as np
# Créer un tableau 1D à partir d'une liste
vecteur = np.array([1, 2, 3, 4, 5])
print(vecteur) # [1 2 3 4 5]
print(type(vecteur)) # <class 'numpy.ndarray'>
print(vecteur.dtype) # int64
# Créer un tableau 2D (matrice)
matrice = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
print(matrice.shape) # (3, 3)
print(matrice.ndim) # 2
Attributs essentiels d'un ndarray
Chaque tableau NumPy embarque des attributs qui décrivent sa structure. Voici les plus importants :
- shape : les dimensions du tableau (ex :
(3, 4)pour 3 lignes, 4 colonnes) - ndim : le nombre de dimensions
- dtype : le type de données (
float64,int32,bool, etc.) - size : le nombre total d'éléments
- itemsize : la taille en octets de chaque élément
- nbytes : la taille totale en mémoire
data = np.random.randn(1000, 50)
print(f"Forme : {data.shape}") # (1000, 50)
print(f"Dimensions : {data.ndim}") # 2
print(f"Type : {data.dtype}") # float64
print(f"Taille mémoire : {data.nbytes / 1024:.1f} Ko") # 390.6 Ko
Créer des tableaux NumPy : toutes les méthodes
Fonctions de création courantes
Pas besoin de partir d'une liste Python à chaque fois. NumPy propose tout un arsenal de fonctions pour créer des tableaux directement :
# Tableaux remplis de zéros ou de uns
zeros = np.zeros((3, 4)) # Matrice 3x4 de zéros
uns = np.ones((2, 3), dtype=int) # Matrice 2x3 d'entiers à 1
# Tableau avec une valeur constante
constante = np.full((3, 3), 7.5) # Matrice 3x3 remplie de 7.5
# Séquences numériques
seq = np.arange(0, 20, 2) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
lin = np.linspace(0, 1, 5) # [0.0, 0.25, 0.5, 0.75, 1.0]
# Matrice identité
identite = np.eye(4) # Matrice identité 4x4
# Tableau non initialisé (rapide mais contenu imprévisible)
vide = np.empty((2, 3))
Petit conseil au passage : np.empty est tentant parce qu'il est plus rapide que np.zeros, mais attention, le contenu est vraiment aléatoire. À n'utiliser que si vous êtes sûr de remplir le tableau immédiatement après.
Génération de données aléatoires
Le module numpy.random est indispensable pour la simulation et l'échantillonnage. Depuis NumPy 1.17, on privilégie le générateur default_rng (plus propre et plus reproductible) :
rng = np.random.default_rng(seed=42)
# Distribution uniforme entre 0 et 1
uniforme = rng.random((3, 3))
# Distribution normale (moyenne=0, écart-type=1)
normale = rng.standard_normal((1000,))
# Entiers aléatoires
entiers = rng.integers(0, 100, size=(5, 5))
# Échantillonnage sans remise
echantillon = rng.choice(np.arange(100), size=10, replace=False)
print(echantillon)
Gestion des types de données (dtype)
Choisir le bon dtype, c'est un réflexe qui peut faire une vraie différence. Un tableau d'entiers 32 bits consomme deux fois moins de mémoire qu'un tableau en 64 bits :
# Comparaison de la consommation mémoire
arr_64 = np.arange(1_000_000, dtype=np.float64)
arr_32 = np.arange(1_000_000, dtype=np.float32)
print(f"float64 : {arr_64.nbytes / 1e6:.1f} Mo") # 8.0 Mo
print(f"float32 : {arr_32.nbytes / 1e6:.1f} Mo") # 4.0 Mo
# Conversion de type
converti = arr_64.astype(np.float32)
Indexation, slicing et sélection avancée
Indexation et slicing de base
Si vous connaissez le slicing Python, vous êtes déjà à mi-chemin. NumPy reprend la même syntaxe, mais étendue à plusieurs dimensions :
arr = np.arange(20).reshape(4, 5)
print(arr)
# [[ 0 1 2 3 4]
# [ 5 6 7 8 9]
# [10 11 12 13 14]
# [15 16 17 18 19]]
# Sélection d'un élément
print(arr[1, 3]) # 8
# Slicing : lignes 1 à 2, colonnes 2 à 4
print(arr[1:3, 2:5])
# [[ 7 8 9]
# [12 13 14]]
# Toute une colonne
print(arr[:, 2]) # [ 2 7 12 17]
Vues vs copies : un piège classique
Celui-là, il m'a personnellement coûté quelques heures de débogage. Contrairement aux listes Python, le slicing d'un tableau NumPy renvoie une vue, pas une copie. Ça veut dire que modifier la vue modifie aussi le tableau d'origine :
original = np.array([10, 20, 30, 40, 50])
vue = original[1:4]
vue[0] = 999
print(original) # [ 10 999 30 40 50] — l'original est modifié !
# Pour obtenir une copie indépendante :
copie = original[1:4].copy()
copie[0] = 0
print(original) # [ 10 999 30 40 50] — l'original reste intact
Moralité : quand vous n'êtes pas sûr, utilisez .copy(). Mieux vaut un peu de mémoire en plus qu'un bug silencieux.
Indexation avancée : masques booléens et fancy indexing
Les masques booléens sont probablement l'une des fonctionnalités les plus pratiques de NumPy. Ils permettent de filtrer des données avec une simple condition. Le fancy indexing, lui, sélectionne des éléments par leurs indices :
donnees = np.array([12, -5, 8, -3, 15, 0, -7, 22])
# Masque booléen : sélectionner les valeurs positives
masque = donnees > 0
print(donnees[masque]) # [12 8 15 22]
# Combinaison de conditions
filtre = (donnees > 0) & (donnees < 20)
print(donnees[filtre]) # [12 8 15]
# Fancy indexing : sélection par indices
indices = [0, 3, 5]
print(donnees[indices]) # [12 -3 0]
# Attention : le fancy indexing retourne toujours une copie
selection = donnees[[0, 2]]
selection[0] = 0
print(donnees[0]) # 12 — l'original n'est pas modifié
Vectorisation et broadcasting : là où NumPy brille vraiment
Opérations vectorisées
Honnêtement, c'est ici que NumPy montre toute sa puissance. La vectorisation, c'est le fait d'appliquer une opération à un tableau entier sans écrire de boucle for. Le calcul est délégué à du code C optimisé, et la différence de vitesse est... spectaculaire :
import time
taille = 1_000_000
a = np.random.randn(taille)
b = np.random.randn(taille)
# Méthode lente : boucle Python
debut = time.perf_counter()
resultat_boucle = [a[i] + b[i] for i in range(taille)]
temps_boucle = time.perf_counter() - debut
# Méthode rapide : vectorisation NumPy
debut = time.perf_counter()
resultat_numpy = a + b
temps_numpy = time.perf_counter() - debut
print(f"Boucle Python : {temps_boucle:.3f}s")
print(f"NumPy vectorisé : {temps_numpy:.4f}s")
print(f"Accélération : {temps_boucle / temps_numpy:.0f}x")
Les opérations arithmétiques (+, -, *, /, **), les comparaisons et les fonctions universelles (ufuncs) sont toutes vectorisées par défaut. Une fois qu'on a pris le réflexe, on ne revient plus en arrière.
Le broadcasting expliqué
Le broadcasting, c'est le mécanisme qui permet à NumPy de faire des opérations entre des tableaux de tailles différentes. En gros, NumPy "étire" virtuellement le plus petit tableau pour qu'il s'adapte au plus grand :
# Scalaire + tableau
arr = np.array([1, 2, 3, 4])
print(arr * 10) # [10 20 30 40]
# Vecteur colonne + vecteur ligne
colonne = np.array([[1], [2], [3]]) # shape (3, 1)
ligne = np.array([10, 20, 30, 40]) # shape (4,)
resultat = colonne + ligne # shape (3, 4)
print(resultat)
# [[11 21 31 41]
# [12 22 32 42]
# [13 23 33 43]]
Les règles sont assez simples une fois qu'on les a intégrées : NumPy compare les dimensions de droite à gauche. Deux dimensions sont compatibles si elles sont égales ou si l'une vaut 1. Si le nombre de dimensions diffère, le tableau avec moins de dimensions est complété par des dimensions de taille 1 à gauche.
Algèbre linéaire et calcul matriciel
Pour ceux qui font du machine learning ou des statistiques, le module linalg est un compagnon quotidien. Il couvre l'essentiel de l'algèbre linéaire :
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
# Produit matriciel
produit = A @ B # ou np.dot(A, B)
print(produit)
# [[19 22]
# [43 50]]
# Transposée
print(A.T)
# [[1 3]
# [2 4]]
# Déterminant
det = np.linalg.det(A)
print(f"Déterminant : {det:.1f}") # -2.0
# Inverse
inv = np.linalg.inv(A)
print(inv)
# Valeurs propres et vecteurs propres
valeurs, vecteurs = np.linalg.eig(A)
print(f"Valeurs propres : {valeurs}")
# Résolution de systèmes linéaires : Ax = b
b = np.array([5, 11])
x = np.linalg.solve(A, b)
print(f"Solution : {x}") # [1. 2.]
L'opérateur @ pour le produit matriciel (introduit en Python 3.5) est bien plus lisible que np.dot(). C'est devenu la norme.
Opérations tensorielles avec einsum
Pour les opérations tensorielles complexes, np.einsum est un outil puissant (et élégant, il faut l'avouer). Il utilise la notation d'Einstein pour exprimer des calculs de manière très compacte :
# Produit matriciel avec einsum
C = np.einsum('ij,jk->ik', A, B) # Équivalent à A @ B
# Trace d'une matrice
trace = np.einsum('ii->', A) # Équivalent à np.trace(A)
# Produit élément par élément puis somme (produit scalaire)
dot = np.einsum('i,i->', np.array([1,2,3]), np.array([4,5,6]))
print(dot) # 32
Nouveautés majeures de NumPy 2.x (2.0 à 2.4)
StringDType : enfin un vrai type pour les chaînes
NumPy 2.0 a introduit StringDType, un type de chaîne à longueur variable encodé en UTF-8. C'est un vrai progrès par rapport aux anciens types de chaînes, à la fois plus performant et mieux intégré :
from numpy.dtypes import StringDType
# Créer un tableau de chaînes avec le nouveau StringDType
noms = np.array(["Alice", "Bob", "Charlie"], dtype=StringDType())
print(noms.dtype) # StringDType()
# Opérations sur les chaînes via numpy.strings
import numpy as np
resultat = np.strings.upper(noms)
print(resultat) # ['ALICE' 'BOB' 'CHARLIE']
# Recherche dans les chaînes
contient = np.strings.find(noms, "li")
print(contient) # [ 1 -1 4] — position ou -1 si absent
API publique nettoyée
Depuis la version 2.0, NumPy a fait un gros ménage dans son API publique. Le nombre d'objets dans l'espace de noms principal a diminué d'environ 10 %, et celui de numpy.lib de près de 80 %. Chaque fonction est désormais accessible depuis un seul endroit, ce qui simplifie pas mal l'apprentissage.
Nouvelles règles de promotion de types (NEP 50)
La NEP 50, adoptée dans NumPy 2.0, change la façon dont les types sont promus lors d'opérations mixtes. Le comportement est maintenant prévisible : la promotion dépend uniquement du dtype des tableaux, plus des valeurs qu'ils contiennent :
# Depuis NumPy 2.0 : comportement prévisible basé uniquement sur les dtypes
arr_int = np.array([1, 2, 3], dtype=np.int8)
resultat = arr_int + np.float32(1.5)
print(resultat.dtype) # float32 — promotion prévisible
C'est un changement qui peut casser du code existant, mais qui rend le comportement beaucoup plus logique à long terme.
Support de Python free-threaded
NumPy 2.1 à 2.4 ont progressivement amélioré le support de Python 3.13+ en mode free-threaded (sans GIL). Concrètement, ça signifie un vrai parallélisme multi-thread pour les opérations NumPy. Si vous travaillez sur des workloads concurrents, c'est une avancée significative.
Protocole __numpy_dtype__ (NumPy 2.4)
NumPy 2.4 a introduit le protocole __numpy_dtype__, qui facilite la création de types personnalisés compatibles avec NumPy. C'est surtout intéressant pour les développeurs de bibliothèques tierces qui veulent une intégration plus profonde avec l'écosystème NumPy.
NumPy en pratique : cas d'usage en data science
Normalisation de données pour le machine learning
Avant de nourrir un modèle ML, il faut généralement normaliser les features. Voici les deux approches classiques :
# Normalisation min-max
def normalisation_minmax(data):
min_val = data.min(axis=0)
max_val = data.max(axis=0)
return (data - min_val) / (max_val - min_val)
# Standardisation (z-score)
def standardisation(data):
return (data - data.mean(axis=0)) / data.std(axis=0)
# Exemple avec des données simulées
rng = np.random.default_rng(42)
features = rng.normal(loc=[50, 1000, 0.5], scale=[10, 200, 0.1], size=(100, 3))
normalise = normalisation_minmax(features)
standardise = standardisation(features)
print(f"Min après normalisation : {normalise.min(axis=0)}") # [0. 0. 0.]
print(f"Max après normalisation : {normalise.max(axis=0)}") # [1. 1. 1.]
print(f"Moyenne après standardisation : {standardise.mean(axis=0).round(2)}") # [0. 0. 0.]
En production, on utilise souvent sklearn.preprocessing pour ça, mais comprendre le mécanisme sous-jacent avec NumPy reste essentiel.
Analyse statistique descriptive
# Jeu de données : notes d'étudiants sur 5 matières
rng = np.random.default_rng(42)
notes = rng.integers(0, 21, size=(30, 5))
# Statistiques descriptives
print(f"Moyenne par matière : {notes.mean(axis=0).round(1)}")
print(f"Écart-type par matière : {notes.std(axis=0).round(1)}")
print(f"Médiane globale : {np.median(notes)}")
print(f"Note la plus fréquente : {np.argmax(np.bincount(notes.ravel()))}")
# Percentiles
q25, q50, q75 = np.percentile(notes, [25, 50, 75])
print(f"Quartiles : Q1={q25}, Q2={q50}, Q3={q75}")
print(f"IQR : {q75 - q25}")
# Matrice de corrélation
correlation = np.corrcoef(notes.T)
print(f"Corrélation entre matières 1 et 2 : {correlation[0, 1]:.3f}")
Traitement d'images avec NumPy
On n'y pense pas toujours, mais une image n'est rien d'autre qu'un tableau de nombres. NumPy est donc parfaitement adapté pour les manipulations basiques :
# Simuler une image en niveaux de gris (256x256 pixels)
rng = np.random.default_rng(42)
image = rng.integers(0, 256, size=(256, 256), dtype=np.uint8)
# Appliquer un seuil binaire
seuil = 128
binaire = (image > seuil).astype(np.uint8) * 255
# Inverser l'image
inversee = 255 - image
# Ajuster la luminosité
luminosite = np.clip(image.astype(np.int16) + 50, 0, 255).astype(np.uint8)
# Calculer l'histogramme
histogramme = np.bincount(image.ravel(), minlength=256)
print(f"Pixel le plus fréquent : intensité {np.argmax(histogramme)}")
Optimiser les performances avec NumPy
Règle d'or : éviter les boucles Python
Je le répète parce que c'est vraiment la chose la plus importante à retenir : si vous écrivez une boucle for sur un tableau NumPy, il y a probablement une meilleure façon de faire.
# Mauvaise pratique : boucle Python
def distance_euclidienne_lente(a, b):
total = 0
for i in range(len(a)):
total += (a[i] - b[i]) ** 2
return total ** 0.5
# Bonne pratique : vectorisation NumPy
def distance_euclidienne_rapide(a, b):
return np.sqrt(np.sum((a - b) ** 2))
# Encore mieux : utiliser linalg.norm
def distance_euclidienne_optimale(a, b):
return np.linalg.norm(a - b)
Utiliser les bons types de données
Réduire la précision numérique quand la pleine précision n'est pas nécessaire, c'est un gain facile. Vous pouvez doubler la vitesse de calcul et diviser par deux la consommation mémoire :
import time
taille = 10_000_000
# float64 (par défaut)
arr64 = np.random.randn(taille).astype(np.float64)
debut = time.perf_counter()
_ = np.sum(arr64 ** 2)
t64 = time.perf_counter() - debut
# float32
arr32 = arr64.astype(np.float32)
debut = time.perf_counter()
_ = np.sum(arr32 ** 2)
t32 = time.perf_counter() - debut
print(f"float64 : {t64:.4f}s | float32 : {t32:.4f}s")
print(f"Mémoire float64 : {arr64.nbytes / 1e6:.0f} Mo")
print(f"Mémoire float32 : {arr32.nbytes / 1e6:.0f} Mo")
Opérations in-place pour économiser la mémoire
Quand vous travaillez avec de gros tableaux, chaque allocation mémoire compte. Les opérations in-place modifient le tableau existant au lieu d'en créer un nouveau :
# Crée un nouveau tableau (consomme plus de mémoire)
a = np.ones(10_000_000)
b = a + 1 # Nouveau tableau alloué
# Modification in-place (économise la mémoire)
a = np.ones(10_000_000)
a += 1 # Modifie le tableau existant
np.add(a, 1, out=a) # Équivalent explicite
NumPy vs Pandas : quand utiliser quoi ?
C'est une question qui revient souvent, et la réponse est simple : ils ne font pas la même chose.
- NumPy : calcul numérique pur, algèbre linéaire, traitement d'images, opérations sur des tableaux homogènes, alimentation de modèles ML
- Pandas : données tabulaires hétérogènes, nettoyage de données, analyse exploratoire, import/export CSV/Excel/SQL
En pratique, les deux travaillent main dans la main. Pandas est construit sur NumPy, et chaque Series contient un ndarray en interne. Les données passent naturellement de l'un à l'autre :
import pandas as pd
# De Pandas vers NumPy
df = pd.DataFrame({'age': [25, 30, 35], 'salaire': [30000, 45000, 55000]})
arr = df.to_numpy()
print(arr.dtype) # float64
# De NumPy vers Pandas
arr = np.random.randn(100, 3)
df = pd.DataFrame(arr, columns=['feature_1', 'feature_2', 'feature_3'])
Mon conseil : commencez avec Pandas pour charger et nettoyer vos données, puis passez en NumPy (souvent via Scikit-learn) pour les calculs du modèle.
FAQ
Quelle est la différence entre une liste Python et un tableau NumPy ?
Une liste Python peut contenir des éléments de types différents et stocke des références vers des objets dispersés en mémoire. Un tableau NumPy (ndarray) ne contient que des éléments du même type, stockés de façon contiguë. Cette organisation permet à NumPy d'être environ 35 fois plus rapide pour les opérations numériques, avec une consommation mémoire nettement réduite.
Faut-il migrer vers NumPy 2.4 depuis une version 1.x ?
Oui, je le recommande. NumPy 2.x apporte une API simplifiée, le nouveau type StringDType, de meilleures performances et le support de Python 3.13+ free-threaded. Attention cependant : certaines fonctions obsolètes ont été supprimées. Testez votre code avec numpy.testing et consultez le guide de migration officiel avant de passer en production.
Pourquoi NumPy est-il plus rapide que Python pur ?
NumPy exécute ses calculs en C compilé sur des blocs de mémoire contigus, ce qui élimine les surcoûts de l'interprétation Python et du typage dynamique. Les opérations vectorisées traitent des tableaux entiers en une seule instruction, sans boucle Python. En plus, NumPy exploite les instructions SIMD et les bibliothèques BLAS/LAPACK pour l'algèbre linéaire.
Comment choisir entre float32 et float64 dans NumPy ?
Utilisez float64 (le défaut) quand la précision est critique : calculs financiers, systèmes linéaires mal conditionnés, statistiques avancées. Préférez float32 quand la vitesse et la mémoire comptent davantage : traitement d'images, deep learning, très grands jeux de données. En float32, vous divisez la mémoire par deux avec une perte de précision souvent négligeable.
NumPy peut-il remplacer Pandas pour l'analyse de données ?
Non, et ce n'est pas son objectif. NumPy excelle dans le calcul numérique sur des tableaux homogènes, tandis que Pandas est conçu pour les données tabulaires avec des colonnes nommées et des index. Dans un projet data science typique, vous utiliserez les deux : Pandas pour le chargement et l'exploration, NumPy pour les calculs du modèle.