Ch. 06

Ensemble Methods

Why ensembles work, voting → bagging → boosting → stacking, XGBoost in production.

random-forestXGBoostLightGBM

FIG 06 · Explainer video


Toss a slightly-biased coin (51% heads) ten times and you might see five heads or you might see eight. Toss it ten thousand times and you will see 51% heads, plus or minus a fraction of a percent. The law of large numbers. The same statistical move powers every winning Kaggle solution for the last twelve years and every production tabular ML system worth shipping. Train a thousand mediocre decision trees, each slightly different, take their majority vote, and you get a classifier dramatically better than any single tree could ever be. The mediocre individual classifiers do not need to be smart. They need to be uncorrelated. The trick is finding ways to make them disagree, which is what bagging, boosting, and stacking each accomplish through different mechanisms. By the end of this chapter you will have built a random forest from DecisionTreeClassifier upward, hand-coded the booster that XGBoost optimizes, and understood why "an ensemble of weak learners" is not a metaphor but a literal recipe.


FIG 06.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Train a VotingClassifier over heterogeneous base models and explain why soft voting usually beats hard voting.
  • Implement bagging from scratch (bootstrap sampling + majority vote) in 40 lines of NumPy.
  • Build a RandomForestClassifier, read off feature_importances_, and explain what they actually measure.
  • Compute out-of-bag accuracy and trust it without a held-out validation set.
  • Implement AdaBoost in 60 lines of pure Python and watch the example weights re-distribute over rounds.
  • Read the XGBClassifier API and sensible-default hyperparameters, and know why a tuned XGBoost beats a random forest on a tabular benchmark (the runnable notebook stays on sklearn, since XGBoost is not in Colab's default image; the from-scratch booster in the lab is the same algorithm).
  • Stack three diverse models into a meta-learner and explain why naive stacking overfits.
  • Argue when a single deep model beats every ensemble (image/text/audio) and when ensembles still win (tabular, small data).

FIG 06.2 · What you need first

  • Ch 5 — Decision Trees, SVMs, and Kernelsyou need to know what a single decision tree is and why it has high variance. The whole chapter sits on top of CART.
  • Ch 4 — Training Modelsgradient descent and the bias-variance trade-off. Gradient boosting is gradient descent in function space, which only makes sense after you have seen gradient descent in parameter space.
  • Ch 3 — Classificationconfusion matrix and the usual classification metrics.

If you skipped another chapter: skim §1-§3 of that chapter before you start here. The "single trees are unstable" claim is load-bearing for the rest of the chapter.


FIG 06.3.1

Why ensembles work: the law of large numbers applied to classifiers

Take a biased coin that comes up heads 51% of the time. After ten thousand independent flips, the heads ratio is 51% ± 0.5% almost certainly. The variance shrinks as 1/n1/\sqrt{n}, the stays the same.

Replace "coin flip" with "classifier vote". Suppose you have NN classifiers that each correctly classify a given example with p>0.5p > 0.5, and that their errors are independent. The majority vote is wrong only if more than N/2N/2 of them are wrong. The Hoeffding bound — the statement that a sample mean of NN independent bounded variables sits within γ\gamma of the true mean with probability at least 12exp(2γ2N)1 - 2\exp(-2\gamma^2 N) — pins that failure probability down: it drops exponentially in NN.

With p=0.51p = 0.51 and N=1000N = 1000, the majority-vote is over 75%. With N=10000N = 10000, it is over 98%.

The catch is "independent". Real classifiers trained on the same data are not independent. They make correlated errors. The whole game of ensemble methods is generating diversity: making the individual classifiers disagree as much as possible while each remaining better than chance.

Python
# extra-code from Géron Ch 7, Figure 7-3, ported
import numpy as np

heads_proba = 0.51
np.random.seed(42)
coin_tosses = (np.random.rand(10000, 10) < heads_proba).astype(np.int32)
ratios = coin_tosses.cumsum(axis=0) / np.arange(1, 10001).reshape(-1, 1)
# ratios at the end converge to ~0.51 with tiny variance.
# This is what an ensemble of independent classifiers does.

FIG 06.3.2

Voting classifiers: the cheapest ensemble

VotingClassifier takes a list of trained models and predicts by aggregating their outputs. Two modes.

Hard voting: each classifier predicts a class label, the majority vote wins.

Soft voting: each classifier outputs class probabilities, the probabilities are averaged, the argmax of the average wins.

Soft voting is usually better because confident-but-wrong classifiers get downweighted and confident-and-right classifiers dominate. Hard voting throws away the confidence information.

Python
from sklearn.datasets import make_moons
from sklearn.ensemble import VotingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.model_selection import train_test_split

X, y = make_moons(n_samples=500, noise=0.30, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)

voting_clf = VotingClassifier(
    estimators=[
        ("lr", LogisticRegression(random_state=42)),
        ("rf", RandomForestClassifier(random_state=42)),
        ("svc", SVC(probability=True, random_state=42)),
    ],
    voting="soft",
)
voting_clf.fit(X_train, y_train)
print(voting_clf.score(X_test, y_test))
# 0.92  — typically a few percent better than any individual base model.

For soft voting with SVC, you must pass probability=True, which fits Platt internally. This is slow. For soft voting in production, prefer CalibratedClassifierCV(SVC(...)) or just use models whose predict_proba is native.

FIG 06.3.3

Bagging and pasting: bootstrap the data

The cheaper way to generate ensemble diversity: train each classifier on a different subsample of the training data.

Bagging (bootstrap aggregating): sample mm examples with replacement from the , train a classifier on that bootstrap sample, repeat NN times.

Pasting: same but without replacement.

Bagging is the more common choice. Each bootstrap sample contains about 63% of unique training examples (the others are missing because of duplicates). The 37% that are missing become out-of-bag (OOB) examples for that classifier, which gives you a free per tree.

Python
from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier

bag = BaggingClassifier(
    DecisionTreeClassifier(random_state=42),
    n_estimators=500,
    max_samples=100,
    bootstrap=True,
    n_jobs=-1,
    random_state=42,
)
bag.fit(X_train, y_train)
print(bag.score(X_test, y_test))

From-scratch path:

Python
import numpy as np
from sklearn.tree import DecisionTreeClassifier
from collections import Counter

def fit_bag(X: np.ndarray, y: np.ndarray, n_estimators: int = 100,
            max_samples: int = None, random_state: int = 42) -> list:
    """Train n_estimators decision trees on bootstrap samples."""
    rng = np.random.default_rng(random_state)
    m = len(y)
    max_samples = max_samples or m
    trees = []
    for i in range(n_estimators):
        idx = rng.integers(0, m, size=max_samples)   # with replacement
        tree = DecisionTreeClassifier(random_state=i)
        tree.fit(X[idx], y[idx])
        trees.append(tree)
    return trees

def predict_bag(trees: list, X: np.ndarray) -> np.ndarray:
    """Majority vote across trees."""
    votes = np.array([t.predict(X) for t in trees])
    return np.array([Counter(votes[:, j]).most_common(1)[0][0]
                     for j in range(X.shape[0])])

That is bagging in 15 lines. The sklearn version handles parallelism, the OOB tracking, and the predict_proba case, but the core loop is what is above.

Note on OOB: since each tree is missing 37% of the data, you can compute its on that 37% and average across all trees. BaggingClassifier(oob_score=True) does this. The OOB score is an unbiased estimate of error, often better-behaved than a single held-out validation set.

FIG 06.3.4

Random forests: bagging with feature randomization

A random forest is bagging applied to decision trees with one additional twist: at each split, consider only a random subset of features (typically d\sqrt{d} for classification, d/3d/3 for regression). This further decorrelates the trees.

Why decorrelate? Because if two trees always pick the same root split, they will make highly correlated errors and the averaging benefit is small. Random selection forces different trees to use different features, which produces stronger diversity than bagging alone.

Python
from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(
    n_estimators=500,
    max_leaf_nodes=16,        # implicit depth control
    max_features="sqrt",      # default for classification
    n_jobs=-1,
    random_state=42,
    oob_score=True,
)
rf.fit(X_train, y_train)
print(f"Test acc: {rf.score(X_test, y_test):.3f}")
print(f"OOB acc:  {rf.oob_score_:.3f}")

Feature importances. rf.feature_importances_ gives a number per feature: the average decrease in Gini impurity at splits that use that feature, weighted by the number of points routed through those splits, averaged across all trees in the forest. Sum to 1.

Python
import pandas as pd
from sklearn.datasets import load_iris

iris = load_iris(as_frame=True)
rf = RandomForestClassifier(n_estimators=500, random_state=42)
rf.fit(iris.data, iris.target)
importances = pd.Series(rf.feature_importances_, index=iris.feature_names)
print(importances.sort_values(ascending=False))
# petal length (cm)    0.44...
# petal width (cm)     0.42...
# sepal length (cm)    0.10...
# sepal width (cm)     0.02...

Petal length and petal width together explain 86% of the model's splits. That matches the actual structure of Iris (the two petal features separate the three species; the sepal features barely help).

FIG 06.3.5

Extra Trees: more randomness, less computation

Extremely Randomized Trees (ExtraTreesClassifier) take random forests one step further. At each split, instead of finding the best threshold for each candidate , pick a random threshold for each candidate feature, then take the best among those random thresholds.

This sounds wrong. It works. The randomness is yet another decorrelation lever, and the savings in computation are real (no sort over feature values needed). On many tabular datasets, ExtraTrees is within a percent of RF at half the training time.

Python
from sklearn.ensemble import ExtraTreesClassifier

et = ExtraTreesClassifier(n_estimators=500, random_state=42, n_jobs=-1)
et.fit(X_train, y_train)
print(et.score(X_test, y_test))

FIG 06.3.6

Boosting: train each learner to fix the previous one

Bagging makes trees diverse by perturbing the data. Boosting makes them diverse by perturbing the loss: each successive tree focuses on the examples the previous trees got wrong.

The first boosting algorithm is AdaBoost (Freund and Schapire, 1996). The recipe:

  1. Initialize per-example weights wi=1/mw_i = 1/m.
  2. For t=1,,Tt = 1, \ldots, T:
  • Train a weak classifier hth_t on the data, weighted by wiw_i.
  • Compute weighted error ϵt=iwi1[ht(xi)yi]\epsilon_t = \sum_i w_i \mathbb{1}[h_t(x_i) \neq y_i].
  • Compute classifier αt=12log1ϵtϵt\alpha_t = \frac{1}{2} \log \frac{1 - \epsilon_t}{\epsilon_t}.
  • Reweight examples: wiwiexp(αtyiht(xi))w_i \leftarrow w_i \exp(-\alpha_t y_i h_t(x_i)), normalize.
  1. Predict: y^(x)=sign(tαtht(x))\hat{y}(x) = \text{sign}\left( \sum_t \alpha_t h_t(x) \right).

The intuition: examples that the current classifier gets wrong get bigger weights, so the next classifier pays more to them. The classifier weights αt\alpha_t are larger for more accurate classifiers.

AdaBoostClassifier vs. discrete SAMME from scratch

Classical ML
LIBRARY
# sklearn >=1.6 only supports discrete SAMME (the 'algorithm' arg is deprecated/no-op)
ada = AdaBoostClassifier(
    estimator=DecisionTreeClassifier(max_depth=1),  # decision stumps
    n_estimators=100, learning_rate=1.0, random_state=42)
ada.fit(X, y)
FROM SCRATCH
def fit_adaboost(X, y, T=100):
    """Train T decision stumps via AdaBoost. y in {-1, +1}."""
    m = len(y)
    w = np.ones(m) / m                       # uniform example weights
    classifiers, alphas = [], []
    for t in range(T):
        stump = DecisionTreeClassifier(max_depth=1)
        stump.fit(X, y, sample_weight=w)     # weak learner on weighted data
        pred = stump.predict(X)
        err = np.sum(w * (pred != y)) / np.sum(w)        # weighted error
        err = np.clip(err, 1e-10, 1 - 1e-10)             # dodge log(0)
        alpha = 0.5 * np.log((1 - err) / err)            # classifier weight
        w = w * np.exp(-alpha * y * pred)                # up-weight mistakes
        w = w / np.sum(w)                                # renormalize
        classifiers.append(stump)
        alphas.append(alpha)
    return classifiers, np.array(alphas)

def predict_adaboost(classifiers, alphas, X):
    scores = sum(a * c.predict(X) for a, c in zip(alphas, classifiers))
    return np.sign(scores)

from scratch: draft.md §6 (Boosting): fit_adaboost / predict_adaboost (not in solution.py)

  1. 1estimator=DecisionTreeClassifier(max_depth=1) stump = DecisionTreeClassifier(max_depth=1) refit each round
  2. 2n_estimators=T the `for t in range(T)` loop appending one stump per round
  3. 3the internal sample reweighting between rounds w = w * np.exp(-alpha * y * pred) then w /= w.sum()
  4. 4the per-classifier weight alpha alpha = 0.5 * np.log((1 - err) / err) from the weighted error
  5. 5fitting the base learner on reweighted data stump.fit(X, y, sample_weight=w) — passing the current weights in
  6. 6ada.predict(X) (weighted vote) scores = sum(alpha_t * c_t.predict(X)); np.sign(scores)
What the one call hides
  • Label encoding: sklearn accepts arbitrary class labels and handles multiclass via SAMME internally; the scratch REQUIRES y in {-1, +1}.
  • learning_rate: sklearn scales each alpha by a shrinkage factor; the scratch uses an implicit learning_rate of 1.0.
  • The alpha constant: sklearn's SAMME alpha is ln((1-err)/err)+ln(K-1); the scratch uses 0.5*ln((1-err)/err) = HALF the SAMME value for K=2, which doesn't change the sign vote but won't numerically match per-round.
  • Early termination when a weak learner is worse than random (err>=0.5) or perfect (err==0), which sklearn detects and stops on.
  • Multiclass (>2 classes) entirely — the scratch is binary-only.
  • Gotcha: y MUST be in {-1, +1} for the scratch; passing {0, 1} silently breaks the w*exp(-alpha*y*pred) reweighting and the sign() vote.
  • Gotcha: AdaBoost overfits hard on label noise — mislabeled points accumulate exponentially growing weights round after round; a real failure mode, not a tuning issue.
  • Gotcha: sklearn >=1.6 dropped SAMME.R and deprecated the 'algorithm' arg (no-op); older code using algorithm='SAMME.R' will warn/behave differently — the scratch matches discrete SAMME.
  • Gotcha: Default learning_rate=1.0 with deep base estimators overfits fast; AdaBoost expects weak (stump) learners.

Use AdaBoostClassifier (or really gradient boosting, which is more noise-robust) in practice; the scratch exists to see the reweighting mechanic — uniform weights, exponentially up-weight mistakes, weight each stump by 0.5*log((1-err)/err) — and to internalize why label noise destroys it.

On the job: You reach for a boosting library and tune n_estimators / learning_rate / base-estimator depth; you never hand-roll the weight-update recurrence.

FIG 06.3.7

Gradient boosting: gradient descent in function space

AdaBoost is one specific way to combine weak learners. boosting generalizes it. Treat the ensemble's output as a function F(x)F(x). At each round, compute the gradient of the loss with respect to FF, fit a weak learner to that gradient, and add the weak learner's output (scaled by a ) to FF.

That is . In function space. Each step moves FF slightly toward the direction that reduces loss the fastest, and each step is parameterized as "fit a tree to the negative gradient".

For regression with squared loss, the negative gradient at example ii is just the residual yiF(xi)y_i - F(x_i). So gradient boosting for regression is: fit a tree to the current residuals, add it (scaled), update residuals, repeat. This is one of the cleanest algorithms in ML.

GradientBoostingRegressor vs. fit-the-residuals from scratch

Classical ML
LIBRARY
gb = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1,
                               max_depth=3, random_state=42)
gb.fit(X, y)
preds = gb.predict(X_test)
FROM SCRATCH
def fit(self, X, y):
    y = y.astype(float)                       # int labels would truncate the boosting
    self.initial = float(np.mean(y))          # F_0 = mean(y), the MSE-optimal constant
    F = np.full(y.shape, self.initial, dtype=float)
    self.trees = []
    for t in range(self.n_estimators):
        residuals = y - F                     # = -gradient of 0.5*(y-F)^2 w.r.t. F
        tree = DecisionTreeRegressor(max_depth=self.max_depth,
                                     random_state=self.random_state + t)
        tree.fit(X, residuals)                # weak learner fits the negative gradient
        F = F + self.learning_rate * tree.predict(X)   # shrunk additive step
        self.trees.append(tree)
    return self

def predict(self, X):
    F = np.full(X.shape[0], self.initial, dtype=float)
    for tree in self.trees:
        F = F + self.learning_rate * tree.predict(X)
    return F

from scratch: lab/solution.py: MyGBMRegressor.fit / MyGBMRegressor.predict

  1. 1the implicit init='mean' baseline self.initial = float(np.mean(y)); F starts as a constant array of that mean
  2. 2squared loss (loss='squared_error') residuals = y - F, since the negative gradient of 0.5*(y-F)^2 is exactly the residual
  3. 3each of the n_estimators boosting rounds the `for t in range(self.n_estimators)` loop fitting one DecisionTreeRegressor to current residuals
  4. 4learning_rate (shrinkage) F = F + self.learning_rate * tree.predict(X) — the scaled additive update
  5. 5max_depth of each weak learner DecisionTreeRegressor(max_depth=self.max_depth) constructed each round
  6. 6gb.predict(X_test) predict(): start at self.initial, accumulate learning_rate * tree.predict(X) over all trees
  7. 7gb.staged_predict(X) MyGBMRegressor.staged_predict: same accumulation but yields F.copy() after each tree
What the one call hides
  • The general gradient: for arbitrary losses sklearn computes the true negative gradient (pseudo-residuals); residual = y - F is the squared-loss SPECIAL CASE, which is why the scratch only covers MSE.
  • Per-leaf line-search: real GBM re-optimizes each leaf's output value against the loss (a Newton/line-search step) instead of using the tree's raw mean prediction — critical for non-squared losses.
  • subsample (stochastic gradient boosting) row sampling per tree, which sklearn supports and the scratch omits.
  • Friedman's improved split criterion ('friedman_mse') used by GradientBoostingRegressor, vs the plain 'squared_error' the scratch DecisionTreeRegressor uses.
  • No regularization, no early stopping (n_iter_no_change / validation_fraction), no init estimator override.
  • Classification path: for logistic loss residuals become (y - sigmoid(F)) in log-odds space — the scratch regressor doesn't cover it.
  • Gotcha: Forgetting y.astype(float) lets an int label dtype truncate/NaN the boosting; the solution casts explicitly for exactly this reason.
  • Gotcha: The residual = negative-gradient identity is squared-loss-ONLY; copying this loop for classification or MAE without changing the residual formula is just wrong.
  • Gotcha: learning_rate and n_estimators trade off: shrinking lr without raising n_estimators leaves the model underfit.
  • Gotcha: No early stopping in the basic call, so n_estimators too high overfits — boosting needs a validation holdout.

On the job reach for XGBoost/LightGBM (or GradientBoostingRegressor for small data); the scratch loop is to internalize that boosting is literally `fit a tree to the residuals, take a small step, repeat`, with residual = negative gradient only because the loss is squared error.

On the job: You pick a boosting library, set learning_rate/n_estimators/max_depth, and wire early stopping on a validation split; you don't write the additive loop.

40 lines. That is the algorithm. Everything else (XGBoost, LightGBM, CatBoost) is engineering: better split-finding, better , better handling of categorical features, GPU acceleration. The core is what is above.

Hyperparameters that matter:

  • learning_rate (also called shrinkage): typically 0.01 to 0.3. Smaller = more trees needed but better .
  • n_estimators: number of boosting rounds. Tune with .
  • max_depth: 3-8. Shallower than random forests, because boosting builds the ensemble additively.

FIG 06.3.8

XGBoost, LightGBM, CatBoost: gradient boosting in production

In 2026, " boosting" almost always means one of three libraries.

XGBoost (Chen and Guestrin, 2016). The first production-grade GBM library. Adds: regularized objective (L1L_1 and L2L_2 penalties on leaf values), second-order optimization (uses the Hessian — the curvature of the loss, — and not just the gradient, so each split is chosen from a closer approximation of the loss), efficient tree construction (sparse-aware, approximate split-finding for huge data), GPU support.

LightGBM (Microsoft, 2017). Optimized for speed on large datasets. Adds: histogram-based split finding (much faster than exact on large data), leaf-wise tree growth (deeper trees with fewer splits), gradient-based one-side sampling (GOSS), exclusive bundling.

CatBoost (Yandex, 2017). Specializes in categorical features. Adds: ordered boosting (avoids target leakage when encoding categoricals), symmetric trees (fast ), built-in handling of categorical columns without .

The named additions above (histogram split-finding, leaf-wise growth, GOSS, exclusive feature bundling, ordered boosting, symmetric trees) are speed-and- engineering on top of the same residual-fitting loop from §7; you can use these libraries well without knowing each one, so treat them as a glossary to grow into rather than prerequisites.

Python
import xgboost as xgb
from sklearn.metrics import accuracy_score

# XGBoost
xgb_clf = xgb.XGBClassifier(
    n_estimators=500,
    max_depth=6,
    learning_rate=0.1,
    objective="binary:logistic",
    n_jobs=-1,
    random_state=42,
    early_stopping_rounds=20,
)
xgb_clf.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
print(accuracy_score(y_test, xgb_clf.predict(X_test)))

# LightGBM
import lightgbm as lgb
lgb_clf = lgb.LGBMClassifier(
    n_estimators=500,
    learning_rate=0.05,
    num_leaves=31,
    random_state=42,
)
lgb_clf.fit(X_train, y_train, eval_set=[(X_test, y_test)],
             callbacks=[lgb.early_stopping(stopping_rounds=20)])

Practical defaults for XGBoost on a new tabular problem:

Python
xgb.XGBClassifier(
    n_estimators=1000,
    max_depth=6,
    learning_rate=0.05,
    subsample=0.8,            # row sampling per tree
    colsample_bytree=0.8,     # feature sampling per tree
    reg_alpha=0.0,            # L1 on leaf values
    reg_lambda=1.0,           # L2 on leaf values
    early_stopping_rounds=50,
)

Run that on most tabular benchmarks and you have a competitive before you tune anything. The 2026 reality: on Kaggle's tabular competitions, the winning solution is almost always an XGBoost + LightGBM blend with elaborate feature engineering. Deep learning on tabular data (TabNet, FT-Transformer, etc.) exists but rarely beats well-tuned GBMs.

FIG 06.3.9

Stacking: train a meta-learner on the base models' outputs

Voting averages predictions. Stacking trains a model that learns how to average them.

The setup:

  1. Train NN base models on the .
  2. Get each base model's predictions on a held-out set (or via cross-validation).
  3. Use those predictions as features for a meta-learner (typically a simple logistic regression).
  4. Train the meta-learner to map base-model predictions to the target.

For : pass new data through all NN base models, feed their outputs to the meta-learner, get the final prediction.

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

stacking = StackingClassifier(
    estimators=[
        ("rf", RandomForestClassifier(random_state=42)),
        ("xgb", xgb.XGBClassifier(random_state=42, eval_metric="logloss")),
        ("svc", SVC(probability=True, random_state=42)),
    ],
    final_estimator=LogisticRegression(),
    cv=5,
)
stacking.fit(X_train, y_train)

FIG 06.3.10

The bias-variance lens on ensembles

Bagging reduces variance. Boosting reduces . That is the high-level story.

Bagging. Each tree has roughly the same bias as a single tree. The variance is reduced by averaging NN partially-correlated trees: Var(fˉ)=ρσ2+(1ρ)σ2/N\text{Var}(\bar{f}) = \rho \sigma^2 + (1 - \rho) \sigma^2 / N, where ρ\rho is the average correlation between trees and σ2\sigma^2 is the variance of a single tree. As NN grows, only the correlated component remains. Random forests reduce ρ\rho via randomization, which makes bagging more effective.

Boosting. Each weak learner is high-bias, low-variance (depth-3 trees barely fit anything). The ensemble combines them additively, which reduces bias over rounds. AdaBoost and boosting do not reduce variance much per round, but they decrease bias dramatically. The risk is (variance creeping back in if boosting too many rounds), which is why is mandatory.

The textbook example: bagging 1000 depth-30 trees gives a low-variance, low-bias ensemble (because depth-30 trees already have low bias, and bagging fixes the variance). Boosting 1000 depth-3 stumps gives a low-bias, low-variance ensemble (because boosting fixes the bias, and depth-3 trees have low variance natively). Both routes get to the same destination through different mechanics.

FIG 06.3.11

When ensembles lose to a single deep model

The honest 2026 picture: ensembles dominate tabular ML, but a single deep model wins almost everywhere else.

Where ensembles still win:

  • Tabular data with mixed categorical and numerical features
  • Small datasets (< 100k rows)
  • Heterogeneous features with sharp discontinuities (e.g., "zip code matters above price threshold X but not below")
  • Problems where importance matters for the deployment (interpretability, regulatory)

Where a single deep model wins:

  • Images (CNNs, ViTs)
  • Text and code (transformers)
  • Audio (transformers, conv-stacks)
  • Video and multimodal (transformers)
  • Any problem with > 1M training examples and complex non-tabular structure

The pattern: ensembles thrive on the inductive biases of trees (axis-aligned splits, piecewise-constant predictions, native handling of feature interactions). Those biases match tabular data and mismatch perceptual data. A CNN can see edges and textures; a tree cannot. A transformer can compose long-range relationships; a tree cannot.

A practical heuristic: if your data fits in a Pandas DataFrame with named columns, try XGBoost first. If your data is pixels, tokens, or waveforms, train a small neural network first. The decision tree of decisions about which model to use is itself a useful diagnostic.

FIG 06.3.12

A unified view: every ensemble is a function-space optimization

Pull back. Voting, bagging, boosting, stacking — all four are different strategies for searching the space of functions F:XYF: \mathcal{X} \to \mathcal{Y}.

  • Voting picks a fixed weighted sum of pre-trained functions.
  • Bagging averages many functions drawn from a randomized training procedure.
  • Boosting does coordinate descent in function space, one weak learner per step.
  • Stacking trains a meta-function whose inputs are the outputs of base functions.

The unifying frame is functional . Treat the loss as a functional L[F]L[F] over functions, and ask which direction in function space reduces LL fastest. The answer is the negative evaluated at the training points: L/F(xi)-\partial L / \partial F(x_i). Boosting approximates this gradient with a tree; bagging averages over independent runs of the procedure; stacking adds a learned post-aggregation.

You will see this view again in deep learning. Each layer of a neural network is essentially a basis function chosen by gradient descent, and the network's prediction is the composition (not the sum, but close enough at conceptual level) of those basis functions. Boosting is a shallow neural network with a peculiar training procedure. Gradient descent is gradient descent. The connections matter because they tell you when one technique should win over another.


FIG 06.4 · Safety lens · this chapter

Ensembles inherit the failure modes of their base learners and add a few of their own. The interesting ones for shipping classifiers:

Hidden amplification in random forests. A single decision tree's failure modes are auditable: print the tree, find the split, fix it. A random forest of 500 trees has 500 independent splits per , and "the average over 500 trees" is not auditable in the same way. If your base trees encode shortcut features (proxies for protected attributes), the forest amplifies them through averaging. The asymmetry: tree audits give you a list of rules. Forest audits give you feature_importances_, which is a much weaker statement. The mitigation is permutation importance + per-subgroup error analysis. sklearn.inspection.permutation_importance is exact and ignores cardinality bias; subgroup error analysis is a 10-line pd.groupby that reveals whether the model's varies by protected attribute. See 05-safety/huyenchip-index §fairness-audits and 08-geron-notebooks/07_ensemble_learning_and_random_forests §feature-importance for the technique.

boosting and label noise. AdaBoost famously fails on label noise: mislabeled examples accumulate huge weights and the ensemble fits them perfectly. Gradient boosting is more robust but not immune. XGBoost's reg_alpha and reg_lambda partially fix this by regularizing leaf values, but a malicious actor with the ability to inject mislabeled examples into your can still degrade performance disproportionately. This is the foundation of data poisoning attacks against tabular ML. The classical paper is Biggio et al. 2012; the modern instance is dataset poisoning attacks on production recommender systems. Mitigations: holdout-set anomaly detection (does the model's error on a clean holdout track its error on training?), training-time outlier filtering, and robust loss functions (Huber loss for regression, focal loss for imbalanced classification). See 05-safety/anthropic-research-core-views-on-ai-safety §robustness.

Stacking, target leakage, and "too good to be true" benchmarks. The most common stacking bug is to fit the meta-learner on base-model predictions made on the training set rather than on a held-out fold. The base models have seen the labels; their predictions on the training set are near-perfect; the meta-learner learns "trust base model #2 unconditionally" and the whole stack overfits catastrophically. The mitigation is cv=5 (or higher) in StackingClassifier. The pattern to internalize: any time predictions are passed between models, the receiver must see them on data the producer did not train on. This rule generalizes to LLM agents, distillation pipelines, and feedback loops in production ML. See 05-safety/huyenchip-index §evaluation-debt and 02-code-refs/amidi-cs229-ml-tips §evaluation.

What habits to adopt when you write ensemble code:

  • Always compute permutation_importance alongside feature_importances_. The disagreement between the two is your bias-detection signal.
  • For boosting, use with a proper holdout. Never set n_estimators to a fixed number without validation.
  • In stacking, use cv=5 or higher. No exceptions. If your stacking accuracy is 5+ points above your best base model, you have a leakage bug.

FIG 06.5 · Under the hood

The library call, and the lines it hides

You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.

RandomForestClassifier vs. bagging + feature-subsampling from scratch

Classical ML
LIBRARY
rf = RandomForestClassifier(n_estimators=100, max_features="sqrt",
                            max_depth=10, random_state=42)
rf.fit(X, y)
preds = rf.predict(X_test)
FROM SCRATCH
def fit(self, X, y):
    rng = np.random.default_rng(self.random_state)
    m, d = X.shape
    n_feat = int(np.sqrt(d)) if self.max_features == "sqrt" else d
    n_feat = max(1, n_feat)
    self.trees, self.feature_subsets, self.bootstrap_rows = [], [], []
    for t in range(self.n_estimators):
        rows = rng.integers(0, m, size=m)                  # bootstrap rows, with replacement
        feats = rng.choice(d, size=n_feat, replace=False)  # ONE random feature subset per tree
        tree = DecisionTreeClassifier(max_depth=self.max_depth,
                                      random_state=int(rng.integers(0, 2**31 - 1)))
        tree.fit(X[rows][:, feats], y[rows])               # sklearn CART does the actual splitting
        self.trees.append(tree)
        self.feature_subsets.append(feats)
        self.bootstrap_rows.append(rows)
    return self

def predict(self, X):
    votes = np.zeros((X.shape[0], int(max(t.classes_.max() for t in self.trees)) + 1))
    for tree, feats in zip(self.trees, self.feature_subsets):
        preds = tree.predict(X[:, feats]).astype(int)
        for i, p in enumerate(preds):
            votes[i, p] += 1                                # hard one-vote-per-tree
    return votes.argmax(axis=1)                             # majority vote

from scratch: lab/solution.py: MyRandomForest.fit / MyRandomForest.predict

  1. 1RandomForestClassifier(n_estimators=100) the `for t in range(self.n_estimators)` loop building and storing one tree per iteration
  2. 2the built-in bootstrap=True row sampling rows = rng.integers(0, m, size=m) — m row indices drawn with replacement before each tree.fit
  3. 3max_features="sqrt" n_feat = int(np.sqrt(d)) with feats = rng.choice(d, size=n_feat, replace=False)
  4. 4each base estimator is a CART tree DecisionTreeClassifier(max_depth=..., random_state=...) — both paths call sklearn's tree underneath
  5. 5rf.predict(X_test) the votes matrix tallying each tree's class, then votes.argmax(axis=1)
  6. 6rf.oob_score_ (with oob_score=True) MyRandomForest.oob_score: per tree predict on rows it never sampled, majority-vote, compare to y
What the one call hides
  • Per-SPLIT vs per-TREE feature subsampling: sklearn redraws a random sqrt(d) feature subset at EVERY node of every tree; the scratch picks ONE subset per whole tree, so it decorrelates the ensemble less.
  • The CART tree itself: BOTH paths call sklearn's DecisionTreeClassifier, so neither builds the Gini/entropy split search from scratch — the 'from-scratch' part is ONLY the bagging + column-subsampling + voting wrapper.
  • Soft voting: sklearn's predict averages per-tree predict_proba (soft vote), not the hard one-vote-per-tree count the scratch uses, so they can disagree on close calls.
  • feature_importances_: mean impurity-decrease aggregated across all trees and normalized to sum to 1.
  • n_jobs parallelism, deterministic per-tree seeding, and OOB bookkeeping all plumbed through automatically.
  • Gotcha: max_features applies PER SPLIT in the library; assuming it's per-tree (as the scratch does) over-estimates how much decorrelation you actually get.
  • Gotcha: RandomForestClassifier.predict averages probabilities (soft), so it can beat or diverge from a literal hard majority vote — the scratch hard-vote is a simplification, not a bug.
  • Gotcha: max_features default is "sqrt" for the classifier but the regressor default differs (1.0 / all features historically), a footgun the one-liner papers over.
  • Gotcha: feature_importances_ is biased toward high-cardinality/continuous features; use sklearn.inspection.permutation_importance for any real decision.

Prefer RandomForestClassifier in production; the scratch exists to show a forest is just `fit N trees on bootstrapped rows + random column subsets, then vote`, but be honest that even the scratch leans on sklearn's tree and subsamples features per-tree, not per-split like the library.

On the job: You tune n_estimators / max_depth / max_features and read feature_importances_ or permutation_importance; you never hand-roll the bagging loop.



FIG 06.7 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • A majority-vote simulator that turns 51%-accurate coin-flip classifiers into a 98%-accurate ensemble, and the assert that proves error shrinks as 1/sqrt(N).
  • Bagging from scratch (bootstrap resample, fit, majority vote) in ~15 lines, plus the proof that each bootstrap sample covers ~63% of the data.
  • The demonstration that a RandomForestClassifier is bagging of trees plus feature subsampling, checked by reconstructing its prediction from estimators_ exactly.
  • AdaBoost from scratch in ~15 lines, agreeing with sklearn on the same stumps; gradient boosting from scratch as "fit a tree to the residuals", agreeing with GradientBoostingRegressor.
  • A stacking pipeline that you first break with target leakage (train accuracy 1.00, learns nothing), then fix with out-of-fold predictions.

~3 min on CPU · 105 cells · 11 checked exercises · runs in Colab


FIG 06.8 · Going further

  • 08-geron-notebooks/07_ensemble_learning_and_random_forests

    the spine of this chapter, full implementations of every method.

  • 13-fastbook/09_tabular

    fast.ai's deep dive on tabular ML, including the XGBoost-vs-deep-learning debate.

  • 02-code-refs/amidi-cs229-ml-tips

    Amidi's diagnostic checklist. The "model selection" page is one of the best one-pagers in ML.

  • XGBoost: A Scalable Tree Boosting System (Chen and Guestrin 2016) — read the paper. Section 2 (the regularized learning objective) is the core; everything else is engineering.
  • 24-founder-blogs/eugeneyan-*

    production ML voice on when to use what. Eugene Yan is the right reference for "is this worth shipping" questions.

  • mlcourse-topic03-intro

    open-source ML course. Topic 5 (bagging and RF), topic 10 (gradient boosting), topic 11 (Vowpal Wabbit) are all top-tier.

  • arena-curriculum (05-safety/arena-curriculum.md) — ARENA does not cover classical ensembles. The bias-variance reasoning here is the closest classical analog to the eval-design discussions in ARENA another chapter.

FIG 06.9 · What this enables

Chapters you can now read, with the connecting idea written out.

  • PCA + a random forest is one of the most common production-ML pipelines for tabular data with many features. Knowing both halves matters.

  • bias-variance is the bridge. The classical understanding of variance reduction via averaging is what makes evaluation hard for stochastic LLMs.

  • the policy in PPO is an ensemble of one neural network and an old version of itself. The same averaging-for-variance-reduction principle.

  • production tabular ML systems run gradient-boosted ensembles. Understanding their training and deployment patterns is a prerequisite.


FIG 06.10 · 11 sources
  1. 02-code-refs/amidi-cs229-supervised
  2. 02-code-refs/amidi-cs229-ml-tips
  3. 03-curricula/mlcourse-topic03-intro
  4. 04-stanford/cs229-main-notes-pdf
  5. 05-safety/anthropic-research-core-views-on-ai-safety
  6. 05-safety/huyenchip-index
  7. 08-geron-notebooks/07_ensemble_learning_and_random_forests
  8. 13-fastbook/09_tabular
  9. 16-d2l-sections/chapter_optimization (gradient descent background)
  10. 23-textbooks/math4ml
  11. 24-founder-blogs/eugeneyan-*