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.

L’apprentissage par ensembles combine plusieurs apprenants faibles en un apprenant fort, plus robuste et qui généralise mieux. Un exemple familier est la forêt aléatoire, bien plus forte que les arbres de décision individuels qui la composent.

Principaux avantages de l’apprentissage par ensembles :

  • Moins de surapprentissage : les méthodes de bagging (agrégation de modèles sur échantillons bootstrap) comme les forêts aléatoires réduisent le risque de surapprentissage en combinant les prédictions de plusieurs modèles.
  • Meilleure généralisation : les modèles combinés captent plus efficacement les relations complexes et généralisent mieux sur des données variées.
  • Robustesse au bruit et aux valeurs aberrantes : agréger les prédictions de nombreux modèles dilue l’influence des points de données isolés, bruités ou aberrants.
  • Plus grande stabilité : un modèle isolé peut bien se comporter sur certains sous-ensembles des données et mal sur d’autres ; combiner des modèles divers donne des prédictions plus stables d’un sous-groupe de données à l’autre.

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

Les données : quatre classes d’événements sismiques

Nous utilisons le jeu de données d’événements sismiques sélectionné et vérifié de l’enregistrement Zenodo 14025693 : 1 000 séismes, 1 000 explosions, 1 000 événements de surface et 1 000 fenêtres de bruit enregistrés dans le Nord-Ouest Pacifique. Chaque événement est décrit par des caractéristiques physiques de la forme d’onde (durées, rapports d’énergie, kurtosis, statistiques spectrales, etc.). Les classes sont équilibrées, et les caractéristiques s’étalent sur des échelles extrêmement différentes.

import numpy as np
import pandas as pd
import pooch
SEISMIC_FILES = {
    "1000_earthquakes_physical_features.csv": "md5:28129c8dd1b3e14f655d489577b841b5",
    "1000_explosion_physical_features.csv": "md5:af1342d32e163e961e043364136359b0",
    "1000_noise_physical_features.csv": "md5:16cdb992fed6cf6273d5624f5df905da",
    "1000_surface_physical_features.csv": "md5:9a2c2643030cf058704d68e130654e9d",
}

frames = []
for fname, checksum in SEISMIC_FILES.items():
    path = pooch.retrieve(
        url=f"https://zenodo.org/api/records/14025693/files/{fname}/content",
        known_hash=checksum,
        fname=fname,
        path=pooch.os_cache("mlgeo"),
    )
    frames.append(pd.read_csv(path, index_col=0))
seismic = pd.concat(frames, ignore_index=True)
seismic = seismic.dropna(axis=1)  # drops the one feature column with missing values
X = seismic.drop(columns=["source", "serial_no"])
y = seismic["source"]
print(X.shape)
print(y.value_counts())
(4000, 61)
source
earthquake       1000
explosion        1000
noise            1000
surface event    1000
Name: count, dtype: int64

Nous utilisons le même découpage canonique que la leçon 3.5, afin que les exactitudes soient directement comparables d’une leçon à l’autre : 25 % des lignes sont tenues à l’écart pour le test final, avec stratification par classe. Les modèles à base d’arbres utilisent les caractéristiques brutes ; les modèles sensibles à l’échelle (SVC, K plus proches voisins, bayésien naïf) reçoivent un StandardScaler dans leur pipeline scikit-learn (l’objet Pipeline, construit ici par make_pipeline), ajusté sur les seuls plis d’entraînement.

Le modèle de référence trivial : avec quatre classes équilibrées, prédire toujours la classe majoritaire donne 25 % d’exactitude. Chaque chiffre ci-dessous doit se lire par rapport à ce plancher.

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=2026, stratify=y)

baseline = y_train.value_counts(normalize=True).max()
print(f"Train: {X_train.shape[0]} rows, test: {X_test.shape[0]} rows")
print(f"Majority-class baseline accuracy: {baseline:.2f}")
Train: 3000 rows, test: 1000 rows
Majority-class baseline accuracy: 0.25

1. Classifieur par vote

Agréger les prédictions de chaque classifieur et prédire la classe qui recueille le plus de voix.

Voting Classifier D’après « Hands on Machine Learning With Sci-kit Learn, Keras, and Tensorflow » (Géron).

Nous construisons trois modèles de base différents : un bayésien naïf gaussien, une forêt aléatoire et un classifieur à vecteurs de support. Les modèles bayésien naïf et SVC sont enveloppés dans un pipeline avec un StandardScaler, de sorte que la mise à l’échelle (scaler) est réajustée sur chaque pli d’entraînement pendant la validation croisée et ne voit jamais les lignes de validation. La forêt aléatoire travaille sur les caractéristiques brutes.

from sklearn.ensemble import RandomForestClassifier, VotingClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import cross_val_score
from sklearn.naive_bayes import GaussianNB
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC

nb_clf = make_pipeline(StandardScaler(), GaussianNB())
rf_clf = RandomForestClassifier(random_state=42)
svc_clf = make_pipeline(StandardScaler(), SVC(random_state=42))

voting_clf = VotingClassifier(
    estimators=[('nb', nb_clf), ('rf', rf_clf), ('svc', svc_clf)],
    voting='hard')

Comparons l’ensemble à ses membres par validation croisée à 5 plis — sur le seul ensemble d’entraînement.

for name, clf in [('Naive Bayes', nb_clf), ('Random Forest', rf_clf),
                  ('SVC', svc_clf), ('Voting ensemble', voting_clf)]:
    scores = cross_val_score(clf, X_train, y_train, scoring='accuracy', cv=5, n_jobs=-1)
    print(f"{name:16s} CV accuracy: {scores.mean():.3f} +/- {scores.std():.3f}")
Naive Bayes      CV accuracy: 0.784 +/- 0.010
Random Forest    CV accuracy: 0.877 +/- 0.014
SVC              CV accuracy: 0.880 +/- 0.012
Voting ensemble  CV accuracy: 0.875 +/- 0.011

Ajustons maintenant l’ensemble par vote sur l’ensemble d’entraînement complet et rapportons son exactitude de test — une seule fois.

voting_clf.fit(X_train, y_train)
voting_test = accuracy_score(y_test, voting_clf.predict(X_test))
print(f"Voting classifier test accuracy: {voting_test:.3f}")
Voting classifier test accuracy: 0.874

Notez que prendre la moyenne de la probabilité prédite, au lieu de son maximum, est aussi une possibilité. On ne peut alors évaluer que les classifieurs qui produisent des probabilités, ce que le SVM ne fait pas par défaut. Mettez voting à soft pour comparer avec la probabilité moyenne.

2. Bagging et pasting

Cette approche utilise le même algorithme de modèle mais rééchantillonne l’ensemble d’entraînement. Pour un rééchantillonnage avec remise (bootstrap), on parle de bagging ; pour un rééchantillonnage sans remise, de pasting.

Plusieurs modèles sont entraînés sur des données différentes, puis les prédictions sont agrégées (mode statistique pour la classification et moyenne pour la régression). Le modèle agrégé tend à avoir une variance plus faible que les modèles individuels, comme ce qu’obtiendrait un modèle unique entraîné sur davantage de données.

Ci-dessous, nous appliquons le bagging à 50 classifieurs des K plus proches voisins, chacun entraîné sur une moitié aléatoire des lignes d’entraînement. L’agrégateur se place à l’intérieur d’un pipeline de mise à l’échelle car le kNN est sensible à l’échelle. Modifiez n_estimators, max_samples ou bootstrap et observez le score de validation croisée.

from sklearn.ensemble import BaggingClassifier
from sklearn.neighbors import KNeighborsClassifier

bag_clf = make_pipeline(
    StandardScaler(),
    BaggingClassifier(
        estimator=KNeighborsClassifier(),
        n_estimators=50,     # number of models to train
        max_samples=0.5,     # each model sees half of the training rows
        bootstrap=True,      # True: bagging; False: pasting
        n_jobs=-1,           # use all available CPU cores
    ))
scores = cross_val_score(bag_clf, X_train, y_train, cv=5)
print(f"Bagged kNN CV accuracy: {scores.mean():.3f}")
Bagged kNN CV accuracy: 0.839

Évaluation hors sac (out-of-bag)

L’échantillonnage avec remise a un effet de bord utile. Quand on tire mm échantillons avec remise parmi mm lignes d’entraînement, chaque bootstrap ne contient qu’environ 63 % des lignes distinctes ; les ~37 % restants n’entrent jamais dans l’ensemble d’entraînement de ce modèle. Ces lignes laissées de côté forment un ensemble de validation gratuit pour ce modèle. Mettez oob_score=True et le BaggingClassifier note chaque ligne d’entraînement en n’utilisant que les modèles qui ne l’ont jamais vue.

from sklearn.tree import DecisionTreeClassifier

oob_clf = BaggingClassifier(
    estimator=DecisionTreeClassifier(),
    n_estimators=200,
    oob_score=True,
    random_state=42,
)
oob_clf.fit(X_train, y_train)
oob_test = accuracy_score(y_test, oob_clf.predict(X_test))
print(f"Out-of-bag score: {oob_clf.oob_score_:.3f}")
print(f"Test accuracy:    {oob_test:.3f}")
Out-of-bag score: 0.875
Test accuracy:    0.889

Les deux nombres sont proches, et ils doivent l’être : le score hors sac est une estimation honnête, parce que chaque prédiction provient de modèles qui ne se sont jamais entraînés sur cette ligne — la propriété même d’un ensemble de test tenu à l’écart.

La dispersion de l’ensemble comme incertitude

Les 200 arbres agrégés donnent plus qu’une étiquette de classe. Chaque arbre émet un vote, et la répartition des votes est elle-même une mesure : quand 195 arbres sur 200 sont d’accord, l’ensemble est confiant ; quand les votes se dispersent sur trois classes, l’ensemble vous dit qu’il ne sait pas. L’agrégateur ajusté conserve ses arbres dans estimators_, ce qui nous permet de compter les votes nous-mêmes et d’attacher une confiance à chaque prédiction de test, sans aucun réentraînement.

import matplotlib.pyplot as plt

# per-tree predictions on the test set: one row per tree
tree_votes = np.stack([tree.predict(X_test.to_numpy())
                       for tree in oob_clf.estimators_])

# each tree predicts a class index; count the votes for each class
n_classes = len(oob_clf.classes_)
vote_frac = np.stack([(tree_votes == k).mean(axis=0)
                      for k in range(n_classes)], axis=1)
agreement = vote_frac.max(axis=1)  # fraction voting the winning class
errors = oob_clf.predict(X_test) != y_test.to_numpy()

edges = [0.25, 0.5, 0.7, 0.9, 1.001]
bin_labels = ['25-50%', '50-70%', '70-90%', '90-100%']
bin_idx = np.digitize(agreement, edges) - 1
error_rate = [errors[bin_idx == b].mean() for b in range(4)]
counts = [int((bin_idx == b).sum()) for b in range(4)]

fig, ax = plt.subplots(figsize=(6, 4))
ax.bar(bin_labels, error_rate)
for i, (e, n) in enumerate(zip(error_rate, counts)):
    ax.text(i, e + 0.005, f"n={n}", ha='center')
ax.set_xlabel('Vote agreement (fraction of trees voting the winning class)')
ax.set_ylabel('Misclassification rate on the test set')
ax.grid(alpha=0.3, axis='y')
plt.show()
<Figure size 600x400 with 1 Axes>

Les arbres qui s’accordent ont généralement raison : au-dessus de 90 % d’accord, l’ensemble se trompe environ 3 % du temps, tandis que les échantillons qui divisent la forêt en deçà de 50 % d’accord sont erronés environ 40 % du temps. L’ensemble classe ses propres prédictions par fiabilité avant que la moindre étiquette ne soit révélée ; dans un catalogue opérationnel, les événements à faible accord sont ceux qu’il faut orienter vers un analyste.

La même recette — entraîner de nombreux modèles, lire leur désaccord comme une incertitude — revient à la leçon 4.5 sous la forme d’ensembles profonds de réseaux de neurones.

3. Boosting

L’idée des méthodes de boosting (renforcement séquentiel) est d’entraîner les prédicteurs séquentiellement, chacun cherchant à corriger son prédécesseur.

3.1 AdaBoost

L’algorithme AdaBoost entraîne un nouveau prédicteur en prêtant davantage attention (en les surpondérant) aux mauvaises prédictions du prédicteur précédent. Par exemple, en classification, un premier prédicteur sous-apprend les données et classe mal les étiquettes. Le deuxième prédicteur pondère plus fortement les données mal classées. Le paramètre learning_rate fixe l’ampleur de cette repondération.

AdaBoost fonctionne avec tout classifieur qui produit des probabilités de classe (p. ex. arbres de décision, kNN ; cherchez les classifieurs dotés d’une méthode predict_proba()). Ici, les apprenants faibles sont des arbres peu profonds (profondeur 2), ce qui est le choix standard.

from sklearn.ensemble import AdaBoostClassifier

ada_clf = AdaBoostClassifier(
    estimator=DecisionTreeClassifier(max_depth=2),
    n_estimators=200,
    learning_rate=0.5,
    random_state=42,
)
ada_clf.fit(X_train, y_train)
ada_test = accuracy_score(y_test, ada_clf.predict(X_test))
print(f"AdaBoost test accuracy: {ada_test:.3f}")
AdaBoost test accuracy: 0.874

3.2 Gradient boosting

Le gradient boosting (renforcement par gradient) construit lui aussi les arbres séquentiellement, mais chaque nouvel arbre ajuste les résidus de l’ensemble courant :

  • Un premier petit arbre est entraîné sur les données (un apprenant faible).
  • Un deuxième petit arbre est entraîné sur les résidus entre les données et les prédictions du premier arbre. Les résidus diminuent.
  • Un troisième petit arbre ajuste les résidus restants, et ainsi de suite.
  • La prédiction finale est la somme des prédictions de tous les arbres.

n_estimators limite le nombre total d’arbres ; trop d’arbres conduisent au surapprentissage. Le learning_rate module la contribution de chaque arbre ; un taux plus faible demande plus d’arbres.

Le gradient boosting a gagné la partie sur les données tabulaires. Les implémentations à base d’histogrammes — LightGBM, XGBoost et le HistGradientBoostingClassifier de scikit-learn — discrétisent les caractéristiques en intervalles pour faire croître les arbres rapidement, et en 2026 elles sont le choix par défaut pour des tableaux de caractéristiques comme celui-ci. Les forêts aléatoires restent un modèle de référence solide, robuste et quasiment sans réglage. Les arbres se scindent selon des seuils sur les caractéristiques, donc aucun de ces modèles n’a besoin d’entrées mises à l’échelle.

D’abord, le booster à histogrammes intégré à scikit-learn :

from sklearn.ensemble import HistGradientBoostingClassifier

hgb_clf = HistGradientBoostingClassifier(random_state=42)
hgb_clf.fit(X_train, y_train)
hgb_test = accuracy_score(y_test, hgb_clf.predict(X_test))
print(f"HistGradientBoosting test accuracy: {hgb_test:.3f}")
HistGradientBoosting test accuracy: 0.898

Puis LightGBM, une bibliothèque autonome très répandue offrant la même interface que scikit-learn :

from lightgbm import LGBMClassifier

lgbm_clf = LGBMClassifier(random_state=42, verbose=-1)
lgbm_clf.fit(X_train, y_train)
lgbm_test = accuracy_score(y_test, lgbm_clf.predict(X_test))
print(f"LightGBM test accuracy: {lgbm_test:.3f}")
LightGBM test accuracy: 0.892

4. Empilement (stacking)

Le vote traite tous les modèles de base sur un pied d’égalité. L’empilement entraîne au contraire un méta-apprenant sur les prédictions des modèles de base : il apprend à quel modèle faire confiance, et où. Les modèles de base produisent des prédictions ; le méta-apprenant (souvent une simple régression logistique) prend ces prédictions comme caractéristiques d’entrée et les ajuste aux étiquettes vraies.

Un piège de fuite de données guette ici : si le méta-apprenant était entraîné sur des prédictions de modèles de base portant sur des lignes que ces modèles avaient déjà vues, il apprendrait à faire confiance à des prédictions en surapprentissage. Le StackingClassifier de scikit-learn l’évite en engendrant les prédictions de base par validation croisée interne, de sorte que le méta-apprenant ne voit jamais que les prédictions de chaque modèle de base sur des lignes sur lesquelles ce modèle ne s’est pas entraîné.

Nous empilons trois bases diverses — une forêt aléatoire, un booster à gradient boosting sur histogrammes, et un kNN mis à l’échelle — surmontées d’une régression logistique.

from sklearn.ensemble import StackingClassifier
from sklearn.linear_model import LogisticRegression

base_estimators = [
    ('rf', RandomForestClassifier(random_state=42)),
    ('hgb', HistGradientBoostingClassifier(random_state=42)),
    ('knn', make_pipeline(StandardScaler(), KNeighborsClassifier())),
]

stack_clf = StackingClassifier(
    estimators=base_estimators,
    final_estimator=LogisticRegression(max_iter=1000),
    cv=5,
    n_jobs=-1,
)
stack_clf.fit(X_train, y_train)
stack_test = accuracy_score(y_test, stack_clf.predict(X_test))
print(f"Stacking test accuracy: {stack_test:.3f}")
Stacking test accuracy: 0.899

Pour comparaison, un ensemble à vote dur des trois mêmes bases sur le même découpage :

voting3_clf = VotingClassifier(estimators=base_estimators, voting='hard', n_jobs=-1)
voting3_clf.fit(X_train, y_train)
voting3_test = accuracy_score(y_test, voting3_clf.predict(X_test))
print(f"Voting (same bases) test accuracy: {voting3_test:.3f}")
Voting (same bases) test accuracy: 0.891

Résumé

Exactitude de test et rappel par classe pour chaque ensemble de cette leçon, face au modèle de référence trivial. L’exactitude agrégée masque les échecs propres à certaines classes ; les colonnes de rappel montrent, pour chaque classe vraie, la fraction de ses événements que le modèle attrape.

from sklearn.metrics import recall_score

fitted = {
    'Voting (NB + RF + SVC)': voting_clf,
    'Bagged trees (OOB demo)': oob_clf,
    'AdaBoost': ada_clf,
    'HistGradientBoosting': hgb_clf,
    'LightGBM': lgbm_clf,
    'Stacking (RF + HGB + kNN)': stack_clf,
    'Voting (RF + HGB + kNN)': voting3_clf,
}
classes = sorted(y.unique())
rows = {'Majority-class baseline': [baseline] + [np.nan] * len(classes)}
for name, clf in fitted.items():
    pred = clf.predict(X_test)
    rows[name] = ([accuracy_score(y_test, pred)]
                  + list(recall_score(y_test, pred,
                                      average=None, labels=classes)))
results = pd.DataFrame(
    rows, index=['test accuracy'] + [f'recall: {c}' for c in classes]).T
results.round(3)
Loading...

Tous les ensembles dépassent largement le plancher de 25 %, et les écarts entre les ensembles performants sont faibles sur ce jeu de données. Les colonnes de rappel montrent où logent les erreurs restantes : chaque modèle attrape le bruit et les événements de surface avec un rappel de 90 % ou mieux, tandis que le rappel sur les explosions se situe entre 0,77 et 0,84 pour tous — l’exactitude agrégée masque une classe mal classée deux à trois fois plus souvent que les autres. L’habitude à conserver : comparer les modèles par validation croisée à l’intérieur de l’ensemble d’entraînement, dépenser l’ensemble de test exactement une fois, et rapporter le rappel par classe à côté du score agrégé.

References
  1. Kharita, A. (2024). Physical features for small sample of data (1000 events per class). Zenodo. 10.5281/ZENODO.14025693