LangueFR
Français
English
Retour au sommaire du cours

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.

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.

Python — la structure d'un ndarray
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émoire

Les 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.

Python — la différence, en pratique
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.

les règles, illustrées
  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)
Python
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
Le piège mémoire du broadcasting. Une expression vectorisée élégante peut créer un tableau intermédiaire gigantesque. Le calcul de toutes les distances deux à deux, 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.

Python — le même calcul, deux ordres de grandeur d'écart
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)
Python — les outils de vectorisation à connaître
# 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 y

5. 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
Python — le style chaîné, à privilégier
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

le mécanisme du groupby
  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")
Les trois pièges classiques.
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.

Python — le même traitement, en mode paresseux
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 > RAM

L'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.

Python
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 pandas

Le 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

arbre de décision
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èle

Le 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.

Python — un pipeline complet
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}")
Python — écrire son propre transformateur
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).

Python — autograd, pas à pas
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  ✓
Python — une boucle d'entraînement minimale mais complète
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()
Les gradients s'accumulent. C'est le bug numéro un des débutants : sans 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, TensorFlow et les autres, en 2026
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 ndarray est 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, .loc pour é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 Pipeline scikit-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.

NumPypandaspolarsDaskscikit-learnPyTorch