TP5. Régression et validation croisée¶
Durée : 2 h.
Objectifs¶
- Distinguer un problème de régression (sortie continue) d'un problème de classification (sortie discrète), et choisir les bonnes métriques.
- Aller au-delà d'un simple partage entraînement/test avec la validation croisée.
- Comprendre l'hyperparamètre
max_depthcomme un curseur entre sous-apprentissage et surapprentissage, et le régler par une méthode plutôt qu'à l'œil.
Prérequis¶
Le TP3 : train_test_split, arbre de décision, surapprentissage.
Ce TP transpose ces notions à une sortie continue plutôt qu'à une étiquette.
Ressources¶
Étape 1. Régression contre classification (15 min)¶
Une classification prédit une étiquette parmi un ensemble fini (malin ou bénin, au TP3). Une régression prédit un nombre, qui peut prendre n'importe quelle valeur dans un intervalle continu : un prix, une température, une durée. Les métriques changent en conséquence : plus d'exactitude ni de rappel, mais des mesures d'écart.
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import mean_absolute_error, root_mean_squared_error, r2_score
housing = fetch_california_housing(as_frame=True)
X = housing.data
y = housing.target
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
reg = DecisionTreeRegressor(random_state=42)
reg.fit(X_train, y_train)
predictions = reg.predict(X_test)
print("MAE :", round(mean_absolute_error(y_test, predictions), 3))
print("RMSE :", round(root_mean_squared_error(y_test, predictions), 3))
print("R2 :", round(r2_score(y_test, predictions), 3))
Le jeu California Housing donne le prix médian d'un logement (en centaines de
milliers de dollars) pour un secteur, à partir de huit variables (revenu médian, âge des
logements, nombre de pièces...). Un arbre de décision non limité atteint ici un MAE de
0.454 (soit environ 45 400 $ d'erreur moyenne) et un R2 de 0.623.
Exercice 1.1 : trois métriques, trois lectures
MAE (erreur absolue moyenne), RMSE (racine de l'erreur quadratique moyenne) et
R2 (part de variance expliquée) mesurent tous un écart entre prédiction et réalité,
mais pas de la même façon. Le RMSE (0.703) est-il plus grand ou plus petit que le
MAE (0.454) ? Pourquoi, structurellement, ne peut-il jamais être plus petit ?
Résultat attendu : RMSE > MAE toujours, sauf cas particulier où toutes les
erreurs sont identiques. Le RMSE élève chaque écart au carré avant de moyenner,
donc une seule grosse erreur pèse disproportionnellement plus qu'un ensemble de
petites erreurs de même somme.
Corrigé
import numpy as np
ecarts = np.abs(y_test.values - predictions)
print("MAE recalculé :", round(ecarts.mean(), 3))
print("RMSE recalculé :", round(np.sqrt((ecarts ** 2).mean()), 3))
print("Plus grosse erreur :", round(ecarts.max(), 3))
Choisir RMSE plutôt que MAE revient à décider que les grosses erreurs comptent
plus que proportionnellement à leur taille : pertinent si une erreur énorme est
particulièrement coûteuse (une estimation immobilière dix fois trop haute), moins
pertinent si toutes les erreurs se valent également.
Étape 2. Au-delà du simple partage entraînement/test (20 min)¶
Ajuster un hyperparamètre en regardant le score sur le jeu de test, encore et encore, finit par surapprendre ce jeu de test précis, même sans jamais l'utiliser pour entraîner directement le modèle. La validation croisée limite ce risque en faisant tourner plusieurs partages.
from sklearn.model_selection import KFold, cross_val_score
reg5 = DecisionTreeRegressor(max_depth=5, random_state=42)
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(reg5, X_train, y_train, scoring="neg_root_mean_squared_error", cv=kf)
print("Scores (négatifs) :", scores.round(3))
print("RMSE moyen :", round(-scores.mean(), 3))
print("Écart-type :", round(scores.std(), 3))
L'IA vous le donne en trois secondes
Demandez à un assistant de calculer un RMSE avec scikit-learn. Une réponse
fréquente, héritée d'anciens tutoriels, écrit
mean_squared_error(y_test, predictions, squared=False). Testez-la :
from sklearn.metrics import mean_squared_error
try:
mean_squared_error(y_test, predictions, squared=False)
except TypeError as erreur:
print("Erreur :", erreur)
Le paramètre squared a été retiré de mean_squared_error : la fonction dédiée
root_mean_squared_error, utilisée dans ce TP depuis l'étape 1, l'a remplacé. Une
erreur de ce genre, TypeError explicite, se corrige vite ; le risque réel est un
assistant qui écrit mean_squared_error(...) ** 0.5 à la place, du code qui
fonctionne mais réinvente une fonction déjà fournie, moins lisible pour qui relit.
Exercice 2.1 : pourquoi les scores sont négatifs
scoring="neg_root_mean_squared_error" renvoie des valeurs négatives, que le code
ci-dessus retourne avec -scores.mean(). Cherchez dans la documentation scikit-learn
pourquoi le signe est inversé plutôt que de fournir directement "root_mean_squared_error".
Résultat attendu : par convention, les fonctions de score de scikit-learn suivent
la règle « plus grand est meilleur », pour que GridSearchCV et les outils de
sélection de modèle puissent toujours maximiser un score, quelle que soit la
métrique. Une erreur, elle, doit être minimisée : la négation transforme donc
« minimiser une erreur » en « maximiser un score », uniformément.
Corrigé
Il n'y a pas de code supplémentaire ici, la réponse est dans la documentation
scikit-learn (section Scoring parameter). Retenez la conséquence pratique :
n'oubliez jamais le signe - en lisant un score neg_*, une erreur classique
étant de rapporter un RMSE négatif dans un tableau de résultats sans s'apercevoir
de l'absurdité du signe.
Étape 3. Comparer plusieurs modèles par validation croisée (25 min)¶
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
import pandas as pd
def evalue(modele, X, y):
scores = -cross_val_score(modele, X, y, scoring="neg_root_mean_squared_error", cv=5, n_jobs=-1)
return scores.mean()
modeles = {
"Arbre de décision": DecisionTreeRegressor(random_state=42),
"Forêt aléatoire": RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1),
"Boosting de gradient": GradientBoostingRegressor(random_state=42),
}
resultats = pd.DataFrame(
[{"modèle": nom, "rmse_cv": round(evalue(m, X_train, y_train), 3)} for nom, m in modeles.items()]
)
print(resultats.sort_values("rmse_cv"))
Exercice 3.1 : le meilleur modèle en validation tient-il sur le test ?
Réentraînez le modèle le mieux classé en validation croisée sur tout X_train, puis
évaluez-le sur X_test. Le RMSE de test est-il cohérent avec le RMSE de validation
croisée ?
Résultat attendu : la forêt aléatoire arrive en tête en validation croisée avec un
RMSE moyen d'environ 0.511, loin devant l'arbre seul (0.724) et le boosting de
gradient (0.534). Le RMSE de test du modèle réentraîné reste proche du RMSE de
validation croisée, ce qui confirme que l'estimation n'était pas un coup de chance.
Corrigé
meilleur = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
meilleur.fit(X_train, y_train)
rmse_test = root_mean_squared_error(y_test, meilleur.predict(X_test))
print("RMSE test :", round(rmse_test, 3))
Un RMSE de test très différent du RMSE de validation croisée serait un signal d'alerte : soit le jeu de test n'est pas représentatif (rare avec un partage aléatoire sur 20 000 lignes), soit une fuite de données s'est glissée quelque part dans le protocole. Ici, les deux valeurs concordent, ce qui est le résultat attendu quand tout est fait correctement.
Étape 4. max_depth, un curseur entre deux erreurs (25 min)¶
Un arbre trop peu profond ne capte pas la structure des données (sous-apprentissage) ; un arbre trop profond mémorise le bruit de l'entraînement (surapprentissage, déjà vu au TP3). Entre les deux se trouve un optimum, que la validation croisée permet de localiser sans toucher au jeu de test.
resultats_profondeur = []
for profondeur in range(2, 11):
reg = DecisionTreeRegressor(max_depth=profondeur, random_state=42)
rmse_cv = -cross_val_score(reg, X_train, y_train, scoring="neg_root_mean_squared_error", cv=5, n_jobs=-1).mean()
resultats_profondeur.append({"max_depth": profondeur, "rmse_cv": round(rmse_cv, 3)})
df_profondeur = pd.DataFrame(resultats_profondeur)
print(df_profondeur)
Exercice 4.1 : trouver le minimum, pas le supposer
À partir de df_profondeur, trouvez automatiquement la profondeur qui minimise
rmse_cv, sans la lire à l'œil dans le tableau affiché.
Résultat attendu : le minimum se trouve à max_depth = 9 (rmse_cv ≈ 0.633).
Le RMSE diminue de façon presque monotone de la profondeur 2 à 9, puis remonte
légèrement à la profondeur 10 : la signature typique d'un optimum, pas d'un plateau.
Corrigé
idxmin() plutôt qu'une lecture visuelle du tableau n'est pas qu'une question de
confort : sur un tableau plus long, ou généré automatiquement par un pipeline sans
supervision humaine, c'est la seule façon fiable de retrouver le bon réglage. Cette
même logique (chercher le minimum d'une courbe erreur-complexité) est celle de
GridSearchCV, qui automatise ce que cette boucle fait à la main.
Pour aller plus loin¶
- Visualiser le sous-apprentissage et le surapprentissage sur un jeu synthétique en
une dimension (par exemple
y = sin(x) + bruit) rend le compromis biais-variance visible directement sur la courbe de prédiction, pas seulement sur un score. GridSearchCVcombine la recherche d'hyperparamètres et la validation croisée en un seul appel, plutôt que la boucle manuelle de l'étape 4.- Sur un jeu de données avec des variables de nature très différente (revenu, superficie, proximité d'une autoroute), une normalisation préalable peut aider certains modèles (moins les arbres, qui n'en ont pas besoin) : sujet du TP8.
Ce qu'il faut retenir¶
Une régression prédit une valeur continue, évaluée par MAE, RMSE ou R2, jamais par
l'exactitude. Le RMSE pénalise davantage les grosses erreurs que le MAE. La validation
croisée protège contre le risque de choisir un hyperparamètre qui colle trop bien au seul
jeu de test observé, en faisant tourner plusieurs partages et en moyennant. Les scores
neg_* de scikit-learn sont négatifs par convention, pour que maximiser un score
corresponde toujours à minimiser une erreur. Et max_depth, comme tout hyperparamètre de
complexité, a un optimum qui se cherche par une courbe erreur-profondeur, pas en devinant
une valeur ronde.
Auto-évaluation¶
Avant de continuer, vous devez pouvoir, sans regarder le corrigé :
- citer trois métriques de régression et dire ce que chacune mesure ;
- expliquer pourquoi RMSE est toujours supérieur ou égal à MAE ;
- expliquer ce qu'apporte la validation croisée par rapport à un simple partage entraînement/test ;
- dire pourquoi les scores
neg_*de scikit-learn sont négatifs ; - trouver, par le code, la valeur d'un hyperparamètre qui minimise une erreur de validation croisée.
Pas encore de QCM pour ce TP
Contrairement au module IA générative, ce TP n'a pas de QCM d'auto-évaluation prêt à
l'emploi : les TP d'IA prédictive n'en ont jamais eu. En créer un est possible avec le
skill qcm-generator, mais reste à faire.