Ch. 06
Ensemble Methods
Why ensembles work, voting → bagging → boosting → stacking, XGBoost in production.
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 A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → 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
VotingClassifierover 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 offfeature_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
XGBClassifierAPI and sensible-default hyperparameters, and know why a tuned XGBoost beats a random forest on a tabular benchmark (the runnable notebook stays onsklearn, 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 Kernels — you 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 Models — gradient 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 — Classification — confusion 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 , the A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → stays the same.
Replace "coin flip" with "classifier vote". Suppose you have classifiers that each correctly classify a given example with A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → , and that their errors are independent. The majority vote is wrong only if more than of them are wrong. The Hoeffding bound — the statement that a sample mean of independent bounded variables sits within of the true mean with probability at least — pins that failure probability down: it drops exponentially in .
With and , the majority-vote The share of guesses the model got right out of all its guesses.Full glossary → is over 75%. With , 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.
# 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.
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 Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → 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 examples with replacement from the The batch of examples the model actually studies and learns from.Full glossary →, train a classifier on that bootstrap sample, repeat 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 A separate batch of examples you check the model against while you're still tinkering, to see how it's doing.Full glossary → per tree.
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:
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 The share of guesses the model got right out of all its guesses.Full glossary → on that 37% and average across all trees. BaggingClassifier(oob_score=True) does this. The OOB score is an unbiased estimate of How well the model handles brand-new examples it never studied.Full glossary → 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 for classification, 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 One piece of information about an example that the model looks at when making a guess.Full glossary → selection forces different trees to use different features, which produces stronger diversity than bagging alone.
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.
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 One piece of information about an example that the model looks at when making a guess.Full glossary →, 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 The share of guesses the model got right out of all its guesses.Full glossary → at half the training time.
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:
- Initialize per-example weights .
- For :
- Train a weak classifier on the data, weighted by .
- Compute weighted error .
- Compute classifier A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → .
- Reweight examples: , normalize.
- Predict: .
The intuition: examples that the current classifier gets wrong get bigger weights, so the next classifier pays more A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → to them. The classifier weights are larger for more accurate classifiers.
AdaBoostClassifier vs. discrete SAMME from scratch
Classical ML# 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)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
estimator=DecisionTreeClassifier(max_depth=1)stump = DecisionTreeClassifier(max_depth=1) refit each round - 2
n_estimators=Tthe `for t in range(T)` loop appending one stump per round - 3
the internal sample reweighting between roundsw = w * np.exp(-alpha * y * pred) then w /= w.sum() - 4
the per-classifier weight alphaalpha = 0.5 * np.log((1 - err) / err) from the weighted error - 5
fitting the base learner on reweighted datastump.fit(X, y, sample_weight=w) — passing the current weights in - 6
ada.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. A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → boosting generalizes it. Treat the ensemble's output as a function . At each round, compute the gradient of the loss with respect to , fit a weak learner to that gradient, and add the weak learner's output (scaled by a How big an adjustment the model makes to its numbers each time, after the gradient tells it which way to go.Full glossary →) to .
That is The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary →. In function space. Each step moves 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 is just the residual . 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 MLgb = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1,
max_depth=3, random_state=42)
gb.fit(X, y)
preds = gb.predict(X_test)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 Ffrom scratch: lab/solution.py: MyGBMRegressor.fit / MyGBMRegressor.predict
- 1
the implicit init='mean' baselineself.initial = float(np.mean(y)); F starts as a constant array of that mean - 2
squared loss (loss='squared_error')residuals = y - F, since the negative gradient of 0.5*(y-F)^2 is exactly the residual - 3
each of the n_estimators boosting roundsthe `for t in range(self.n_estimators)` loop fitting one DecisionTreeRegressor to current residuals - 4
learning_rate (shrinkage)F = F + self.learning_rate * tree.predict(X) — the scaled additive update - 5
max_depth of each weak learnerDecisionTreeRegressor(max_depth=self.max_depth) constructed each round - 6
gb.predict(X_test)predict(): start at self.initial, accumulate learning_rate * tree.predict(X) over all trees - 7
gb.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 A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary →, 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 How well the model handles brand-new examples it never studied.Full glossary →.n_estimators: number of boosting rounds. Tune with Watching the model's score on fresh examples and halting training the moment that score stops improving.Full glossary →.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, "A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → boosting" almost always means one of three libraries.
XGBoost (Chen and Guestrin, 2016). The first production-grade GBM library. Adds: regularized objective ( and penalties on leaf values), second-order optimization (uses the Hessian — the curvature of the loss, Out of all the things that really were 'yes,' how many the model managed to catch.Full glossary → — 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 One piece of information about an example that the model looks at when making a guess.Full glossary → bundling.
CatBoost (Yandex, 2017). Specializes in categorical features. Adds: ordered boosting (avoids target leakage when encoding categoricals), symmetric trees (fast Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →), built-in handling of categorical columns without A way to turn a category like a color into numbers the model can use, by making a yes/no slot for each option.Full glossary →.
The named additions above (histogram split-finding, leaf-wise growth, GOSS, exclusive feature bundling, ordered boosting, symmetric trees) are speed-and-The share of guesses the model got right out of all its guesses.Full glossary → 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.
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:
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 A simple reference method you compare against to see whether a fancier approach is actually worth it.Full glossary → 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:
- Train base models on the The batch of examples the model actually studies and learns from.Full glossary →.
- Get each base model's predictions on a held-out set (or via cross-validation).
- Use those predictions as features for a meta-learner (typically a simple logistic regression).
- Train the meta-learner to map base-model predictions to the target.
For Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →: pass new data through all base models, feed their outputs to the meta-learner, get the final prediction.
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 A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary →. That is the high-level story.
Bagging. Each tree has roughly the same bias as a single tree. The variance is reduced by averaging partially-correlated trees: , where is the average correlation between trees and is the variance of a single tree. As grows, only the correlated component remains. Random forests reduce via One piece of information about an example that the model looks at when making a guess.Full glossary → 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 A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → boosting do not reduce variance much per round, but they decrease bias dramatically. The risk is When a model memorizes the quirks and flukes of its study examples instead of the real pattern, so it flops on anything new.Full glossary → (variance creeping back in if boosting too many rounds), which is why Watching the model's score on fresh examples and halting training the moment that score stops improving.Full glossary → 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 One piece of information about an example that the model looks at when making a guess.Full glossary → 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 A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → 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 .
- 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 The core routine of training: check which way is downhill, take a small step, and repeat until the guesses are good.Full glossary →. Treat the loss as a functional over functions, and ask which direction in function space reduces fastest. The answer is the negative A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → evaluated at the training points: . 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 A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → 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 One piece of information about an example that the model looks at when making a guess.Full glossary →, 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 The share of guesses the model got right out of all its guesses.Full glossary → 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.
A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → 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 The batch of examples the model actually studies and learns from.Full glossary → 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_importancealongsidefeature_importances_. The disagreement between the two is your bias-detection signal. - For boosting, use Watching the model's score on fresh examples and halting training the moment that score stops improving.Full glossary → with a proper holdout. Never set
n_estimatorsto a fixed number without validation. - In stacking, use
cv=5or 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 MLrf = RandomForestClassifier(n_estimators=100, max_features="sqrt",
max_depth=10, random_state=42)
rf.fit(X, y)
preds = rf.predict(X_test)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 votefrom scratch: lab/solution.py: MyRandomForest.fit / MyRandomForest.predict
- 1
RandomForestClassifier(n_estimators=100)the `for t in range(self.n_estimators)` loop building and storing one tree per iteration - 2
the built-in bootstrap=True row samplingrows = rng.integers(0, m, size=m) — m row indices drawn with replacement before each tree.fit - 3
max_features="sqrt"n_feat = int(np.sqrt(d)) with feats = rng.choice(d, size=n_feat, replace=False) - 4
each base estimator is a CART treeDecisionTreeClassifier(max_depth=..., random_state=...) — both paths call sklearn's tree underneath - 5
rf.predict(X_test)the votes matrix tallying each tree's class, then votes.argmax(axis=1) - 6
rf.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_foreststhe spine of this chapter, full implementations of every method.
13-fastbook/09_tabularfast.ai's deep dive on tabular ML, including the XGBoost-vs-deep-learning debate.
02-code-refs/amidi-cs229-ml-tipsAmidi'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-introopen-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
- 02-code-refs/amidi-cs229-supervised
- 02-code-refs/amidi-cs229-ml-tips
- 03-curricula/mlcourse-topic03-intro
- 04-stanford/cs229-main-notes-pdf
- 05-safety/anthropic-research-core-views-on-ai-safety
- 05-safety/huyenchip-index
- 08-geron-notebooks/07_ensemble_learning_and_random_forests
- 13-fastbook/09_tabular
- 16-d2l-sections/chapter_optimization (gradient descent background)
- 23-textbooks/math4ml
- 24-founder-blogs/eugeneyan-*