Module 2 — Reproductibilité : graines, environnements, verrouillage des versions
Le carnet d'Alice donne 0,82 le lundi et 0,79 le mardi, sur les mêmes données. Elle relance, obtient 0,81. Ce genre d'aléa rend impossible toute comparaison sérieuse entre deux idées. La reproductibilité n'est pas un luxe : c'est ce qui distingue « j'ai amélioré le modèle de 1 point d'AUC » de « le bruit d'exécution est de 1,5 point ».
Les trois graines qu'il faut fixer
Un pipeline d'apprentissage tire des nombres aléatoires à plusieurs
endroits : le mélange des données, l'initialisation des poids, l'ordre des
lots, l'abandon (dropout), la validation croisée. Chaque brique a son
propre générateur.
import os, random
import numpy as np
import torch
def fixer_graines(seed: int = 42) -> None:
"""Fixe toutes les graines connues du pipeline."""
os.environ["PYTHONHASHSEED"] = str(seed)
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
PYTHONHASHSEED fixe l'ordre d'itération des dictionnaires, qui influe sur
certaines opérations. random couvre la bibliothèque standard. numpy
couvre les opérations vectorielles. torch couvre les tenseurs et les
initialisations de couche. Oublier l'une des quatre laisse une source de
variation.
Sur GPU, il faut en plus désactiver deux optimisations qui échangent de la reproductibilité contre de la vitesse :
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
benchmark = True laisse cuDNN choisir l'algorithme le plus rapide pour
chaque forme de tenseur, en le mesurant à la première exécution ; ce choix
n'est pas déterministe. deterministic = True force des algorithmes dont le
résultat est identique d'une exécution à l'autre, au prix de 10 à 30 % de
lenteur. En développement, c'est le bon compromis ; en production
d'inférence, le contraire.
Verrouiller l'environnement
Une graine fixée ne sert à rien si la version de numpy change entre deux
lancements. Il existe trois niveaux de verrouillage, de moins en moins
naïf.
Un fichier requirements.txt sans version est le pire cas. pip install scikit-learn installe la dernière version publiée, qui change chaque
semaine. Le carnet qui marchait il y a un mois ne marche plus aujourd'hui.
Des versions figées (scikit-learn==1.3.2, numpy==1.26.4) résolvent
une partie du problème, mais laissent flotter les dépendances transitives.
scikit-learn 1.3.2 peut fonctionner avec deux versions différentes de
scipy, et un jour on obtiendra l'une ou l'autre.
Un fichier verrouillé complet (poetry.lock, uv.lock, pip-tools)
fige aussi les dépendances transitives, avec leurs empreintes de hachage.
C'est le seul niveau qui garantit qu'on installera exactement le même arbre
de paquets six mois plus tard.
Le conteneur d'entraînement
Verrouiller les paquets Python ne suffit pas : le système d'exploitation
apporte lui aussi des bibliothèques natives (libgomp, libc, pilotes
GPU). D'où l'usage d'un conteneur pour l'entraînement, pas seulement pour
le service.
FROM python:3.11-slim@sha256:ab12...
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install --no-deps -r <(uv export --format requirements-txt)
COPY src/ ./src/
ENV PYTHONHASHSEED=42
ENV CUBLAS_WORKSPACE_CONFIG=:4096:8
ENTRYPOINT ["python", "-m", "src.entrainement"]
L'épinglage par empreinte de hachage (sha256:...) de l'image de base rend
l'ensemble déterministe : même si Docker Hub republie python:3.11-slim
demain, votre image d'entraînement reste identique. CUBLAS_WORKSPACE_CONFIG
est la variable exigée par PyTorch pour rendre certaines opérations cuBLAS
déterministes sur GPU récent.
Les sources qui résistent
Même avec toutes ces précautions, quatre sources de variation subsistent sur GPU et méritent d'être connues.
Les réductions en virgule flottante (somme sur une dimension) ne sont
pas associatives : (a + b) + c ne donne pas exactement a + (b + c) sur
des flottants. Un GPU qui parallélise la réduction sur des blocs différents
d'une exécution à l'autre produit des résultats aux dixièmes de bit près.
atomicAdd sur des accumulateurs partagés est non déterministe par
nature : l'ordre d'accumulation dépend de la scheduler du GPU.
Le pilote CUDA lui-même peut changer d'une machine à l'autre. Un modèle
entraîné sur driver 535 et un autre sur driver 545 peuvent différer.
Le matériel enfin : une même image lancée sur une A100 et une H100
peut produire des sorties légèrement différentes, parce que les unités de
calcul ne sont pas physiquement identiques.
Viser « exactement les mêmes poids » sur GPU est un objectif que le matériel refuse. L'objectif utile est « une variation inférieure au bruit statistique du modèle », mesurée en relançant le même entraînement trois fois avec la même graine. Si l'écart-type de l'AUC sur trois exécutions dépasse l'amélioration prétendue, l'amélioration n'existe pas.
En résumé
- Fixer les quatre graines Python,
numpy,torchetcuDNNcouvre le CPU ; ajouterdeterministic = TrueetCUBLAS_WORKSPACE_CONFIGpour le GPU. - Un fichier verrouillé complet (empreintes de hachage des dépendances transitives) est le seul niveau utile.
- Le conteneur d'entraînement fige le système d'exploitation autour du
pipeline ; épinglez l'image de base par
sha256:.... - Sur GPU, la reproductibilité bit à bit n'existe pas : mesurez le bruit d'exécution et comparez toute amélioration à ce bruit.
Le module 3 ouvre le premier grand chantier de MLOps : suivre chaque
exécution avec MLflow pour que les 30 essais d'un après-midi restent
comparables trois mois plus tard.