Un score OOB du bagging peut être disponible alors que certaines lignes d’entraînement n’ont reçu aucune prédiction hors sac. Avec peu d’arbres, lire seulement oob_score_ masque ce problème. Ce cas pratique mesure d’abord la couverture, recalcule l’exactitude sur les lignes réellement couvertes et garde un jeu de test séparé pour une configuration fixée à l’avance.
Résultat mesuré : avec un arbre et la graine 7, seulement 149 des 400 lignes d’entraînement reçoivent un vote OOB. Les 251 autres restent sans vote. Avec 50 arbres, les 400 lignes sont couvertes pour chacune des trois graines examinées. Cela décrit cette expérience sur données synthétiques ; 50 n’est pas un seuil universel.
Télécharger le ZIP gratuit The Pingouin — code et tests. Sans inscription. Archive extraite et rejouée, 8 tests de référence réussis. Les données sont fictives ; les résultats présentés proviennent du code exécuté.
Une prédiction hors sac doit venir d’un arbre qui n’a pas vu la ligne
Chaque arbre du bagging apprend sur un tirage bootstrap : des lignes sont tirées avec remise, certaines plusieurs fois et d’autres pas du tout. Pour évaluer une ligne en OOB, on utilise seulement les arbres dont le tirage ne la contient pas. La même observation peut donc recevoir beaucoup de votes, un seul vote ou aucun.
La documentation de BaggingClassifier précise que oob_decision_function_ peut contenir des NaN lorsqu’un nombre insuffisant d’estimateurs laisse certaines observations sans prédiction OOB. Elle expose aussi estimators_samples_, les indices tirés pour chaque estimateur. Nous utilisons cet attribut public pour compter les votes disponibles. Voir la référence officielle de BaggingClassifier.
Le guide du bagging et du bootstrap présente le principe général. Ici, nous examinons précisément les lignes qui ne contribuent pas à une prédiction hors sac.
Fixer le protocole et isoler le test
Le script génère 600 observations synthétiques pour une classification binaire, avec huit variables. Une séparation stratifiée fixe réserve 400 observations à l’entraînement et 200 au test. Les arbres utilisent une profondeur maximale de 5 et au moins 3 observations par feuille.
Nous examinons 1, 3, 10 et 50 arbres, pour les graines 7, 23 et 101. Le bootstrap est activé avec max_samples=1.0 : chaque arbre reçoit 400 tirages avec remise parmi les 400 lignes d’entraînement. Toutes les variables restent disponibles.
La configuration finale de 50 arbres est fixée dans le code avant la lecture du test. Les scores sur les 200 lignes séparées ne sont calculés que pour cette configuration, pour les trois graines annoncées. Nous ne choisissons ensuite ni la meilleure graine ni d’autres paramètres à partir de ces scores. Les courbes intermédiaires concernent uniquement l’OOB sur l’entraînement.
Compter les votes avant de calculer l’exactitude
Pour chaque arbre, le programme marque les observations présentes dans son tirage bootstrap. Il ajoute ensuite un vote OOB à toutes les observations absentes de ce tirage. Une observation tirée trois fois reste simplement « présente » pour cet arbre : les doublons ne doivent pas compter comme trois votes négatifs.
Le masque de couverture est votes > 0. Sur ces seules lignes, le script vérifie que les probabilités sont finies et que leur somme vaut 1, puis calcule l’exactitude. S’il ne reste aucune ligne couverte, il renvoie une valeur absente pour ce score, sans inventer un zéro.
Le fichier votes-oob.csv conserve le nombre de votes pour chaque observation d’entraînement et chaque configuration. Il permet de retrouver les lignes concernées, au lieu de ne conserver qu’un pourcentage global.
Observer ce qui manque avec peu d’arbres
Le tableau suivant reprend les calculs pour la graine 7. Les deux scores OOB ne portent pas nécessairement sur le même ensemble d’observations lorsque la couverture est incomplète.
| Arbres | Lignes couvertes | Sans vote | OOB brut | OOB sur couvertes |
|---|---|---|---|---|
| 1 | 149/400 | 251 | 65,50 % | 79,19 % |
| 3 | 302/400 | 98 | 76,00 % | 82,45 % |
| 10 | 395/400 | 5 | 85,25 % | 85,82 % |
| 50 | 400/400 | 0 | 87,75 % | 87,75 % |
Avec un arbre, le score brut est de 65,50 %, tandis que l’exactitude recalculée sur les 149 lignes couvertes vaut 79,19 %. Ce second chiffre n’est pas une correction magique : il décrit seulement le sous-ensemble ayant reçu une prédiction. Il doit être accompagné de son effectif.
Dans scikit-learn 1.9.1, version exécutée ici, le calcul du score brut prend l’argmax des sommes de probabilités. Une ligne sans contribution reste à zéro et son argmax vaut 0, ce qui revient à sélectionner la première classe pour le calcul de ce score. La normalisation des probabilités laisse, elle, des valeurs non finies sur ces lignes. Ce comportement est visible dans le code officiel de cette version et explique pourquoi il faut inspecter la couverture.
Ne remplacez donc pas les NaN par zéro avant un argmax pour faire disparaître le problème. Cela fabrique une décision pour une ligne sans vote. L’écart entre les scores n’a pas une direction garantie pour tous les jeux de données, et le comportement d’un autre estimateur ou d’une autre version doit être vérifié séparément.

Pour les trois graines, la couverture est de 34,25 à 37,25 % avec un arbre, de 74,50 à 76,75 % avec trois arbres et de 98,75 à 99 % avec dix arbres. Les quatre ou cinq lignes encore absentes à dix arbres suffisent à rendre l’évaluation incomplète. À 50 arbres, chaque ligne reçoit au moins huit votes dans cette expérience.
Relier la couverture observée au bootstrap
Avec n observations et n tirages avec remise, la probabilité qu’une observation donnée reste hors d’un tirage est q = (1 − 1/n)n. Pour n = 400, cela représente environ 36,74 %. Avec B tirages bootstrap indépendants, la probabilité de recevoir au moins un vote OOB est 1 − (1 − q)B.
La courbe en pointillés montre cette espérance de couverture, calculée par le script. Elle explique pourquoi une couverture presque complète peut demander plusieurs arbres. Elle ne garantit pas que chaque ligne sera couverte sur un tirage particulier. Cette formule correspond au protocole avec n tirages par arbre ; changer max_samples change le calcul.
Une couverture de 100 % n’assure pas non plus un score stable. Une ligne évaluée par un seul arbre reste peu documentée. Le nombre minimal et la médiane des votes, disponibles dans les résultats, complètent donc utilement la proportion de lignes couvertes.
Comparer la configuration fixée au test séparé
Voici les trois modèles à 50 arbres. Tous utilisent exactement la même séparation entraînement/test ; seule la graine du bagging change.
| Graine | Lignes OOB | Exactitude OOB | Exactitude test (200 lignes) |
|---|---|---|---|
| 7 | 400 | 87,75 % | 85,00 % |
| 23 | 400 | 86,00 % | 84,50 % |
| 101 | 400 | 86,50 % | 86,50 % |
Le score test varie ici de 84,50 à 86,50 %. Un prédicteur constant choisissant la classe majoritaire de l’entraînement obtient 56 % sur ce même test. Ces valeurs situent l’expérience ; elles ne prédisent pas la performance sur des données réelles.
Trois graines sur le même jeu ne constituent pas trois jeux indépendants ni un intervalle de confiance. Si vous utilisez ces résultats pour changer les hyperparamètres, le test cesse d’être une évaluation finale indépendante. Le choix des paramètres doit alors passer par une validation appropriée, avec un autre test final conservé à part.
Reproduire les mesures et le graphique
Dans un environnement Python dédié, depuis le dossier extrait :
python -m pip install -r requirements.txt
python bagging_oob.py
python test_exemple.py
Le programme écrit resultats.json, resultats.csv, votes-oob.csv et figure.png dans le dossier courant, en remplaçant les fichiers de même nom. Le JSON conserve les avertissements émis pendant les entraînements. Les versions des dépendances sont fixées dans le téléchargement ; le programme travaille ensuite localement, sans service externe ni données personnelles.
Afficher le script Python complet et commenté
"""Mesurer la couverture OOB avant d'interpréter un score de bagging."""
import csv
import json
from pathlib import Path
import warnings
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import make_classification
from sklearn.ensemble import BaggingClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
GRAINES = (7, 23, 101)
TAILLES = (1, 3, 10, 50)
TAILLE_FINALE = 50 # Fixée avant consultation du jeu de test.
def donnees():
X, y = make_classification(n_samples=600, n_features=8, n_informative=5,
n_redundant=1, weights=[0.55, 0.45], class_sep=0.8, flip_y=0.05, random_state=23)
train, test = train_test_split(np.arange(len(y)), test_size=200,
stratify=y, random_state=17)
return X, y, train, test
def comptes_oob(echantillons, n):
comptes = np.zeros(n, dtype=int)
for indices in echantillons:
presents = np.zeros(n, dtype=bool)
presents[np.unique(indices)] = True
comptes += ~presents
return comptes
def couverture_attendue(n, arbres):
"""Bootstrap : n tirages avec remise pour chacun des arbres."""
if type(n) is not int or type(arbres) is not int or n < 2 or arbres < 1:
raise ValueError("n >= 2 et arbres >= 1 doivent être des entiers.")
probabilite_oob = (1 - 1 / n) ** n
return 1 - (1 - probabilite_oob) ** arbres
def ajuster(X, y, arbres, graine):
modele = BaggingClassifier(
estimator=DecisionTreeClassifier(max_depth=5, min_samples_leaf=3),
n_estimators=arbres, bootstrap=True, max_samples=1.0,
max_features=1.0, bootstrap_features=False, oob_score=True,
random_state=graine, n_jobs=1)
with warnings.catch_warnings(record=True) as messages:
warnings.simplefilter("always")
modele.fit(X, y)
return modele, [{"type": type(m.message).__name__, "message": str(m.message)} for m in messages]
def diagnostiquer(modele, y):
votes = comptes_oob(modele.estimators_samples_, len(y))
probabilites = np.asarray(modele.oob_decision_function_)
if probabilites.shape != (len(y), len(modele.classes_)):
raise ValueError("Dimensions des probabilités OOB incohérentes.")
couvertes = votes > 0
if not np.isfinite(probabilites[couvertes]).all():
raise ValueError("Probabilités non finies malgré des votes OOB.")
if not np.allclose(probabilites[couvertes].sum(axis=1), 1):
raise ValueError("Probabilités OOB non normalisées.")
score_valide = None
if couvertes.any():
predictions = modele.classes_[probabilites[couvertes].argmax(axis=1)]
score_valide = float(accuracy_score(y[couvertes], predictions))
brut = float(modele.oob_score_)
return {
"n_train": len(y), "couvertes": int(couvertes.sum()),
"sans_vote": int((~couvertes).sum()), "couverture": float(couvertes.mean()),
"votes_min": int(votes.min()), "votes_mediane": float(np.median(votes)),
"lignes_non_finies": int((~np.isfinite(probabilites).all(axis=1)).sum()),
"score_oob_brut": brut if np.isfinite(brut) else None,
"score_oob_couvertes": score_valide,
}, votes
def experience():
X, y, train, test = donnees()
lignes, details, avertissements = [], [], []
for graine in GRAINES:
for arbres in TAILLES:
modele, messages = ajuster(X[train], y[train], arbres, graine)
ligne, votes = diagnostiquer(modele, y[train])
ligne.update(graine=graine, arbres=arbres,
couverture_attendue=couverture_attendue(len(train), arbres),
score_test=None)
if arbres == TAILLE_FINALE:
ligne["score_test"] = float(accuracy_score(y[test], modele.predict(X[test])))
lignes.append(ligne)
avertissements.append({"graine": graine, "arbres": arbres, "messages": messages})
for ident, nombre in zip(train.tolist(), votes.tolist()):
details.append({"graine": graine, "arbres": arbres,
"id_observation": ident, "votes_oob": nombre})
majoritaire = int(np.bincount(y[train]).argmax())
resultat = {
"donnees": "600 observations synthétiques ; aucun résultat d'entreprise.",
"n_train": len(train), "n_test": len(test), "graines": GRAINES,
"tailles": TAILLES, "taille_finale_fixee": TAILLE_FINALE,
"score_test_majoritaire": float(np.mean(y[test] == majoritaire)),
"resultats": lignes, "avertissements": avertissements,
}
return resultat, details
def exporter_csv(nom, lignes):
with Path(nom).open("w", encoding="utf-8", newline="") as fichier:
writer = csv.DictWriter(fichier, fieldnames=list(lignes[0]))
writer.writeheader()
writer.writerows(lignes)
def figure(resultat):
fig, axes = plt.subplots(1, 2, figsize=(10, 4.5), constrained_layout=True)
couleurs = ["#087e8b", "#e07a25", "#6f4e9c"]
for graine, couleur in zip(GRAINES, couleurs):
lignes = [r for r in resultat["resultats"] if r["graine"] == graine]
axes[0].plot(TAILLES, [100 * r["couverture"] for r in lignes],
"o-", color=couleur, label=f"Graine {graine}")
axes[1].plot(TAILLES, [r["score_oob_couvertes"] for r in lignes],
"o-", color=couleur, label=f"Graine {graine}")
axes[0].plot(TAILLES, [100 * couverture_attendue(resultat["n_train"], b) for b in TAILLES],
"--", color="#444444", label="Espérance théorique")
axes[0].set(title="Lignes ayant au moins un vote OOB", ylabel="Couverture (%)", ylim=(0, 105))
axes[1].set(title="Exactitude sur les seules lignes couvertes", ylabel="Exactitude OOB", ylim=(0, 1))
for ax in axes:
ax.set_xscale("log")
ax.set_xticks(TAILLES, [str(n) for n in TAILLES])
ax.set_xlabel("Nombre d'arbres (échelle logarithmique)")
ax.grid(alpha=0.2)
ax.legend(fontsize=8)
fig.suptitle("Bagging : couverture et score OOB — 400 lignes d'entraînement synthétiques")
fig.savefig("figure.png", dpi=160)
plt.close(fig)
def main():
resultat, details = experience()
Path("resultats.json").write_text(
json.dumps(resultat, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
exporter_csv("resultats.csv", resultat["resultats"])
exporter_csv("votes-oob.csv", details)
figure(resultat)
print(json.dumps(resultat, ensure_ascii=False, indent=2, allow_nan=False))
if __name__ == "__main__":
main()
Contrôler le calcul et respecter ses limites
Les huit tests vérifient notamment les doublons du bootstrap, la séparation des identifiants d’entraînement et de test, le cas sans aucune ligne couverte et le rejet de probabilités incohérentes. Un témoin indépendant reconstruit les probabilités OOB en additionnant les prédictions des arbres réellement éligibles et compare ce résultat à celui de scikit-learn. L’archive a été extraite et rejouée, avec les mêmes résultats JSON et CSV.
Cette démonstration concerne BaggingClassifier avec des arbres sur des données synthétiques. Une série temporelle ou plusieurs lignes appartenant au même client demandent une autre réflexion sur l’indépendance des observations. Une couverture complète ne répare pas une fuite entre clients, ni un prétraitement appris sur les données servant ensuite à l’évaluation.
Pour ce dernier problème, poursuivez avec le cas séparer les clients entre entraînement et test. Les autres scripts et exercices sont rassemblés sur la page de ressources Python.
Exemple original exécuté le 23 octobre 2026 avec Python 3.12.14, numpy 2.5.3, scipy 1.18.1, scikit-learn 1.9.1, matplotlib 3.11.2. Versions et consignes dans le téléchargement. Miniature illustrative générée avec image_gen. Notre méthode éditoriale.

