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 :
- 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.
- De bons choix par défaut sur données tabulaires. Les arbres à gradient boosting (le
HistGradientBoostingRegressorde 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.
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)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
Solution
- La classe minoritaire est supprimée en silence.
df[df["label"].isin(["granite", "basalt"])]écarte tous les échantillons d’andésite sous un commentaire évoquant du « bruit d’étiquetage ». Un problème à 3 classes devient un problème à 2 classes, plus facile ; l’exactitude annoncée porte sur une autre tâche que celle qui était posée, et en production ce serait une erreur scientifique silencieuse : le modèle ne peut jamais prédire l’andésite. - La mise à l’échelle (scaler) est ajustée avant le découpage.
StandardScaler().fit_transform(X)sur la matrice complète calcule des moyennes et des variances en utilisant les lignes de test, et le découpage n’intervient qu’ensuite. Les statistiques de l’ensemble de test fuient dans les caractéristiques d’entraînement. L’effet est faible ici, mais le schéma est exactement la fuite de données (data leakage) évoquée dans les leçons précédentes, et avec d’autres prétraitements (imputation, encodage par la cible) il peut être important. - La sélection de modèle utilise l’exactitude sur l’ensemble d’entraînement.
clf.score(X_train, y_train)récompense la mémorisation, si bien que cette comparaison favorise systématiquement le candidat le plus en surapprentissage. Ici, chaque modèle obtient ~1,0 sur des données qu’il a déjà vues, donc la comparaison ne peut absolument pas les distinguer. L’« exactitude du meilleur modèle » affichée ne dit rien de la généralisation.
L’odeur révélatrice : un score quasi parfait qui apparaît sans modèle de référence et sans évaluation sur données tenues à l’écart. Les vrais résultats viennent avec un modèle de référence à battre et un ensemble de test touché une seule fois.
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.