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.

1. La promesse de l’AutoML

Autour de 2018–2022, une vague d’outils d’AutoML a promis d’automatiser le milieu fastidieux de l’apprentissage automatique : choisir la famille de modèles, régler les hyperparamètres, annoncer le vainqueur. auto-sklearn, TPOT, H2O AutoML et pycaret proposaient tous une expérience compare_models() en une ligne, et les éditions antérieures de ce chapitre enseignaient pycaret sur le même jeu de données de température que la leçon 3.7.

Cette vague s’est retirée. La plupart des outils d’AutoML académiques ne sont plus maintenus, et pycaret est retiré de ce cours. Deux choses ont survécu et méritent d’être enseignées :

  1. Les bibliothèques d’optimisation d’hyperparamètres. La recherche automatisée des réglages d’un modèle reste une pratique standard ; Optuna en est aujourd’hui la bibliothèque par défaut.
  2. De bons choix par défaut sur données tabulaires. Les arbres à gradient boosting (le HistGradientBoostingRegressor de scikit-learn, LightGBM, XGBoost) l’emportent si régulièrement sur les tableaux de caractéristiques que la recherche de modèle n’est presque plus le goulot d’étranglement. Prenez un arbre à gradient boosting, réglez-le un peu, et consacrez le temps gagné à la qualité des données et à l’évaluation.

Cette leçon traite les deux survivants, puis regarde ce qui a remplacé l’AutoML en 2026 : les agents qui écrivent du code, et les compétences de vérification qu’ils exigent de vous.

🖥️ Diapositives du cours — Séance 17 (ven. 6 nov.)

2. La recherche d’hyperparamètres qui a survécu : recherche sur grille ou Optuna

Nous réutilisons exactement le jeu de données de la leçon 3.7 — même générateur, mêmes caractéristiques, même découpage — afin que les nombres soient comparables entre les deux leçons. Le modèle est HistGradientBoostingRegressor, et la quantité que nous optimisons est la MAE validée en croisé à 5 plis sur l’ensemble d’entraînement.

import numpy as np
import pandas as pd


def make_daily_temps(start="2012-01-01", end="2019-12-31", seed=42):
    """Synthetic Seattle-like daily maximum temperature record (degrees F).

    Seasonal climatology + a weak warming trend + AR(1) weather noise.
    Generated in-notebook so the lesson does not depend on a remote file.
    """
    rng = np.random.default_rng(seed)
    dates = pd.date_range(start, end, freq="D")
    day_of_year = dates.dayofyear.to_numpy()
    climatology = 62.0 - 15.0 * np.cos(2 * np.pi * (day_of_year - 203) / 365.25)
    trend = 0.05 * np.arange(len(dates)) / 365.25
    noise = np.zeros(len(dates))
    for i in range(1, len(dates)):
        noise[i] = 0.65 * noise[i - 1] + rng.normal(0.0, 3.0)
    df = pd.DataFrame(
        {
            "date": dates,
            "average": np.round(climatology, 1),  # historical average for that calendar day
            "actual": np.round(climatology + trend + noise, 1),
        }
    )
    df["temp_1"] = df["actual"].shift(1)  # yesterday's max
    df["temp_2"] = df["actual"].shift(2)  # two days ago
    df["month"] = df["date"].dt.month
    df["day"] = df["date"].dt.day
    df["doy_sin"] = np.sin(2 * np.pi * day_of_year / 365.25)
    df["doy_cos"] = np.cos(2 * np.pi * day_of_year / 365.25)
    return df.dropna().reset_index(drop=True)


df = make_daily_temps()

features = ["temp_1", "temp_2", "average", "month", "day", "doy_sin", "doy_cos"]
X = df[features]
y = df["actual"]

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
print("Training features:", X_train.shape, " Testing features:", X_test.shape)
Training features: (2190, 7)  Testing features: (730, 7)

Recherche sur grille

GridSearchCV essaie chaque combinaison d’une grille fixe de valeurs. Avec 3 profondeurs, 3 taux d’apprentissage et 2 limites de nœuds-feuilles, cela fait 18 candidats, chacun évalué par validation croisée à 5 plis : 90 ajustements.

import time
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import GridSearchCV, KFold, cross_val_score

cv = KFold(n_splits=5, shuffle=True, random_state=42)

param_grid = {
    "max_depth": [2, 4, 8],
    "learning_rate": [0.03, 0.1, 0.3],
    "max_leaf_nodes": [15, 31],
}

grid = GridSearchCV(
    HistGradientBoostingRegressor(random_state=42),
    param_grid,
    cv=cv,
    scoring="neg_mean_absolute_error",
)

t0 = time.perf_counter()
grid.fit(X_train, y_train)
grid_time = time.perf_counter() - t0

n_candidates = len(grid.cv_results_["params"])
grid_fits = n_candidates * cv.get_n_splits()
grid_cv_mae = -grid.best_score_
grid_test_mae = mean_absolute_error(y_test, grid.best_estimator_.predict(X_test))

print("Best params:", grid.best_params_)
print(f"Best CV MAE: {grid_cv_mae:.3f} F")
print(f"Fits: {grid_fits} ({n_candidates} candidates x {cv.get_n_splits()} folds), wall time {grid_time:.1f} s")
Best params: {'learning_rate': 0.1, 'max_depth': 2, 'max_leaf_nodes': 15}
Best CV MAE: 2.458 F
Fits: 90 (18 candidates x 5 folds), wall time 78.0 s

Optuna

Optuna échantillonne l’espace des hyperparamètres au lieu de parcourir une grille régulière. Son échantillonneur TPE construit un modèle des régions qui obtiennent de bons scores et y concentre les essais suivants. La fonction objectif peut être n’importe quelle quantité calculable : ici, la même MAE en validation croisée à 5 plis.

import optuna

optuna.logging.set_verbosity(optuna.logging.WARNING)


def objective(trial):
    params = {
        "max_depth": trial.suggest_int("max_depth", 2, 10),
        "learning_rate": trial.suggest_float("learning_rate", 0.01, 0.4, log=True),
        "max_leaf_nodes": trial.suggest_int("max_leaf_nodes", 10, 60),
    }
    model = HistGradientBoostingRegressor(random_state=42, **params)
    scores = -cross_val_score(model, X_train, y_train, cv=cv, scoring="neg_mean_absolute_error")
    return scores.mean()


study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler(seed=42))

t0 = time.perf_counter()
study.optimize(objective, n_trials=30)
optuna_time = time.perf_counter() - t0

optuna_fits = 30 * cv.get_n_splits()
optuna_cv_mae = study.best_value

print("Best params:", study.best_params)
print(f"Best CV MAE: {optuna_cv_mae:.3f} F")
print(f"Fits: {optuna_fits} (30 trials x {cv.get_n_splits()} folds), wall time {optuna_time:.1f} s")
Best params: {'max_depth': 3, 'learning_rate': 0.05958491008634781, 'max_leaf_nodes': 35}
Best CV MAE: 2.438 F
Fits: 150 (30 trials x 5 folds), wall time 145.8 s
# Refit each winner on the full training set, score once on the test set
optuna_model = HistGradientBoostingRegressor(random_state=42, **study.best_params)
optuna_model.fit(X_train, y_train)
optuna_test_mae = mean_absolute_error(y_test, optuna_model.predict(X_test))

comparison = pd.DataFrame(
    {
        "best CV MAE (F)": [grid_cv_mae, optuna_cv_mae],
        "test MAE (F)": [grid_test_mae, optuna_test_mae],
        "wall time (s)": [grid_time, optuna_time],
        "fits": [grid_fits, optuna_fits],
    },
    index=["GridSearchCV", "Optuna (TPE)"],
)
comparison.round(3)
Loading...

La recherche sur grille dépense son budget sur une grille fixe : chaque point est essayé, que son voisinage ait déjà semblé mauvais ou non. Optuna échantillonne l’espace continu et s’adapte aux essais passés ; il peut donc tomber entre les nœuds de la grille et sauter les régions mortes. Sur un problème aussi petit, la différence se compte en minutes ; sur une recherche en apprentissage profond où chaque ajustement dure une heure, elle se compte en jours. Dans les deux cas, les deux méthodes rapportent un score de validation croisée sur les données d’entraînement et ne touchent l’ensemble de test qu’une seule fois, à la fin.

3. 2026 : l’agent écrit le pipeline, vous le vérifiez

L’exploration de modèles se fait aujourd’hui souvent sur le mode de la conversation : vous décrivez le jeu de données à un agent fondé sur un LLM, il écrit le code du pipeline, l’exécute et rapporte un score. Le problème de recherche que l’AutoML tentait de résoudre est devenu un problème de vérification. Le code écrit par une machine échoue de la même manière que du code humain écrit dans la précipitation — prétraitement qui laisse fuiter des données, classes supprimées, scores calculés sur le mauvais découpage — mais avec plus d’aisance, enveloppé de commentaires soignés et d’une sortie assurée.

Voici ci-dessous un script de modélisation du genre qu’un assistant IA produira volontiers. Il s’exécute, il affiche un bon score, et il est faux de trois manières distinctes. Trouvez-les avant d’ouvrir la solution.

# --- AI-generated modeling script: do NOT trust it yet ---
import mlgeo_synth
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# Whole-rock geochemistry: oxide wt%, density, magnetic susceptibility -> rock type
df = mlgeo_synth.geochem_table(n=4000, seed=7)
df = df[df["label"].isin(["granite", "basalt"])]  # remove sparse label noise

feature_cols = ["SIO2", "AL2O3", "FEO", "MGO", "CAO", "NA2O", "K2O", "density_g_cm3", "mag_susc_si"]
X = df[feature_cols].to_numpy()
y = df["label"].to_numpy()

scaler = StandardScaler()  # normalize features
X = scaler.fit_transform(X)

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=7)

models = {
    "logistic_regression": LogisticRegression(max_iter=2000),
    "random_forest": RandomForestClassifier(n_estimators=200, random_state=7),
    "hist_gradient_boosting": HistGradientBoostingClassifier(random_state=7),
}

best_name, best_score = None, -1.0
for name, clf in models.items():
    clf.fit(X_train, y_train)
    score = clf.score(X_train, y_train)  # evaluate each candidate
    print(f"{name}: accuracy = {score:.3f}")
    if score > best_score:
        best_name, best_score = name, score

print(f"\nbest model: {best_name}, accuracy = {best_score:.3f}")
logistic_regression: accuracy = 1.000
random_forest: accuracy = 1.000
hist_gradient_boosting: accuracy = 1.000

best model: logistic_regression, accuracy = 1.000

Pipeline corrigé

Les corrections : conserver les trois classes ; découper d’abord, avec stratification pour que la minorité d’andésite apparaisse dans les deux sous-ensembles ; placer la mise à l’échelle à l’intérieur d’un Pipeline pour qu’elle ne soit ajustée que sur les plis d’entraînement ; rapporter un modèle de référence de classe majoritaire avant tout modèle ; choisir parmi les candidats d’après le F1 macro en validation croisée sur l’ensemble d’entraînement (le F1 macro pondère la classe minoritaire à égalité) ; et ne toucher l’ensemble de test qu’une fois, à la fin, avec des scores par classe.

from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, classification_report, f1_score
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline

df = mlgeo_synth.geochem_table(n=4000, seed=7)  # all three classes kept
print(df["label"].value_counts(), "\n")

X = df[feature_cols]
y = df["label"]

# Split FIRST, stratified so class proportions match in train and test
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=7, stratify=y
)

# Baseline before any model
dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
print(f"Majority-class baseline: accuracy = {dummy.score(X_test, y_test):.3f}, "
      f"macro-F1 = {f1_score(y_test, dummy.predict(X_test), average='macro'):.3f}\n")

candidates = {
    "logistic_regression": make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
    "random_forest": make_pipeline(StandardScaler(), RandomForestClassifier(n_estimators=200, random_state=7)),
    "hist_gradient_boosting": make_pipeline(StandardScaler(), HistGradientBoostingClassifier(random_state=7)),
}

best_name, best_cv = None, -1.0
for name, pipe in candidates.items():
    scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="f1_macro")
    print(f"{name}: CV macro-F1 = {scores.mean():.3f} +/- {scores.std():.3f}")
    if scores.mean() > best_cv:
        best_name, best_cv = name, scores.mean()

# One look at the test set, for the selected model only
final = candidates[best_name].fit(X_train, y_train)
y_pred = final.predict(X_test)
print(f"\nSelected model: {best_name}")
print(f"Test accuracy = {accuracy_score(y_test, y_pred):.3f}, "
      f"test macro-F1 = {f1_score(y_test, y_pred, average='macro'):.3f}\n")
print(classification_report(y_test, y_pred))
label
granite     2205
basalt      1397
andesite     398
Name: count, dtype: int64 

Majority-class baseline: accuracy = 0.551, macro-F1 = 0.237

logistic_regression: CV macro-F1 = 0.999 +/- 0.002
random_forest: CV macro-F1 = 0.999 +/- 0.003
hist_gradient_boosting: CV macro-F1 = 0.999 +/- 0.001

Selected model: hist_gradient_boosting
Test accuracy = 0.999, test macro-F1 = 0.998

              precision    recall  f1-score   support

    andesite       1.00      0.99      0.99       100
      basalt       1.00      1.00      1.00       349
     granite       1.00      1.00      1.00       551

    accuracy                           1.00      1000
   macro avg       1.00      1.00      1.00      1000
weighted avg       1.00      1.00      1.00      1000

Le pipeline corrigé rapporte lui aussi un score élevé — ces types de roches sont réellement bien séparés dans l’espace des oxydes — mais le nombre affirme désormais autre chose. Il couvre les trois classes, y compris l’andésite que le script de l’IA ne pouvait jamais prédire ; il est mesuré sur des données tenues à l’écart et non sur des données mémorisées ; et il se mesure à un modèle de référence majoritaire à 0,55. Mêmes chiffres, sens différent. Sur un jeu de données plus difficile, les deux flux de travail divergent : le score d’entraînement du script de l’IA reste proche de 1,0 quoi qu’il arrive, tandis que le nombre honnête chute pour vous le dire.

4. Ce qu’il faut retenir

L’automatisation a déménagé. En 2020, elle vivait dans les algorithmes de recherche — l’AutoML bouclant sur les modèles et les hyperparamètres. En 2026, elle vit dans les agents qui écrivent du code et produisent tout le pipeline à la demande. La vérification, elle, n’a pas bougé. Un modèle de référence trivial, un découpage sans fuite, une validation croisée à l’intérieur de l’ensemble d’entraînement et un unique regard sur un ensemble de test tenu à l’écart attrapent les erreurs écrites par la machine exactement comme ils attrapent celles des humains. Automatisez la recherche ; jamais la vérification.