La force du nombre : forêts aléatoires et boosting
Dr. El Hadji Bassirou TOURÉ · DMI · FST · UCAD
La Séance 7 a réglé un arbre au mieux : AUC \(0{,}73\) sur le paludisme, puis un plafond. Ce TP construit à la main les idées qui dépassent l’arbre unique : le vote de la foule, le bootstrap (et son \(63\,\%\)), l’out-of-bag, la forêt aléatoire et son importance des variables, puis le gradient boosting déroulé résidu par résidu — avant de confronter le tout à scikit-learn et d’aboutir à une leçon contre-intuitive : sur ces données, le modèle le plus puissant n’est pas le meilleur.
Durée estimée : 1h30. Exécuter les cellules dans l’ordre, de haut en bas.
Partie 0 — Mise en place
import numpy as npimport pandas as pdimport matplotlib.pyplot as pltnp.set_printoptions(precision=4, suppress=True)pd.set_option("display.precision", 4)print("Outils prêts. NumPy", np.__version__, "· pandas", pd.__version__)
# Le découpage de référence du cours (Séances 5 à 7) et la CV stratifiéefrom sklearn.model_selection import train_test_split, StratifiedKFoldX = df[colonnes]y = df["paludisme"]X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)cv5 = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)print("train :", len(y_train), "· test :", len(y_test), "·", int(y_test.sum()), "positifs au test")
train : 8000 · test : 2000 · 235 positifs au test
Partie 1 — La sagesse de la foule, en simulation
Des jurés qui se trompent une fois sur trois, mais dont les erreurs sont indépendantes : la majorité se trompe-t-elle moins ? Vérifions par simulation.
# Des jurés à 65 % de réussite, erreurs indépendantes, vote majoritairerng = np.random.default_rng(8)def proba_majorite(M, p=0.65, essais=20000): votes = (rng.uniform(0, 1, (essais, M)) < p).sum(axis=1)return (votes > M/2).mean()for M in [1, 3, 11, 51]:print(f"{M:2d} jurés : la majorité a raison {proba_majorite(M):.3f} du temps")
1 jurés : la majorité a raison 0.647 du temps
3 jurés : la majorité a raison 0.718 du temps
11 jurés : la majorité a raison 0.858 du temps
51 jurés : la majorité a raison 0.987 du temps
Lecture. Un seul juré : \(0{,}65\). Onze jurés : \(\approx 0{,}85\). Cinquante et un : \(\approx 0{,}99\). Des votants médiocres mais indépendants forment un collège excellent — c’est tout le pari des ensembles. Le mot clé est indépendants : la Partie 2 va fabriquer cette indépendance entre arbres.
Exercice. Modifier proba_majorite pour des jurés à \(p = 0{,}55\) (à peine mieux que le hasard) et afficher le résultat pour \(M = 1, 11, 101\). La foule sauve-t-elle encore des votants aussi faibles ?
# Jurés à 55 % : proba de la majorité pour M = 1, 11, 101
La condition d’indépendance, en chiffres
Le théorème de Condorcet suppose les erreurs indépendantes. Que se passe-t-il si les votants se ressemblent ? Simulons des jurés dont les votes sont corrélés.
# Des jurés corrélés : un "climat d'opinion" commun + un avis propredef proba_majorite_correlee(M, p=0.65, rho=0.0, essais=20000):# rho = poids du signal commun (0 = independants, 1 = tous identiques) climat = rng.uniform(0, 1, (essais, 1)) < p # avis commun a tous propre = rng.uniform(0, 1, (essais, M)) < p # avis individuel tirage_commun = rng.uniform(0, 1, (essais, M)) < rho # qui suit le climat ? votes_corrects = np.where(tirage_commun, climat, propre).sum(axis=1)return (votes_corrects > M/2).mean()for rho in [0.0, 0.3, 0.9]: p51 = proba_majorite_correlee(51, rho=rho)print(f"51 jures a 65%, correlation {rho} : majorite correcte {p51:.3f}")
51 jures a 65%, correlation 0.0 : majorite correcte 0.986
51 jures a 65%, correlation 0.3 : majorite correcte 0.734
51 jures a 65%, correlation 0.9 : majorite correcte 0.647
Lecture. Indépendants (\(\rho = 0\)) : \(\approx 0{,}99\). Fortement corrélés (\(\rho = 0{,}9\)) : à peine mieux que \(0{,}65\) — les \(51\) jurés ne valent presque pas mieux qu’un seul. La leçon est sans appel et prépare toute la suite : ce n’est pas le nombre de votants qui compte, mais leur indépendance. Toute la mécanique des forêts vise à fabriquer cette indépendance entre arbres.
Partie 2 — Le bootstrap : varier les données
Un échantillon bootstrap tire \(n\) patients avec remise parmi \(n\) : certains en double, d’autres absents (les out-of-bag).
# Un tirage bootstrap sur six patients, à la mainpatients = np.array([1, 2, 3, 4, 5, 6])tirage = rng.integers(0, 6, 6) # 6 indices avec remiseechantillon = patients[tirage]vus = np.unique(echantillon)oob = np.setdiff1d(patients, vus)print("échantillon bootstrap :", sorted(echantillon))print("patients vus :", vus, "· out-of-bag :", oob)
# La fraction de points uniques converge vers 1 - 1/efor n in [6, 100, 10000]: fractions = [len(np.unique(rng.integers(0, n, n))) / n for _ inrange(2000)]print(f"n = {n:5d} : fraction unique = {np.mean(fractions):.4f}")print("\nlimite théorique 1 - 1/e =", round(1-1/np.e, 4))
Lecture. Quel que soit \(n\) (pourvu qu’il soit grand), un tirage bootstrap retient environ \(\mathbf{63\,\%}\) des patients ; les \(\approx 37\,\%\) restants sont out-of-bag. La raison : la probabilité qu’un patient échappe aux \(n\) tirages est \(\big(1 - \tfrac1n\big)^n \to 1/e \approx 0{,}368\).
# Vérification directe de la formule (1 - 1/n)^nfor n in [6, 100, 10000]:print(f"n = {n:5d} : (1 - 1/n)^n = {(1-1/n)**n:.4f} -> vus = {1-(1-1/n)**n:.4f}")
Le bagging entraîne un arbre par échantillon bootstrap, puis agrège. Comme chaque patient est OOB pour environ un tiers des arbres, on obtient une validation gratuite.
Un bagging fait main (pour comprendre l’agrégation)
Avant la version sklearn, construisons un mini-bagging nous-mêmes : tirer des échantillons bootstrap, entraîner un arbre sur chacun, puis moyenner leurs probabilités. On vérifie au passage que l’ensemble bat un arbre seul.
# Bagging maison : 30 arbres profonds sur des echantillons bootstrapfrom sklearn.tree import DecisionTreeClassifierfrom sklearn.metrics import roc_auc_scoren =len(X_train)probas_test = []for b inrange(30): idx = rng.integers(0, n, n) # echantillon bootstrap arbre = DecisionTreeClassifier(random_state=b) arbre.fit(X_train.iloc[idx], y_train.iloc[idx]) probas_test.append(arbre.predict_proba(X_test)[:, 1])# un arbre seul vs la moyenne des 30auc_un = roc_auc_score(y_test, probas_test[0])auc_moyenne = roc_auc_score(y_test, np.mean(probas_test, axis=0))print(f"un arbre profond seul : AUC {auc_un:.4f}")print(f"moyenne de 30 arbres : AUC {auc_moyenne:.4f}")
un arbre profond seul : AUC 0.5519
moyenne de 30 arbres : AUC 0.6431
Lecture. Un arbre profond seul a une AUC médiocre et instable (il a tout mémorisé) ; la moyenne de trente arbres baggés la relève nettement. C’est le bagging dans sa forme la plus nue : bootstrap → entraîner → moyenner. La version de scikit-learn ajoute à cela le score out-of-bag.
# Bagging de 100 arbres avec score out-of-bagfrom sklearn.ensemble import BaggingClassifierfrom sklearn.tree import DecisionTreeClassifierfrom sklearn.metrics import roc_auc_scorebag = BaggingClassifier(DecisionTreeClassifier(random_state=0), n_estimators=100, oob_score=True, random_state=0, n_jobs=-1)bag.fit(X_train, y_train)print("score OOB (accuracy, gratuit) :", round(bag.oob_score_, 4))print("AUC test du bagging :", round(roc_auc_score(y_test, bag.predict_proba(X_test)[:, 1]), 4))
score OOB (accuracy, gratuit) : 0.8688
AUC test du bagging : 0.6601
Lecture. Le score OOB (\(0{,}869\)) est calculé sans toucher au test : chaque patient a été prédit par les arbres qui ne l’avaient pas vu. C’est le rôle de la validation croisée de la Séance 7, mais intégré au bagging — pratique pour un premier diagnostic.
Partie 4 — La forêt aléatoire
La forêt ajoute un second hasard : à chaque coupure, seules max_features variables tirées au sort sont candidates. Les arbres se décorrèlent, et la moyenne s’améliore.
# Une forêt de 300 arbresfrom sklearn.ensemble import RandomForestClassifierfrom sklearn.model_selection import cross_val_scoreforet = RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1)foret.fit(X_train, y_train)auc_cv = cross_val_score(foret, X_train, y_train, cv=cv5, scoring="roc_auc").mean()print("AUC CV de la forêt :", round(auc_cv, 4))print("AUC test de la forêt :", round(roc_auc_score(y_test, foret.predict_proba(X_test)[:, 1]), 4))
AUC CV de la forêt : 0.657
AUC test de la forêt : 0.6807
# Le plateau : ajouter des arbres ne sur-apprend jamaisfor ne in [1, 5, 20, 50, 100, 300, 500]: m = RandomForestClassifier(n_estimators=ne, random_state=0, n_jobs=-1) s = cross_val_score(m, X_train, y_train, cv=cv5, scoring="roc_auc").mean()print(f"{ne:3d} arbres : AUC CV = {s:.4f}")
Lecture. L’AUC monte (\(0{,}53 \to 0{,}65\)) puis plafonne vers \(0{,}66\) : ajouter des arbres à une forêt ne fait jamais sur-apprendre. n_estimators n’est donc pas à régler finement — on en met « assez » (quelques centaines), le surcoût est en temps de calcul.
Vérifier la formule de variance d’une moyenne corrélée
Le deck affirme que la variance de la moyenne de \(B\) modèles corrélés tend vers \(\rho\sigma^2\), et non vers \(0\). Vérifions-le par simulation — c’est l’argument mathématique qui justifie pourquoi il faut décorréler les arbres.
# B variables de variance 1, correlees deux a deux a rho, via un facteur commundef variance_moyenne(B, rho, n=40000): commun = rng.normal(0, 1, (n, 1)) # partage par tous propre = rng.normal(0, 1, (n, B)) # propre a chacun arbres = np.sqrt(rho)*commun + np.sqrt(1-rho)*propre # correlation rho, variance 1 moyenne = arbres.mean(axis=1)return moyenne.var()print("rho B=1 B=10 B=100 theorie B->inf (= rho)")for rho in [0.0, 0.3, 0.9]: v1 = variance_moyenne(1, rho); v10 = variance_moyenne(10, rho); v100 = variance_moyenne(100, rho)print(f"{rho}{v1:.3f}{v10:.3f}{v100:.3f}{rho:.3f}")
Lecture. À \(\rho = 0\) (arbres indépendants), la variance tombe comme \(1/B\) : \(1 \to 0{,}1 \to 0{,}01\). À \(\rho = 0{,}9\) (arbres semblables, le défaut du bagging sur une variable dominante), elle plafonne à \(0{,}90\) : cent arbres ne valent presque pas mieux qu’un seul. La forêt existe précisément pour faire baisser ce \(\rho\) — c’est la traduction chiffrée de tout ce qui précède.
Exercice. Comparer une forêt standard à des extra-trees (ExtraTreesClassifier, qui tirent aussi les seuils au hasard, donc décorrèlent davantage) sur le paludisme, en AUC de validation croisée. L’import est from sklearn.ensemble import ExtraTreesClassifier.
# Forêt vs Extra-Trees (300 arbres chacun), AUC CV
# L'importance des variables — à lire avec prudenceimportances = pd.Series(foret.feature_importances_, index=colonnes).sort_values(ascending=False)print(importances.round(4))
glycemie 0.2555
hemoglobine 0.2526
age 0.2451
fievre 0.1925
saison 0.0542
dtype: float64
Lecture (le piège). La forêt désigne glycémie, hémoglobine et âge comme les plus « importantes » — alors que, par construction des données, le paludisme ne dépend que de la saison et de la fièvre. Ces variables continues offrent une multitude de seuils, et les arbres s’en servent pour fragmenter le bruit : l’importance par impureté favorise les variables à forte cardinalité, même non causales. L’importance suggère, elle ne prouve pas.
Exercice. Entraîner deux forêts de \(300\) arbres, l’une avec max_features=1, l’autre avec max_features=None (toutes les variables à chaque coupure, donc des arbres plus corrélés). Comparer leurs AUC en validation croisée. Laquelle décorrèle le mieux ?
# Deux forêts : max_features=1 contre max_features=None, AUC CV
Partie 5 — Le gradient boosting, à la main
Le boosting enchaîne des arbres : chacun rattrape les résidus (l’erreur) de la somme des précédents. Déroulons-le sur un jouet de régression.
# Jouet : 4 points. On boost avec des souches (arbres de profondeur 1).from sklearn.tree import DecisionTreeRegressorxb = np.array([1., 2., 3., 4.]).reshape(-1, 1)yb = np.array([2., 3., 5., 9.])F0 = np.full(4, yb.mean()) # prédiction initiale = la moyenneprint("F0 (moyenne) :", F0)r1 = yb - F0print("résidus r1 = y - F0 :", r1.round(3))print("MSE de F0 :", round(np.mean((yb - F0)**2), 3))
# Souche 2 ajustée sur r2, puis F2 = F1 + h2souche2 = DecisionTreeRegressor(max_depth=1).fit(xb, r2)F2 = F1 + souche2.predict(xb)print("F2 = F1 + h2 :", F2.round(3))print("MSE : F0", round(np.mean((yb-F0)**2), 3),"-> F1", round(np.mean((yb-F1)**2), 3),"-> F2", round(np.mean((yb-F2)**2), 3))
F2 = F1 + h2 : [2.5 2.5 4.167 9.833]
MSE : F0 7.188 -> F1 1.167 -> F2 0.472
Lecture. La MSE chute \(7{,}19 \to 1{,}17 \to 0{,}47\) : chaque arbre rattrape ce que le précédent a laissé. La prédiction finale est la somme des arbres (\(F_0 + h_1 +
h_2 + \dots\)), pas leur moyenne — c’est là toute la différence avec la forêt. « Gradient » vient de ce que les résidus sont, pour la MSE, l’opposé du gradient de la perte : le boosting est la descente de gradient de la Séance 5, appliquée fonction par fonction.
Exercice. Sur trois points, \(F_1 = (3, 5, 8)\) et \(y = (4, 4, 10)\). Calculer en NumPy les résidus \(r_2 = y - F_1\), puis \(F_2 = F_1 + 0{,}5 \cdot (0{,}5, 0{,}5, 2)\), et vérifier que la somme des carrés des résidus a diminué (elle passe de \(6\) à \(3{,}125\)).
# Résidus r2, mise à jour F2 avec learning_rate 0,5, et somme des carrés avant/après
Partie 6 — Le gradient boosting en pratique
Sur le paludisme, et le couple décisif learning_rate × n_estimators.
# Gradient boosting sur le paludisme (défauts : 100 arbres, lr=0.1, depth=3)from sklearn.ensemble import GradientBoostingClassifiergb = GradientBoostingClassifier(random_state=0)auc_cv = cross_val_score(gb, X_train, y_train, cv=cv5, scoring="roc_auc").mean()gb.fit(X_train, y_train)print("AUC CV du gradient boosting :", round(auc_cv, 4))print("AUC test :", round(roc_auc_score(y_test, gb.predict_proba(X_test)[:, 1]), 4))
AUC CV du gradient boosting : 0.6892
AUC test : 0.7238
# Le couple learning_rate x n_estimators (AUC CV)for lr in [0.01, 0.1, 0.5, 1.0]: ligne = []for ne in [50, 200]: m = GradientBoostingClassifier(learning_rate=lr, n_estimators=ne, random_state=0) s = cross_val_score(m, X_train, y_train, cv=cv5, scoring="roc_auc").mean() ligne.append(f"n={ne}: {s:.4f}")print(f"learning_rate={lr:4} -> "+" | ".join(ligne))
Lecture. Le meilleur réglage est learning_rate faible et peu d’arbres (\(0{,}01\), \(50\) arbres : \(0{,}698\)) ; un grand pas avec beaucoup d’arbres dégrade (\(1{,}0\), \(200\) : \(0{,}610\)). À l’opposé de la forêt : ici, ajouter des arbres à grand pas fait sur-apprendre. C’est pourquoi ce couple se règle par validation croisée (Séance 7).
Partie 7 — Le grand comparatif : l’arc se referme
Cinq modèles sur le paludisme, jugés à l’AUC en validation croisée et sur le test.
Lecture. Surprise : les ensembles ne dominent pas. L’arbre réglé (\(0{,}73\) test) et la logistique (\(0{,}71\)) font aussi bien, voire mieux que la forêt (\(0{,}68\)) et le boosting (\(0{,}72\)). La raison : le risque de paludisme dépend ici de deux variables de façon simple (saison, fièvre) — un petit modèle suffit, et la puissance des forêts se retourne contre elles en cherchant des interactions dans le bruit des variables continues.
La morale n’est pas « les ensembles sont mauvais » (ils dominent sur quantité de problèmes réels), mais qu’aucun modèle n’est supérieur dans l’absolu : la complexité doit être justifiée par les données. À performance égale, on choisit le plus simple.
Mini-défi — Le plateau de la forêt contre la dérive du boosting
Une expérience qui résume la séance : pousser n_estimators pour les deux familles, et regarder l’écart entre AUC d’entraînement et AUC de test.
# Forêt : train et test selon n_estimatorsprint("FORÊT (plus d'arbres = plateau, jamais de dérive)")for ne in [5, 50, 500]: m = RandomForestClassifier(n_estimators=ne, random_state=0, n_jobs=-1).fit(X_train, y_train) tr = roc_auc_score(y_train, m.predict_proba(X_train)[:, 1]) te = roc_auc_score(y_test, m.predict_proba(X_test)[:, 1])print(f" {ne:3d} arbres : train {tr:.4f} / test {te:.4f} (écart {tr-te:.3f})")# Boosting à grand pas : train et test selon n_estimatorsprint("\nBOOSTING (learning_rate=0.5 : plus d'arbres = sur-apprentissage)")for ne in [50, 200, 1000]: m = GradientBoostingClassifier(learning_rate=0.5, n_estimators=ne, random_state=0).fit(X_train, y_train) tr = roc_auc_score(y_train, m.predict_proba(X_train)[:, 1]) te = roc_auc_score(y_test, m.predict_proba(X_test)[:, 1])print(f" {ne:4d} arbres : train {tr:.4f} / test {te:.4f} (écart {tr-te:.3f})")
FORÊT (plus d'arbres = plateau, jamais de dérive)
5 arbres : train 0.9889 / test 0.6203 (écart 0.369)
50 arbres : train 1.0000 / test 0.6804 (écart 0.320)
500 arbres : train 1.0000 / test 0.6815 (écart 0.319)
BOOSTING (learning_rate=0.5 : plus d'arbres = sur-apprentissage)
50 arbres : train 0.8109 / test 0.7059 (écart 0.105)
200 arbres : train 0.9102 / test 0.6713 (écart 0.239)
1000 arbres : train 0.9938 / test 0.6381 (écart 0.356)
Lecture. La forêt sature (train \(1{,}0\), test stable autour de \(0{,}68\) — l’écart ne se creuse plus). Le boosting à grand pas dérive : son train monte vers \(0{,}99\) pendant que son test de \(0{,}71\) à \(0{,}64\) — l’écart explose, signe du sur-apprentissage. Deux mécaniques opposées, une même conclusion : la forêt pardonne l’excès d’arbres, le boosting non.
La Séance 9 quittera le terrain des étiquettes : comment regrouper des patients sans cible (clustering), et résumer des données à beaucoup de variables (réduction de dimension).
Synthèse — à compléter
Agréger des modèles ………. bat chacun d’eux — à condition que leurs erreurs diffèrent (la sagesse de la foule).
Un échantillon ………. tire \(n\) points avec remise et en retient environ ………. % ; les autres sont ………. et offrent une validation gratuite.
La forêt aléatoire ajoute au bagging un tirage de ………. à chaque coupure, pour ………. les arbres ; ajouter des arbres fait ………., jamais sur-apprendre.
Le boosting enchaîne des arbres ………. qui rattrapent les ………. des précédents ; il réduit le ………. et ………. sur-apprendre si le couple learning_rate/n_estimators est mal réglé.
L’importance des variables ………., elle ne ………. pas — et favorise les variables ………..
Leçon centrale : le modèle le plus ………. n’est pas toujours le meilleur ; à performance égale, choisir le plus ………..