Boîte à outils numérique : NumPy, pandas, polars, Dask
Ces bibliothèques ne sont pas interchangeables : chacune répond à une contrainte précise de volume, de latence ou de type de calcul. Ce module explique ce qu'elles font réellement sous le capot — parce que c'est ce qui permet de choisir la bonne, et d'écrire du code dix à cent fois plus rapide sans rien changer à l'algorithme.
📋 Au programme
- NumPy : pourquoi c'est rapide
- Vues et copies
- Broadcasting
- Vectoriser, vraiment
- pandas : le modèle et ses pièges
- polars : expressions et évaluation paresseuse
- Dask, Spark, Ray : passer à l'échelle
- Quel outil pour quelle taille
- scikit-learn : l'API qui structure tout
- PyTorch et la différentiation automatique
1. NumPy : pourquoi c'est rapide
Le problème d'une liste Python
Une liste Python de un million de nombres n'est pas un bloc de un million de nombres : c'est un tableau de un million de pointeurs, chacun désignant un objet float alloué ailleurs en mémoire, avec son en-tête, son compteur de références et son type. Additionner deux listes élément par élément oblige donc l'interpréteur, à chaque itération, à suivre deux pointeurs, vérifier les types, déballer les valeurs, additionner, allouer un nouvel objet résultat. Le calcul utile représente une fraction infime du travail.
Un ndarray NumPy est l'inverse : un bloc de mémoire contigu et homogène, plus une description de la façon de le lire. Les valeurs y sont stockées brutes, les unes derrière les autres, sans en-tête ni indirection. Additionner deux tableaux descend alors dans une boucle C compilée, qui traite les éléments à la chaîne en exploitant les instructions vectorielles du processeur.
import numpy as np
a = np.arange(12, dtype=np.float64).reshape(3, 4)
a.shape # (3, 4) la forme logique
a.dtype # float64 le type, identique pour TOUS les éléments
a.strides # (32, 8) combien d'octets sauter pour avancer d'un cran…
# …sur l'axe 0 (une ligne = 4 × 8 octets) et sur l'axe 1
a.nbytes # 96 la mémoire réellement occupée
a.flags # C_CONTIGUOUS : les lignes sont contiguës en mémoireLes strides sont la notion qui explique presque tout le comportement de NumPy. Les données sont un ruban linéaire ; la forme et les strides disent comment le parcourir. Transposer une matrice, par exemple, ne déplace aucune donnée : il suffit d'inverser les strides.
b = a.T # transposée b.strides # (8, 32) — les strides sont inversés, les données inchangées b.base is a # True : b n'est qu'une nouvelle VUE sur la mémoire de a np.shares_memory(a, b) # True
Choisir son dtype, plutôt que le subir
Le type par défaut est float64 (8 octets). Passer en float32 divise la mémoire par deux et accélère souvent les calculs — c'est d'ailleurs le type natif des GPU. Deux précautions : en float32, sommer plusieurs millions de termes accumule une erreur d'arrondi visible (utiliser np.sum(x, dtype=np.float64) si la précision compte), et un entier trop petit déborde en silence :
np.array([200], dtype=np.int8) + 100 # array([44]) — débordement, aucun avertissement np.array([200], dtype=np.int16) + 100 # array([300]) — correct
2. Vues et copies
C'est la source d'erreurs numéro un chez qui débute avec NumPy. Le découpage (slicing) renvoie une vue — une autre façon de lire la même mémoire — alors que l'indexation par tableau d'indices ou par masque booléen renvoie une copie.
x = np.arange(10) v = x[2:5] # VUE : aucune donnée copiée v[0] = 999 x[2] # 999 → x a été modifié sans qu'on l'ait touché c = x[[2, 3, 4]] # COPIE (indexation par liste d'indices) c[0] = -1 x[2] # 999 → x est intact m = x[x > 5] # COPIE (masque booléen) np.shares_memory(x, v) # True np.shares_memory(x, c) # False
Ce comportement est voulu : une vue est gratuite, alors qu'une copie d'un tableau de 4 Go coûte 4 Go. Mais il impose de savoir dans quel cas on se trouve. La règle pratique : si l'on veut modifier sans affecter l'original, écrire explicitement x[2:5].copy() ; et pour modifier en place une partie d'un tableau, utiliser l'affectation par découpage (x[x > 5] = 0), qui, elle, écrit bien dans l'original.
3. Broadcasting
Le broadcasting permet d'opérer sur des tableaux de formes différentes sans les dupliquer. Les règles tiennent en trois lignes : les formes sont alignées par la droite ; deux dimensions sont compatibles si elles sont égales ou si l'une vaut 1 ; une dimension de taille 1 est « étirée » virtuellement, en mettant simplement son stride à zéro — aucune allocation n'a lieu.
X (1000, 50) X (1000, 50) X (3, 1)
mu (50,) ✓ poids (1000,) ✗ Y (4,) ✓
───────────────────── ──────────────────── ──────────────────
résultat (1000, 50) ERREUR : 50 ≠ 1000 résultat (3, 4)
→ il faut poids[:, None] (produit externe)X = np.random.randn(1000, 50) mu = X.mean(axis=0) # (50,) une moyenne par colonne Xc = X - mu # broadcasting : mu n'est PAS dupliquée 1000 fois # centrer par LIGNE demande d'ajouter explicitement l'axe manquant moy_ligne = X.mean(axis=1) # (1000,) Xc = X - moy_ligne[:, None] # (1000,1) contre (1000,50) → compatible # X - moy_ligne lèverait une erreur : (50,) et (1000,) ne s'alignent pas
np.linalg.norm(X[:, None, :] - X[None, :, :], axis=-1), construit un intermédiaire de forme (n, n, d) : pour n = 1000 et d = 50, cela fait 400 Mo ; pour n = 20 000, cela fait 160 To et le programme meurt. Réflexe : dès qu'une expression ajoute un axe, multiplier mentalement les dimensions avant d'exécuter. Pour ce cas précis, scipy.spatial.distance.cdist ou sklearn.metrics.pairwise_distances travaillent par blocs.
4. Vectoriser, vraiment
Une boucle Python sur un tableau NumPy cumule les deux inconvénients : le coût de l'interpréteur et la conversion de chaque élément en objet Python. L'opération vectorisée équivalente reste entièrement dans le code natif.
x = np.random.randn(1_000_000) # ≈ 400 ms : un million de tours d'interpréteur out = np.array([v ** 2 + 3 * v if v > 0 else 0.0 for v in x]) # ≈ 4 ms : une seule descente dans le code compilé out = np.where(x > 0, x ** 2 + 3 * x, 0.0)
# agrégations par axe
X.sum(axis=0) # somme par colonne X.mean(axis=1) # moyenne par ligne
X.argmax(axis=1) # indice du maximum np.percentile(X, 95, axis=0)
# sélection et conditions
np.where(cond, a, b) np.clip(x, 0, 1) np.select([c1, c2], [v1, v2], defaut)
# einsum : produits et contractions, sans reshape acrobatique
np.einsum("ij,jk->ik", A, B) # produit matriciel classique
np.einsum("ij,ij->i", A, B) # produit scalaire ligne à ligne
np.einsum("ii->", A) # trace
# opérations "sur place" : évitent une allocation (utile sur de gros tableaux)
np.multiply(X, 2, out=X) # équivalent de X *= 2
# quand l'algorithme est SÉQUENTIEL et ne se vectorise pas : Numba compile la boucle
from numba import njit
@njit # décorateur (module 1) : compilation à la volée
def recurrence(x, alpha):
y = np.empty_like(x)
y[0] = x[0]
for i in range(1, len(x)): # boucle Python… compilée en code machine
y[i] = alpha * x[i] + (1 - alpha) * y[i - 1]
return y5. pandas : le modèle et ses pièges
Le modèle
pandas ajoute à NumPy ce qui manque pour des données réelles : des colonnes de types différents, des étiquettes, et la gestion des valeurs manquantes. Deux objets à connaître : la Series (une colonne : un tableau de valeurs plus un index) et le DataFrame (un dictionnaire ordonné de Series partageant le même index).
L'index est le concept le moins intuitif. Ce n'est pas un simple numéro de ligne : c'est une étiquette, utilisée pour l'alignement automatique. Additionner deux Series ne fait pas correspondre les positions mais les étiquettes — ce qui est puissant et surprenant la première fois :
a = pd.Series([1, 2, 3], index=["x", "y", "z"]) b = pd.Series([10, 20, 30], index=["z", "y", "x"]) a + b # x: 31, y: 22, z: 13 → aligné par étiquette, pas par position
import pandas as pd
df = (
pd.read_parquet("data/commandes.parquet")
.assign( # créer des colonnes
date=lambda d: pd.to_datetime(d["date"]),
marge=lambda d: d["prix"] - d["cout"],
)
.query("marge > 0") # filtrer
.astype({"categorie": "category"}) # mémoire ÷ 10 sur du texte répété
.sort_values("date")
)L'écriture chaînée n'est pas qu'une question de style : chaque étape produit un nouvel objet, ce qui évite les mutations en place et donc les états intermédiaires impossibles à reproduire dans un notebook. Les lambda permettent de référencer le DataFrame tel qu'il est à cette étape, sans variable intermédiaire.
Split-apply-combine
DataFrame SPLIT APPLY COMBINE cat val cat=A : [10, 30] A : mean = 20 cat val A 10 cat=B : [20] B : mean = 20 A 20 B 20 cat=C : [5, 15] C : mean = 10 B 20 A 30 C 10 C 5 C 15
stats = (
df.groupby(["categorie", pd.Grouper(key="date", freq="ME")]) # ME = fin de mois
.agg(ca=("prix", "sum"), n=("prix", "size"), marge_moy=("marge", "mean"))
.reset_index()
)
# transform : renvoie un résultat de MÊME TAILLE que l'entrée (comme une fonction
# de fenêtre SQL, module 4) — indispensable pour normaliser par groupe
df["ecart_a_la_moyenne"] = df["prix"] - df.groupby("categorie")["prix"].transform("mean")1.
SettingWithCopyWarning — df[df.a > 0]["b"] = 1 écrit dans un objet temporaire qui est aussitôt jeté : df n'est pas modifié, et aucune erreur n'est levée. La forme correcte est df.loc[df.a > 0, "b"] = 1. Retenir la règle : une seule paire de crochets, avec .loc.2.
apply ligne par ligne — df.apply(f, axis=1) repasse par l'interpréteur pour chaque ligne : cent fois plus lent qu'une expression vectorisée équivalente. On y recourt en dernier ressort, jamais par défaut.3. La mémoire — un DataFrame occupe typiquement 3 à 10 fois la taille du CSV d'origine, parce que chaque chaîne est un objet Python. Colonnes textuelles répétées en
category, entiers en int32, et df.info(memory_usage="deep") pour connaître le vrai coût.
6. polars : expressions et évaluation paresseuse
polars est écrit en Rust sur le format mémoire Apache Arrow. Deux différences de fond avec pandas : son moteur est multi-thread par défaut (pas de GIL à l'intérieur, module 2), et il propose un mode paresseux où la requête est d'abord construite, puis optimisée, puis exécutée — exactement comme un moteur SQL.
import polars as pl
q = (
pl.scan_parquet("data/commandes_*.parquet") # scan_ : rien n'est lu pour l'instant
.filter(pl.col("date") >= pl.date(2025, 1, 1))
.with_columns(marge=pl.col("prix") - pl.col("cout"))
.group_by("categorie")
.agg(
ca=pl.col("prix").sum(),
marge_moy=pl.col("marge").mean(),
n=pl.len(),
)
.sort("ca", descending=True)
)
print(q.explain()) # le plan optimisé, comme un EXPLAIN SQL (module 4)
df = q.collect() # ICI seulement, le calcul a lieu
df = q.collect(streaming=True) # par morceaux, pour un volume > RAML'optimiseur fait deux choses décisives. La projection pushdown : seules les colonnes réellement utilisées sont lues sur le disque — sur un fichier de 50 colonnes dont on en utilise 4, le gain est immédiat. La predicate pushdown : les filtres descendent au plus près de la lecture, parfois jusqu'aux métadonnées des fichiers Parquet, qui permettent de sauter des blocs entiers sans les décompresser.
Le second apport est conceptuel : une expression (pl.col("x").sum()) est un objet réutilisable et composable, qu'on peut nommer, stocker dans une variable et appliquer ailleurs — là où pandas manipule des chaînes de caractères magiques. En pratique : facteur 5 à 20 sur les agrégations et jointures. Le coût de migration est réel (API différente, pas d'index), mais les deux cohabitent, df.to_pandas() et pl.from_pandas() étant peu coûteux grâce à Arrow.
7. Dask, Spark, Ray : passer à l'échelle
Dask découpe un grand DataFrame en partitions — chacune étant un DataFrame pandas ordinaire —, construit un graphe de tâches, puis l'exécute en parallèle sur plusieurs cœurs ou plusieurs machines, en traitant les partitions les unes après les autres (out-of-core) plutôt qu'en chargeant tout en mémoire.
import dask.dataframe as dd
ddf = dd.read_parquet("s3://bucket/annee=2025/*.parquet") # graphe, rien n'est chargé
resultat = ddf[ddf.montant > 0].groupby("client_id").montant.sum()
out = resultat.compute() # déclenche le calcul, renvoie une Series pandasLe point délicat s'appelle le shuffle : toute opération qui redistribue les données entre partitions — un groupby sur une clé non triée, une jointure sur une colonne quelconque, un tri global — oblige à faire transiter les données par le réseau ou le disque. C'est là que passe l'essentiel du temps, et c'est la raison pour laquelle un pipeline distribué mal conçu est plus lent qu'un pipeline local.
- Dask — même API que pandas et NumPy : idéal pour passer d'un poste de travail à un cluster sans réécrire.
- Spark (PySpark) — l'écosystème d'entreprise, très mature, adapté aux vrais téraoctets, avec un coût opérationnel élevé.
- Ray — calcul distribué générique (entraînement, tuning, RL, servage) plutôt que DataFrames.
8. Quel outil pour quelle taille
Volume Outil recommandé Remarque
──────────────────────────────────────────────────────────────────────────────
< 1 Go pandas ou polars tout tient en RAM
1 – 50 Go polars (lazy) ou DuckDB la RAM d'une machine moderne
suffit encore largement
50 – 500 Go polars streaming, DuckDB, Dask une grosse machine coûte souvent
moins cher qu'un cluster mal utilisé
> 500 Go Spark, Dask sur cluster la complexité devient justifiée
Tableaux denses NumPy (+ Numba) calcul numérique pur
GPU PyTorch, CuPy, cuDF si le calcul est massivement
parallèleLe conseil qui fait gagner le plus de temps
La plupart des jeux de données présentés comme « trop gros » tiennent en mémoire une fois convertis en Parquet, les colonnes inutiles supprimées et les types réduits. Avant de monter un cluster : convertir le CSV en Parquet (compression 5 à 10×, lecture par colonnes), ne garder que les colonnes utiles, échantillonner pour l'exploration. Un cluster mal exploité est plus lent, plus cher et beaucoup plus difficile à déboguer qu'un ordinateur portable bien utilisé.
9. scikit-learn : l'API qui structure tout
La vraie valeur de scikit-learn n'est pas sa collection d'algorithmes, c'est la régularité de son interface. Trois verbes, et tout objet de l'écosystème s'y conforme :
fit(X, y)— apprend des paramètres et les stocke dans l'objet, dans des attributs suffixés par un tiret bas (mean_,coef_) ; c'est la convention qui distingue un paramètre appris d'un hyperparamètre fourni ;transform(X)— applique la transformation apprise ;predict(X)/predict_proba(X)— produit la prédiction.
La conséquence est majeure : un Pipeline qui enchaîne des étapes se comporte lui-même comme un estimateur. La validation croisée réajuste donc toute la chaîne à chaque pli, ce qui constitue la protection la plus efficace contre la fuite de données (module 12) — un StandardScaler ajusté hors du pipeline aurait appris la moyenne du jeu de validation.
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.impute import SimpleImputer
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
num = ["surface", "n_pieces", "age"]
cat = ["ville", "type_bien"]
pre = ColumnTransformer([
# chaque groupe de colonnes reçoit son propre traitement
("num", make_pipeline(SimpleImputer(strategy="median"), StandardScaler()), num),
("cat", OneHotEncoder(handle_unknown="ignore", min_frequency=20), cat),
])
modele = Pipeline([
("pre", pre),
("gbm", HistGradientBoostingRegressor(max_iter=400, learning_rate=0.05)),
])
scores = cross_val_score(modele, X, y, cv=5, scoring="neg_root_mean_squared_error")
print(f"RMSE = {-scores.mean():.3f} ± {scores.std():.3f}")from sklearn.base import BaseEstimator, TransformerMixin
class ClipOutliers(BaseEstimator, TransformerMixin):
"""Écrête aux quantiles appris SUR LE TRAIN uniquement."""
def __init__(self, q=0.01):
self.q = q # hyperparamètre : attribut simple, même nom
def fit(self, X, y=None):
self.lo_ = np.quantile(X, self.q, axis=0) # paramètres APPRIS : tiret bas
self.hi_ = np.quantile(X, 1 - self.q, axis=0)
return self # fit renvoie toujours self
def transform(self, X):
return np.clip(X, self.lo_, self.hi_)En respectant ces conventions, l'objet s'intègre partout : pipelines, recherche d'hyperparamètres, sérialisation. TransformerMixin fournit gratuitement fit_transform, et BaseEstimator fournit get_params/set_params, indispensables aux recherches sur grille.
10. PyTorch et la différentiation automatique
Un framework de deep learning apporte trois choses : des tenseurs qui vivent sur GPU, la différentiation automatique, et une bibliothèque de couches et d'optimiseurs. Un tenseur ressemble à un ndarray, avec deux attributs en plus : un device (processeur ou GPU) et la capacité d'enregistrer les opérations qu'il subit.
La différentiation automatique est le cœur du système. Pendant le calcul, PyTorch construit un graphe des opérations effectuées ; backward() le remonte en appliquant la règle de dérivation des fonctions composées, et dépose le gradient dans l'attribut .grad de chaque tenseur concerné. On n'écrit donc jamais de dérivée à la main (module 8).
import torch x = torch.tensor([3.0], requires_grad=True) # "je veux le gradient par rapport à x" y = x ** 2 + 5 * x # PyTorch enregistre : carré, produit, somme y.backward() # remonte le graphe print(x.grad) # tensor([11.]) = 2x + 5 en x = 3 ✓
import torch, torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
modele = nn.Sequential( # un perceptron multicouche (module 16)
nn.Linear(10, 64), nn.ReLU(),
nn.Linear(64, 1),
).to(device)
perte_fn = nn.MSELoss()
opt = torch.optim.AdamW(modele.parameters(), lr=1e-3)
for epoque in range(100):
for xb, yb in chargeur: # mini-lots (module 8)
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad(set_to_none=True) # 1. REMETTRE LES GRADIENTS À ZÉRO
pred = modele(xb) # 2. passe avant
perte = perte_fn(pred, yb) # 3. calcul de la perte
perte.backward() # 4. passe arrière : remplit les .grad
opt.step() # 5. mise à jour des poids
modele.eval() # bascule en mode évaluation (dropout off)
with torch.no_grad(): # pas de graphe : plus rapide, moins de mémoire
val = sum(perte_fn(modele(x.to(device)), y.to(device)) for x, y in val_loader)
modele.train()opt.zero_grad(), chaque backward() ajoute au gradient précédent au lieu de le remplacer, et l'entraînement diverge sans message d'erreur. Ce comportement n'est pas un défaut : il permet l'accumulation de gradient volontaire, qui simule un grand lot sur une petite carte (module 25). Deux autres réflexes : torch.no_grad() pour toute évaluation, et modele.eval() / modele.train() pour basculer le comportement du dropout et de la batch norm.
PyTorch impératif, déboguable au pdb, domine la recherche. Le défaut aujourd'hui.
torch.compile compile le graphe à la volée : confort ET performance.
TensorFlow encore présent en production, écosystème mobile mûr (TFLite). À apprendre
si l'existant de l'équipe l'impose.
JAX fonctions pures et transformations composables (grad, vmap, jit).
Très utilisé en recherche scientifique et en calcul différentiable.
BoTorch construit sur PyTorch, pour l'optimisation bayésienne (module 19) :
optimiser un système dont chaque évaluation coûte cher.Ce qu'il faut retenir du module
- Un
ndarrayest un bloc contigu homogène : c'est ce qui rend NumPy rapide, et ce qui explique vues, strides et broadcasting. - Découpage = vue, indexation fantaisiste = copie ; en cas de doute,
np.shares_memory. - Le broadcasting évite les duplications mais peut créer des intermédiaires énormes : calculer leur taille avant.
- Vectoriser plutôt que boucler ; Numba quand l'algorithme est intrinsèquement séquentiel.
- pandas aligne par étiquette ; écriture chaînée,
.locpour écrire, types réduits pour la mémoire. - polars : multi-thread, expressions composables, mode paresseux avec optimisation de requête.
- Ne pas distribuer trop tôt : Parquet, colonnes utiles et types réduits règlent la majorité des problèmes de volume.
- Tout passer par un
Pipelinescikit-learn : c'est la meilleure assurance contre la fuite de données. - PyTorch : les gradients s'accumulent, il faut les remettre à zéro à chaque itération.