Bagging is covered in section 8.2 of An Introduction to Statistical Learning. The method
comes from Leo Breiman’s 1996 paper
“Bagging Predictors”. The name is short for bootstrap aggregating.
The bias and variance post ended with a problem: a deep
tree has almost no bias but huge variance. Its answer depends heavily on which data it happened to
see. Bagging fixes that by averaging many trees.
The idea: ask a committee
If you ask one risk analyst how volatile next month will be, you get their answer, quirks and
all. Ask twenty analysts who each learned from a slightly different few years of history, and take
the average: their individual quirks partly cancel out, and what’s left is closer to the real
pattern.
Bagging does this with models:
Make many bootstrap samples of the training data.
Train one model (usually a deep decision tree) on each.
Average their predictions. For classification, average their probabilities, or take a vote.
Step 1: the bootstrap sample
A bootstrap sample is drawn from the training data with replacement, and it has the same
size as the original. Some rows get picked more than once, some not at all.
Eight training rows, numbered 1 to 8, and three bootstrap samples of them:
Sample
Rows drawn
Left out
1
1, 3, 7, 7, 7, 7, 8, 8
2, 4, 5, 6
2
1, 1, 2, 5, 5, 7, 7, 8
3, 4, 6
3
2, 3, 4, 7, 7, 7, 8, 8
1, 5, 6
Each tree sees a different version of the history, so each tree comes out different.
How many rows does a bootstrap sample leave out? The chance that a given row is not picked in
one draw is 1−1/n. Over n draws:
(1−n1)n⟶e1≈0.368as n grows
So each tree never sees about 37% of the data, and uses about two-thirds of it (ISLP section
8.2.1, Murphy section 18.3). With 10,000 rows, I measured 36.7%. Those
left-out rows are called out-of-bag rows, and they’re useful: each tree can be tested on the
rows it never saw, which gives a free estimate of performance without a separate test set.
Step 3: why averaging reduces variance
An Introduction to Statistical Learning (section 8.2.1) starts from a simple fact: averaging
nindependent numbers, each with variance σ2, gives an average with variance
σ2/n. Bagged trees aren’t independent, though: they learn from overlapping data. If you
average B predictions that each have variance σ2, and every pair has correlation ρ,
the average has variance:
ρ, how alike the trees' errors areσ², one tree's variance
Var(average)=ρσ2+B1−ρσ2
Symbol
Meaning
B
number of models averaged
σ2
variance of one model’s prediction
ρ
correlation between two models’ predictions
Plain meaning: the second term shrinks as you add models. The first term doesn’t. If the models
were completely independent (ρ=0), averaging 100 of them would cut variance 100-fold. But
trees trained on overlapping data make similar mistakes (ρ>0), so variance falls towards a
floor of ρσ2 and stops. ISLP (section 8.2.2) says the same in words: averaging
highly correlated quantities does not reduce variance as much as averaging uncorrelated ones.
(It’s the same maths as the diversification floor in a
portfolio of correlated assets.)
Where the formula comes from
The variance of a sum is the sum of every variance plus every covariance between pairs. B models
each have variance σ2, and each of the B(B−1) ordered pairs has covariance
ρσ2. Dividing the sum by B divides its variance by B2:
Same made-up problem as the bias and variance post: predict next month’s volatility from this
month’s, on a curve I invented so the truth is known. Same method: 200 fresh training samples of
80 made-up months, measuring how much the predictions move between samples.
Trees averaged
Bias²
Variance
Expected error on new months
1 (one deep tree)
0.04
8.88
17.93
2
0.03
6.69
15.72
5
0.02
5.23
14.26
10
0.03
4.71
13.74
25
0.03
4.44
13.47
100
0.02
4.20
13.22
Bias stays near zero. Averaging deep trees doesn’t make them too simple.
Variance halves, from 8.9 to 4.2, most of it by the first 10 trees.
Then it levels off. That’s the ρσ2 floor. From these numbers, the trees’ predictions
are correlated at roughly ρ≈0.47 (my estimate: 4.2/8.9, ignoring the small
leftover term).
On this small, simple problem, the best single tree from the bias and variance post
(depth 3, error 11.8) still beats 100 bagged deep trees (13.2). Bagging removes variance. It
doesn’t remove the need to choose a sensible model.
On the stress-month data
Now the classification problem from the decision trees post:
will next month be a stress month? Scores are AUC on the 2,405 test rows of 2022 and 2023, one
per ticker per trading day (0.5 is a coin flip, 1.0 is perfect):
Base tree
One tree
Bag of 200
Change
Fully grown
0.615
0.788
big gain
Depth 8
0.758
0.817
good gain
Depth 5
0.797
0.823
small gain
Depth 3
0.804
0.828
small gain
This table says most of what there is to say about bagging:
It helps a lot when the base model is unstable (high variance), like a fully grown tree.
It helps less when the model is already stable. A depth-3 tree changes little between
bootstrap samples, so there’s less noise to average away; it still gains about 0.02 of AUC here,
because real market data is noisier than my toy curve.
And a warning about the out-of-bag estimate. From the training data alone, the bag of fully grown
trees reported an AUC of 0.907. Its real test score was 0.788. The estimate is far too
rosy, and the reason is the data, not the method: two rows a day apart are computed from almost
the same month of prices, so a row left out of a bootstrap sample has near-twins inside it, and
“never seen” isn’t really true. On a time series, the only honest test is forward in time. The out-of-bag score is free and useful when rows are independent. Here it would have fooled
me.
Three more experiments
On the regression version of the problem, predicting next month’s volatility as a number, the
same ideas look like this.
How many trees. Fully grown trees, averaged, scored by RMSE (the typical size of a miss;
lower is better) on the 2022 to 2023 rows:
Trees averaged
1
5
10
20
50
100
Test RMSE
15.87
11.93
11.24
10.61
10.19
10.23
The first twenty trees take a third off the error; the next eighty take nothing. That levelling
off is the ρσ2 floor from the formula above, measured.
Random splits. A tree that picks each cut point at random instead of searching for the
best one is weaker alone and far cheaper to build: 5.9 milliseconds against 16.5 for a fully
grown tree here. On this noisy target it is also, oddly, better once leaves hold a few rows
(a test RMSE of 10.24 against 12.01 at ten rows per leaf), because the search was finding cuts
that fit the training noise. Twenty bagged random trees scored 9.80 against 10.61 for twenty
bagged searching trees. Randomness acts as regularisation (a brake that stops a model fitting
noise), and it is the trade that random forests and “extra trees” (extremely randomised trees,
which pick their cut points at random as here) are built on.
Bagging a stable model. Bag twenty linear regressions and you get the single regression’s
answer back, to two decimal places. A straight line barely moves between resamples of the same
data, so there is nothing to average away. Bagging helps unstable learners, deep trees above
all, and does nothing for stable ones.
The code
All three ideas on made-up data, so it runs as pasted (pip install numpy scikit-learn):
one bootstrap sample, deep trees bagged on the made-up volatility curve from the
bias and variance post, and a straight line bagged for comparison.
X_train holds this month’s volatility as a one-column table, y_train next month’s.
import numpy as npfrom sklearn.ensemble import BaggingRegressorfrom sklearn.linear_model import LinearRegressionfrom sklearn.metrics import root_mean_squared_errorfrom sklearn.tree import DecisionTreeRegressorrng = np.random.default_rng(0)# 1. One bootstrap sample: draw 8 rows, with replacement, from rows 1 to 8rows = np.arange(1, 9)sample = rng.choice(rows, size=8, replace=True)print("drawn:", np.sort(sample), " left out:", np.setdiff1d(rows, sample))n = 10_000 # the same on 10,000 rowsleft_out = 1 - len(np.unique(rng.choice(n, size=n))) / nprint("share of rows left out:", round(left_out, 3)) # about 0.37# 2. Bagging deep trees on a made-up curve (next month's volatility from this month's)def true_curve(x): return 6 + 0.45 * x + 8 / (1 + np.exp(-(x - 25) / 3))x_train = rng.uniform(5, 45, 300)y_train = true_curve(x_train) + rng.normal(0, 3, 300)x_test = rng.uniform(5, 45, 1000)y_test = true_curve(x_test) + rng.normal(0, 3, 1000)X_train, X_test = x_train.reshape(-1, 1), x_test.reshape(-1, 1) # tables of one columnfor B in (1, 10, 100): # how many trees are averaged bag = BaggingRegressor(DecisionTreeRegressor(), n_estimators=B, random_state=0) bag.fit(X_train, y_train) # each tree gets its own bootstrap sample print(f"{B:3} deep trees: test RMSE {root_mean_squared_error(y_test, bag.predict(X_test)):.2f}")# 3. Bagging a stable model: twenty straight lines average back to one lineline = LinearRegression().fit(X_train, y_train)bagged = BaggingRegressor(LinearRegression(), n_estimators=20, random_state=0).fit(X_train, y_train)print("one line:", round(root_mean_squared_error(y_test, line.predict(X_test)), 2), " twenty bagged lines:", round(root_mean_squared_error(y_test, bagged.predict(X_test)), 2))
It prints one bootstrap sample (some rows several times, a few left out), then a left-out share
of about 0.37. Then the averaging: one deep tree misses by about 4.7, ten trees by about 3.85, a
hundred by about 3.8, the fast fall and then the floor from the table above. The noise added to
y has a spread of 3, so 3 is the best any model can do. Last, the single line and twenty bagged
lines score the same 3.37: nothing to average away.
Hastie, Tibshirani and Friedman, The Elements of Statistical Learning (2nd ed., Springer, 2009), equation 15.1: the variance of an average of correlated trees, derived in the fold above
Leo Breiman, “Bagging Predictors”, Machine Learning 24(2), 123–140, 1996