Combinez des modèles pour mieux prédire
Au chapitre 4, vous avez construit des arbres de décision lisibles, mais instables : changez quelques patients du jeu d’apprentissage, et l’arbre peut changer du tout au tout. Que faire d’un modèle aussi capricieux ? En construire beaucoup, et les faire voter ! C’est l’idée des méthodes d’ensemble (aussi appelées méta-apprentissage), qui dominent aujourd’hui l’apprentissage sur données tabulaires.
Notre fil rouge reste le dépistage des maladies cardiaques (le jeu heart.csv des chapitres 3 et 4). Et nous regarderons, à la fin, ce qu’une forêt aléatoire nous a appris sur les absences aux rendez-vous médicaux.
Pourquoi plusieurs modèles valent mieux qu’un
Section intitulée « Pourquoi plusieurs modèles valent mieux qu’un »Imaginez cinq médecins qui se trompent chacun dans 30 % des cas, indépendamment les uns des autres. Si l’on suit l’avis majoritaire, il faut qu’au moins trois d’entre eux se trompent en même temps pour que le groupe se trompe. La probabilité que la majorité ait raison vaut :
où est le nombre de façons de choisir les médecins qui ont raison. Le groupe atteint environ 84 % de bonnes réponses, contre 70 % pour chacun. Avec 25 médecins, on dépasserait 98 %.
Tout repose sur un mot : indépendamment. Si les cinq médecins ont suivi la même formation et font les mêmes erreurs, le vote ne sert à rien. Pour qu’un ensemble soit utile, il faut des modèles à la fois bons et divers. Les deux grandes familles de méthodes d’ensemble fabriquent cette diversité de façons opposées.
| Bagging (et forêts aléatoires) | Boosting | |
|---|---|---|
| Construction | en parallèle, modèles indépendants | en séquence, chaque modèle corrige les précédents |
| Modèles de base | forts et instables (arbres profonds) | faibles (arbres très peu profonds) |
| Ce qui est réduit | surtout la variance | surtout le biais |
| Combinaison | vote ou moyenne simple | vote ou somme pondérée |
Le bagging et les forêts aléatoires
Section intitulée « Le bagging et les forêts aléatoires »Le bagging
Section intitulée « Le bagging »Le bagging (bootstrap aggregating) crée de la diversité en variant les données :
- tirer échantillons bootstrap : chacun contient exemples tirés avec remise parmi les exemples d’apprentissage ;
- entraîner un modèle sur chaque échantillon ;
- faire voter les modèles (ou moyenner leurs prédictions en régression).
Avec un tirage avec remise, certains patients apparaissent plusieurs fois dans un échantillon, d’autres jamais. La probabilité qu’un exemple donné ne soit jamais tiré vaut , qui tend vers . Chaque échantillon contient donc environ 63 % des exemples distincts. Les 37 % restants, dits hors sac (out-of-bag), n’ont pas servi à ce modèle : on peut les utiliser pour estimer son erreur, gratuitement, sans jeu de validation séparé.
Pourquoi cela réduit-il la variance ? Si l’on moyenne prédictions de variance chacune, avec une corrélation entre deux prédictions, la variance de la moyenne vaut :
Le second terme disparaît quand grandit, mais pas le premier. Tant que les modèles sont corrélés ( proche de 1), multiplier les arbres ne sert presque à rien. D’où l’idée suivante.
Les forêts aléatoires
Section intitulée « Les forêts aléatoires »Une forêt aléatoire (random forest) est un bagging d’arbres profonds, avec une source de hasard supplémentaire : à chaque nœud, l’arbre ne choisit sa question que parmi un sous-ensemble aléatoire de caractéristiques (typiquement en classification, où est le nombre total de caractéristiques). Si une variable est très forte, elle ne peut plus dominer tous les arbres : ils deviennent décorrélés, et diminue.
Les forêts aléatoires sont robustes, marchent bien sans grand réglage, sont parallélisables et fournissent une estimation hors sac de l’erreur. Leur prix : on perd la lisibilité d’un arbre unique, et le modèle est plus lourd en mémoire.
import pandas as pdfrom sklearn.ensemble import RandomForestClassifier
df = pd.read_csv("heart.csv")X, y = df.drop(columns=["target"]), df["target"]
foret = RandomForestClassifier( n_estimators=500, # nombre d'arbres max_features="sqrt", # caractéristiques candidates à chaque nœud oob_score=True, # estimation hors sac de l'exactitude random_state=0,).fit(X, y)print(f"exactitude hors sac : {foret.oob_score_:.3f}")Le boosting : apprendre de ses erreurs
Section intitulée « Le boosting : apprendre de ses erreurs »AdaBoost
Section intitulée « AdaBoost »Le boosting adopte la logique inverse : des modèles faibles (à peine meilleurs que le hasard), entraînés les uns après les autres, chacun se concentrant sur ce que les précédents ont raté. Le plus souvent, ce sont des souches (stumps), des arbres réduits à une seule question.
L’algorithme AdaBoost, pour deux classes codées et , fonctionne ainsi :
- donner à chaque exemple le même poids ;
- à chaque tour : entraîner un modèle faible sur les données pondérées, et calculer son erreur pondérée (la somme des poids des exemples mal classés) ;
- calculer le poids du modèle : ;
- mettre à jour les poids des exemples : , puis les renormaliser pour qu’ils somment à 1.
La prédiction finale est : un vote pondéré, où les modèles les plus fiables pèsent le plus.
Décortiquons l’étape 4. Si l’exemple est bien classé, et son poids est multiplié par : il pèse moins. S’il est mal classé, et son poids est multiplié par : le modèle suivant devra y faire attention. Quant à , il est d’autant plus grand que l’erreur est petite, et il s’annule quand (un modèle qui répond au hasard ne vote pas).
On montre qu’AdaBoost minimise la perte exponentielle (chapitre 7). Revers de la médaille : il accorde de plus en plus de poids aux exemples difficiles, y compris aux exemples mal étiquetés, ce qui le rend sensible au bruit.
Le gradient boosting, et au-delà
Section intitulée « Le gradient boosting, et au-delà »Le gradient boosting généralise l’idée à n’importe quelle perte dérivable. Au lieu de repondérer les exemples, chaque nouvel arbre est entraîné à prédire ce qui manque au modèle actuel, c’est-à-dire le gradient négatif de la perte ; pour la MSE, ce sont simplement les résidus . Le modèle avance à petits pas :
où est le modèle après arbres et le taux d’apprentissage (learning rate), typiquement entre 0,01 et 0,1. Un petit demande plus d’arbres mais généralise mieux. On arrête l’ajout d’arbres quand l’erreur de validation cesse de baisser (arrêt précoce, early stopping).
Pour aller plus loin que ce cours, trois bibliothèques ont fait du gradient boosting la référence sur données tabulaires : XGBoost (régularisation des feuilles, approximation au second ordre), LightGBM (arbres construits feuille par feuille sur des histogrammes, très rapide sur de gros volumes) et CatBoost (traitement soigné des variables catégorielles). scikit-learn propose une version proche de LightGBM, HistGradientBoostingClassifier.
from sklearn.ensemble import HistGradientBoostingClassifierfrom sklearn.model_selection import cross_val_score
gb = HistGradientBoostingClassifier( learning_rate=0.05, max_iter=500, # nombre maximal d'arbres max_depth=3, # arbres peu profonds early_stopping=True, # arrêt quand la validation interne stagne random_state=0,)print(cross_val_score(gb, X, y, cv=5, scoring="roc_auc").mean())Quelles variables comptent ? L’importance des variables
Section intitulée « Quelles variables comptent ? L’importance des variables »Un ensemble de 500 arbres ne se lit plus. Mais on peut encore demander quelles caractéristiques il utilise le plus.
- L’importance par impureté (MDI, mean decrease in impurity) additionne, pour chaque variable, la baisse d’impureté apportée par toutes les questions qui l’utilisent, dans tous les arbres. Elle est gratuite (
feature_importances_), mais biaisée : elle favorise les variables continues ou à nombreuses valeurs, qui offrent beaucoup de seuils possibles, et elle est calculée sur les données d’apprentissage. - L’importance par permutation mélange au hasard les valeurs d’une variable dans un jeu de validation et mesure la baisse du score. Si le score s’effondre, la variable comptait. Plus coûteuse, elle est plus fiable.
En TP, sur le projet des rendez-vous médicaux manqués (chapitre 10), l’importance par impureté d’une forêt aléatoire plaçait en tête le délai entre la prise du rendez-vous et le rendez-vous (26,32 %), une variable que nous avions créée, suivi de la date de prise du rendez-vous (24,66 %) et de l’âge (17,22 %). Avec le recul, ce classement mérite prudence : la date de prise était un horodatage à la seconde près, qui prend un très grand nombre de valeurs différentes : exactement le type de variable que la MDI surestime. Une importance par permutation aurait permis de le vérifier.
from sklearn.inspection import permutation_importancefrom sklearn.model_selection import train_test_split
X_app, X_val, y_app, y_val = train_test_split(X, y, test_size=0.3, stratify=y, random_state=0)foret = RandomForestClassifier(n_estimators=500, random_state=0).fit(X_app, y_app)
resultat = permutation_importance(foret, X_val, y_val, n_repeats=20, random_state=0)classement = pd.Series(resultat.importances_mean, index=X.columns).sort_values(ascending=False)print(classement.head())En résumé
Section intitulée « En résumé »- Un vote de modèles est meilleur que chacun d’eux si les modèles sont bons et divers (erreurs peu corrélées).
- Le bagging entraîne des modèles instables sur des échantillons bootstrap (environ 63 % d’exemples distincts) et les fait voter ; les exemples hors sac donnent une estimation gratuite de l’erreur.
- La forêt aléatoire tire aussi les caractéristiques candidates à chaque nœud pour décorréler les arbres.
- AdaBoost enchaîne des modèles faibles en repondérant les exemples mal classés ; chaque modèle vote avec un poids qui dépend de son erreur.
- Le gradient boosting ajuste chaque nouvel arbre sur les résidus (le gradient de la perte), avec un taux d’apprentissage et un arrêt précoce ; XGBoost, LightGBM et CatBoost en sont les implémentations de référence.
- L’importance par impureté est biaisée vers les variables à nombreuses valeurs ; l’importance par permutation sur un jeu de validation est plus fiable.
Exercices
Section intitulée « Exercices »Exercice · Le vote de trois modèles
facileTrois classifieurs indépendants ont chacun 70 % de bonnes réponses. Quelle est l’exactitude de leur vote majoritaire ? Et si les trois classifieurs étaient identiques ?
Voir la solution
La majorité a raison si au moins deux des trois ont raison :
Le vote atteint 78,4 %. Si les trois classifieurs étaient identiques, ils se tromperaient toujours ensemble, et le vote aurait exactement 70 % : sans diversité, l’ensemble n’apporte rien.
En code
import numpy as npfrom scipy.stats import binom
# Au moins 2 bonnes réponses sur 3, chaque modèle ayant raison avec probabilité 0,7print(round(binom.sf(1, 3, 0.7), 3)) # 0.784
# Simulation : trois modèles indépendants, puis trois modèles identiquesrng = np.random.default_rng(0)correct = rng.random((100_000, 3)) < 0.7 # une colonne par modèleprint(round((correct.sum(axis=1) >= 2).mean(), 3)) # 0.785identiques = np.repeat(correct[:, :1], 3, axis=1) # trois copies du même modèleprint(round((identiques.sum(axis=1) >= 2).mean(), 3)) # 0.698 : pas mieux qu'un seul modèle# Au moins 2 bonnes réponses sur 3, chaque modèle ayant raison avec probabilité 0,7round(1 - pbinom(1, 3, 0.7), 3) # 0.784
# Simulation : trois modèles indépendants, puis trois modèles identiquesset.seed(0)correct <- matrix(runif(3e5) < 0.7, ncol = 3) # une colonne par modèleround(mean(rowSums(correct) >= 2), 3) # 0.784identiques <- correct[, c(1, 1, 1)] # trois copies du même modèleround(mean(rowSums(identiques) >= 2), 3) # 0.699 : pas mieux qu'un seul modèleExercice · Un tour d'AdaBoost
moyenOn dispose de 8 exemples de poids égaux (). Une souche classe mal 2 d’entre eux. Calculez , , puis les nouveaux poids des exemples bien et mal classés après normalisation.
Voir la solution
L’erreur pondérée vaut . Le poids du modèle vaut .
Les mal classés sont multipliés par , les bien classés par . Avant normalisation :
- chaque mal classé : ;
- chaque bien classé : .
La somme vaut . Après normalisation, chaque mal classé pèse et chaque bien classé . Les deux exemples ratés portent ensemble la moitié du poids total, comme dans l’exemple du cours : c’est une propriété générale d’AdaBoost.
En code
import numpy as np
poids = np.full(8, 1 / 8)mal_classe = np.array([True, True] + [False] * 6) # la souche rate 2 exemples
epsilon = poids[mal_classe].sum()alpha = 0.5 * np.log((1 - epsilon) / epsilon)print(epsilon, round(alpha, 3)) # 0.25 0.549
# y_i h(x_i) vaut -1 si mal classé, +1 sinonpoids = poids * np.exp(-alpha * np.where(mal_classe, -1, 1))print(poids.round(4)[[0, 2]], round(poids.sum(), 3)) # [0.2165 0.0722] 0.866poids = poids / poids.sum()print(poids.round(3)[[0, 2]], round(poids[mal_classe].sum(), 3)) # [0.25 0.083] 0.5poids <- rep(1 / 8, 8)mal_classe <- c(TRUE, TRUE, rep(FALSE, 6)) # la souche rate 2 exemples
epsilon <- sum(poids[mal_classe])alpha <- 0.5 * log((1 - epsilon) / epsilon)c(epsilon, round(alpha, 3)) # 0.25 0.549
# y_i h(x_i) vaut -1 si mal classé, +1 sinonpoids <- poids * exp(-alpha * ifelse(mal_classe, -1, 1))round(c(poids[1], poids[3], sum(poids)), 4) # 0.2165 0.0722 0.866poids <- poids / sum(poids)round(c(poids[1], poids[3], sum(poids[mal_classe])), 3) # 0.25 0.083 0.5Exercice · Plus d'arbres, toujours mieux ?
difficileUn collègue entraîne un bagging d’arbres profonds sur heart.csv. Il constate que passer de 50 à 500 arbres n’améliore presque plus le score, alors que la formule de la variance d’une moyenne semble promettre une amélioration continue. Expliquez ce qu’il observe à l’aide de la formule , et proposez un changement.
Voir la solution
Avec , le second terme vaut déjà , soit au plus 2 % de : il reste très peu à gagner en augmentant . Ce qui limite la variance de l’ensemble, c’est le premier terme, , qui ne dépend pas du nombre d’arbres. Or des arbres entraînés sur des échantillons bootstrap d’un même jeu sont très corrélés, surtout si une ou deux variables dominent : ils posent tous à peu près les mêmes premières questions.
Pour progresser, il faut réduire : passer à une forêt aléatoire, qui ne propose à chaque nœud qu’un sous-ensemble aléatoire de caractéristiques (max_features="sqrt", ou même moins). Les arbres deviennent plus différents, la corrélation baisse, et la moyenne devient plus efficace. Ajouter des arbres ne fait jamais de mal au score d’une forêt, mais au-delà de quelques centaines, cela coûte surtout du temps de calcul.
En code
import numpy as np
sigma2, rho = 1.0, 0.5for B in [1, 50, 500]: print(B, round(rho * sigma2 + (1 - rho) / B * sigma2, 3)) # 1.0, 0.51, 0.501
# Simulation : B prédictions de variance 1, corrélées à rho (une part commune à tous les arbres)rng = np.random.default_rng(0)for B in [50, 500]: commun = rng.normal(size=(5_000, 1)) propre = rng.normal(size=(5_000, B)) predictions = np.sqrt(rho) * commun + np.sqrt(1 - rho) * propre print(B, round(predictions.mean(axis=1).var(), 3)) # 0.508 puis 0.513 : pas de gain
# Réduire rho (forêt aléatoire) fait bien plus baisser la variance que multiplier les arbresprint(round(0.2 * sigma2 + (1 - 0.2) / 50 * sigma2, 3)) # 0.216variance_moyenne <- function(B, rho, sigma2 = 1) rho * sigma2 + (1 - rho) / B * sigma2round(variance_moyenne(c(1, 50, 500), rho = 0.5), 3) # 1, 0.51, 0.501
# Simulation : B prédictions de variance 1, corrélées à rho (une part commune à tous les arbres)set.seed(0)rho <- 0.5for (B in c(50, 500)) { commun <- rnorm(5000) propre <- matrix(rnorm(5000 * B), ncol = B) predictions <- sqrt(rho) * commun + sqrt(1 - rho) * propre cat(B, round(var(rowMeans(predictions)), 3), "\n") # 0.505 puis 0.502 : pas de gain}
# Réduire rho (forêt aléatoire) fait bien plus baisser la variance que multiplier les arbresvariance_moyenne(50, rho = 0.2) # 0.216