Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

À la sixième semaine d’un projet, vous aurez entraîné des dizaines de variantes de modèle. Sans enregistrement, « la bonne exécution » devient une rumeur : vous vous souvenez que la profondeur 8 marchait, mais plus avec quel taux d’apprentissage, sur quelle version des données, ni si le 0,83 dont vous vous souvenez était de la validation ou du test. Le suivi d’expériences est la discipline qui consiste à noter quatre choses pour chaque exécution, automatiquement :

  1. Les paramètres — tous les boutons : hyperparamètres, options de données, graine aléatoire.
  2. Les métriques — ce que vous avez mesuré, et sur quelle partition.
  3. Les artefacts — ce que l’exécution a produit : fichier de modèle, figures, prédictions.
  4. La version du code — le commit Git, pour que l’exécution soit rattachée à un code exact.

Cette discipline compte davantage, et non moins, quand c’est un agent qui mène les expériences pour vous. Un agent qui rapporte « la profondeur 8 était la meilleure » énonce une affirmation ; les enregistrements d’exécution sont ce qui vous permet de la contrôler. Dans ce carnet, nous construisons un outil de suivi complet en une trentaine de lignes de Python, nous l’utilisons sur une véritable mini-étude, puis nous regardons ce que les outils industriels (MLflow, Weights & Biases) ajoutent par-dessus les mêmes idées. Aucune nouvelle dépendance.

🖥️ Diapositives du cours — Séance 27 (ven. 4 déc.)

Un outil de suivi minimal

Une exécution = un fichier JSON dans ./runs/. Toute la conception tient là. JSON parce que c’est lisible par un humain et comparable ligne à ligne ; un fichier par exécution parce qu’ainsi les exécutions ne s’écrasent jamais l’une l’autre.

import json
import subprocess
import time
import uuid
from pathlib import Path

import pandas as pd

RUNS_DIR = Path("runs")


def git_rev():
    """Current commit hash, or 'unknown' outside a git repo."""
    try:
        out = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
                             capture_output=True, text=True, check=True)
        return out.stdout.strip()
    except Exception:
        return "unknown"


def log_run(params, metrics, artifacts=None, runs_dir=RUNS_DIR):
    """Record one experiment: params + metrics + artifacts + code version."""
    runs_dir.mkdir(exist_ok=True)
    record = {
        "run_id": uuid.uuid4().hex[:8],
        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        "code_version": git_rev(),
        "params": params,
        "metrics": metrics,
        "artifacts": artifacts or [],
    }
    path = runs_dir / f"run_{record['run_id']}.json"
    path.write_text(json.dumps(record, indent=2))
    return record["run_id"]


def load_runs(runs_dir=RUNS_DIR):
    """All runs as one flat DataFrame (params and metrics as columns)."""
    rows = []
    for path in sorted(runs_dir.glob("run_*.json")):
        r = json.loads(path.read_text())
        rows.append({"run_id": r["run_id"], "code_version": r["code_version"],
                     **r["params"], **r["metrics"]})
    return pd.DataFrame(rows)

Voilà tout l’outil de suivi. log_run est appelé une fois à la fin de chaque entraînement ; load_runs transforme le répertoire en une table prête pour l’analyse. Tout le reste de ce carnet consiste à s’en servir.

Une vraie mini-étude : classification lithologique

L’étude : classer la lithologie à partir de la table géochimique synthétique du chapitre 2 (mlgeo_synth.geochem_table, avec 5 % de bruit sur les étiquettes pour rester honnête), à l’aide du HistGradientBoostingClassifier de scikit-learn. Nous balayons une petite grille — trois taux d’apprentissage par deux profondeurs d’arbre, six exécutions — et nous suivons chaque exécution.

import numpy as np
import shutil
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split

from mlgeo_synth import geochem_table

# Idempotent notebook: start the study from an empty runs directory.
if RUNS_DIR.exists():
    shutil.rmtree(RUNS_DIR)

df = geochem_table(n=6000, label_noise=0.05, seed=11)
feature_cols = [c for c in df.columns if c != "label"]
X, y = df[feature_cols], df["label"]

# Train / validation / test. The test split exists but stays untouched:
# every decision in this notebook is made on the validation split.
X_trainval, X_test, y_trainval, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=0)
X_train, X_val, y_train, y_val = train_test_split(
    X_trainval, y_trainval, test_size=0.25, stratify=y_trainval, random_state=0)

print(f"train {len(X_train)}, val {len(X_val)}, test {len(X_test)} (untouched)")
train 3600, val 1200, test 1200 (untouched)
def run_experiment(learning_rate, max_depth, seed=0):
    """Train one model, log one run, return its run_id."""
    params = {
        "model": "HistGradientBoostingClassifier",
        "learning_rate": learning_rate,
        "max_depth": max_depth,
        "seed": seed,
        "data": "geochem_table(n=6000, label_noise=0.05, seed=11)",
    }
    t0 = time.time()
    clf = HistGradientBoostingClassifier(
        learning_rate=learning_rate, max_depth=max_depth, random_state=seed)
    clf.fit(X_train, y_train)
    metrics = {
        "train_f1_macro": f1_score(y_train, clf.predict(X_train), average="macro"),
        "val_f1_macro": f1_score(y_val, clf.predict(X_val), average="macro"),
        "fit_seconds": round(time.time() - t0, 2),
    }
    return log_run(params, metrics)


for lr in [0.03, 0.1, 0.5]:
    for depth in [2, 8]:
        run_id = run_experiment(learning_rate=lr, max_depth=depth)
        print(f"logged run {run_id}: lr={lr}, depth={depth}")
logged run 59a87189: lr=0.03, depth=2
logged run 2ea65d76: lr=0.03, depth=8
logged run 5839a691: lr=0.1, depth=2
logged run d4f8712c: lr=0.1, depth=8
logged run 827befe3: lr=0.5, depth=2
logged run 737fb4ac: lr=0.5, depth=8

Charger, comparer, décider

Six fichiers JSON reposent maintenant dans ./runs/. Le bénéfice : la comparaison est une opération sur un DataFrame, pas une fouille archéologique.

runs = load_runs()
runs[["run_id", "learning_rate", "max_depth",
      "train_f1_macro", "val_f1_macro", "fit_seconds"]].round(3)
Loading...
table = runs.pivot_table(index="learning_rate", columns="max_depth",
                         values="val_f1_macro").round(3)
print("validation macro-F1:")
table
validation macro-F1:
Loading...
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(6, 4))
for depth, group in runs.groupby("max_depth"):
    g = group.sort_values("learning_rate")
    ax.plot(g["learning_rate"], g["val_f1_macro"], "o-", label=f"max_depth={depth}")
    ax.plot(g["learning_rate"], g["train_f1_macro"], "o--", alpha=0.4)
ax.set_xscale("log")
ax.set_xlabel("learning rate")
ax.set_ylabel("macro F1")
ax.set_title("Validation (solid) and training (dashed) scores, 6 tracked runs")
ax.legend()
fig.tight_layout()
<Figure size 600x400 with 1 Axes>
best = runs.loc[runs["val_f1_macro"].idxmax()]
print(f"winner on the VALIDATION split: run {best['run_id']} "
      f"(lr={best['learning_rate']}, depth={best['max_depth']}, "
      f"val F1={best['val_f1_macro']:.3f})")
winner on the VALIDATION split: run 737fb4ac (lr=0.5, depth=8, val F1=0.953)

Lisez la figure avant de poursuivre. Les courbes d’entraînement en tirets grimpent vers 1,0 à mesure que le taux d’apprentissage et la profondeur augmentent, tandis que les courbes de validation en trait plein restent plates autour de 0,95 — les modèles profonds et agressifs mémorisent les 5 % de bruit d’étiquetage, et cela ne leur rapporte rien en validation. Avec des étiquettes bruitées, un F1 d’entraînement de 1,000 est un avertissement, pas un exploit. La seule configuration qui perd nettement est la plus agressive des configurations peu profondes (taux d’apprentissage 0,5, profondeur 2), qui sous-apprend la structure des classes tout en surréagissant au bruit.

Remarquez aussi combien les écarts sont faibles : les quatre meilleures configurations tiennent à quelques millièmes les unes des autres, et le « vainqueur » ne mène que d’environ 0,002. Gardez cela en tête pour l’exercice.

Deux règles que nous avons suivies, toutes deux issues du fil de l’évaluation équitable des chapitres 3 et 4 :

  • Le vainqueur est choisi sur la partition de validation. La partition de test n’a pas été touchée. Si nous rapportions maintenant le score de validation du vainqueur comme résultat principal, il serait biaisé de façon optimiste — nous avons pris le maximum de six nombres bruités. La partition de test se dépense exactement une fois, à la fin du projet.
  • Chaque nombre de la table remonte à un fichier d’exécution avec ses paramètres, sa version de code et son horodatage. Quand une collaboratrice (ou un agent, ou le relecteur 2) demande « comment savez-vous quelle exécution était la meilleure ? », la réponse est un fichier, pas un souvenir.

Ce qu’ajoutent MLflow et Weights & Biases

Notre outil de suivi en 30 lignes enregistre paramètres, métriques, artefacts et version du code. C’est aussi, exactement, le modèle de données des outils industriels — MLflow (open source, auto-hébergé ou infogéré) et Weights & Biases (service hébergé, palier académique gratuit). Ce qu’ils ajoutent, c’est de l’ingénierie autour des mêmes quatre enregistrements :

  • Une interface. Tables d’exécutions triables, graphiques en coordonnées parallèles, courbes de perte mises à jour en direct pendant l’entraînement — nos load_runs() et matplotlib, mais instantanés et partageables.
  • Des dépôts d’artefacts. Nous avons journalisé des chemins d’artefacts ; eux stockent les artefacts eux-mêmes (poids de modèles, figures, jeux de données) avec versionnage et déduplication, si bien que « le modèle de l’exécution 7f3a » reste téléchargeable des années plus tard.
  • La collaboration. Une équipe partage un même serveur de suivi ; les exécutions de chaque membre du groupe atterrissent au même endroit interrogeable, avec contrôle d’accès. C’est la différence qui compte à l’échelle d’un laboratoire.
  • Les intégrations. Des rappels de journalisation automatique pour scikit-learn, PyTorch et compagnie enregistrent paramètres et métriques sans appel explicite à log_run, plus l’orchestration des balayages d’hyperparamètres.

Les concepts sont identiques, et c’est tout l’intérêt d’avoir construit l’outil vous-même : quand vous adopterez MLflow pour votre projet final (ce que nous encourageons), vous saurez précisément ce qu’il fait et ce qu’un répertoire runs/ de fichiers JSON vous aurait donné gratuitement. Servez-vous des vrais outils quand vous travaillez avec d’autres personnes ; ne les confondez jamais avec la discipline elle-même.

Exercice : variance d’une exécution à l’autre

Votre étude suivie a une faiblesse : chaque exécution a utilisé seed=0. Donc « la profondeur 8 bat la profondeur 2 de 0,01 » est peut-être un fait, ou peut-être la chance de l’initialisation et du sous-échantillonnage.

Tâche. À l’aide de run_experiment, ajoutez des exécutions suivies pour la configuration gagnante avec au moins trois graines différentes. Chargez toutes les exécutions et quantifiez la variance d’une exécution à l’autre : moyenne, écart type et étendue de val_f1_macro selon les graines. Répondez ensuite : quelles différences de votre table à six exécutions sont plus grandes que la dispersion d’une graine à l’autre — et donc réelles ?