Aller au contenu principal

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.

La reproductibilité bit à bit est une illusion sur GPU

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, torch et cuDNN couvre le CPU ; ajouter deterministic = True et CUBLAS_WORKSPACE_CONFIG pour 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.