AI Knowledge Tree
C7 · Ensembles · lesson 1

Why ensembles work

Show how bagging reduces variance and boosting reduces bias, and see correlated errors as the failure mode.

Bagging and boosting work for different reasons. Bagging lowers the variance of a high-variance learner by averaging across data perturbations. Boosting lowers the bias of a high-bias learner by sequentially fitting residuals. The common failure mode for both is correlated errors: if every base model makes the same mistake, no amount of averaging will correct it[1, 2].

The bias-variance reading

Recall the decomposition for squared loss at a test point: expected error = bias2^2 + variance + noise.

Bagging. Averaging BB identically distributed predictors with pairwise correlation ρ\rho has variance[3]

ρ σ2+1−ρB σ2.\rho\,\sigma^2 + \frac{1 - \rho}{B}\,\sigma^2.

As B→∞B \to \infty, the second term vanishes and only ρσ2\rho\sigma^2 remains. Bagging works to the extent that:

  1. Each base model has substantial variance to begin with (high-variance base learners like deep trees benefit most).
  2. The base models are weakly correlated. Random forests reduce ρ\rho with random feature subsets; bagging on small datasets reduces it through bootstrap variation.

Bagging cannot reduce bias: an underfitting base model still underfits when averaged.

Boosting. Sequential boosting fits each new learner to the residuals (gradient) of the current ensemble. The training loss decreases at every step, driving down bias. Variance does not benefit (each round depends on the previous, so noise compounds rather than cancels). To prevent variance from growing, boosting uses weak learners (small bias contribution each) and a small learning rate (small step).

Correlated errors: the universal failure

Both methods fall apart when base models make the same mistakes:

  • Bagging on deep trees with one dominant feature: every tree splits on it first, so they all agree on the dominant axis. The variance reduction on the dominant axis is small.
  • Boosting on systematically mislabeled data: each round chases the same noise pattern.
  • Stacking highly correlated base models: the meta-learner has no signal from any one model that the others lack.

The remedy is always diversity: different feature subsets (random forests), different model classes (stacking heterogeneous learners), different algorithms, different data subsets.

When ensembles do not help

Diminishing returns set in once:

  • The base model is already low-variance (e.g. linear regression on a well-conditioned problem). Bagging adds noise without removing much.
  • The base model is already low-bias (e.g. an unregularized deep network on a small problem). Boosting overfits without reducing bias.

A useful rule: ensembles improve a learner against its own failure mode. Use bagging on high-variance learners, boosting on high-bias ones, and stacking when you have multiple learners with complementary strengths.

Practical recipe

The standard tabular pipeline:

  1. Random forest (bagged decorrelated trees) as a fast baseline. Good default; often surprisingly competitive.
  2. Gradient boosting (XGBoost, LightGBM, CatBoost). Usually best single-model performance on tabular data.
  3. Stacking (next lesson) when accuracy must be squeezed further by combining heterogeneous models.
Averaging B decorrelated models cuts prediction variance roughly like 1/B without adding bias. This is the core reason bagging works.
Averaging B decorrelated models cuts prediction variance roughly like 1/B without adding bias. This is the core reason bagging works.

Check it on arrays

python · numpy
import numpy as np

rng = np.random.default_rng(0)

# Toy regression: y = sin(x) + noise
def make_data(n=50, sigma=0.3, rng=rng):
    x = rng.uniform(0, 1, n)
    y = np.sin(2 * np.pi * x) + sigma * rng.normal(size=n)
    return x, y

# High-variance base learner: deep polynomial fit (overfit)
def fit_poly(x, y, degree):
    return np.polyfit(x, y, degree)

def predict_poly(coefs, x):
    return np.polyval(coefs, x)

x_test = np.linspace(0, 1, 100)
f_true = np.sin(2 * np.pi * x_test)

# Bias-variance of a single deep polynomial across many random datasets
single_preds = []
for t in range(200):
    x, y = make_data(rng=np.random.default_rng(1000 + t))
    coefs = fit_poly(x, y, degree=10)
    single_preds.append(predict_poly(coefs, x_test))
single_preds = np.array(single_preds)

bias_single = np.mean((single_preds.mean(axis=0) - f_true)**2)
var_single = np.mean(single_preds.var(axis=0))
print(f"single deep polynomial: bias^2 = {bias_single:.3f}, variance = {var_single:.3f}")

# Bagging: average over B bootstrap samples per dataset
B = 30
bagged_preds = []
for t in range(200):
    x, y = make_data(rng=np.random.default_rng(1000 + t))
    rng_in = np.random.default_rng(2000 + t)
    boot_preds = []
    for _ in range(B):
        idx = rng_in.integers(0, len(x), len(x))
        coefs = fit_poly(x[idx], y[idx], degree=10)
        boot_preds.append(predict_poly(coefs, x_test))
    bagged_preds.append(np.mean(boot_preds, axis=0))
bagged_preds = np.array(bagged_preds)

bias_bag = np.mean((bagged_preds.mean(axis=0) - f_true)**2)
var_bag = np.mean(bagged_preds.var(axis=0))
print(f"bagged deep polynomial: bias^2 = {bias_bag:.3f}, variance = {var_bag:.3f}  (variance ↓, bias same)")

# Boosting must use a weak learner whose model class genuinely expands when
# combined. Stumps (one-split piecewise constants) work; a sum of degree-1
# polynomials is still degree 1, so polynomial boosting cannot reduce bias.
def fit_stump(x, y):
    order = np.argsort(x); xs, ys = x[order], y[order]
    cum = np.cumsum(ys); n = len(ys); total = cum[-1]
    best = (None, None, None, np.inf)
    for k in range(1, n):
        thr = (xs[k - 1] + xs[k]) / 2
        sum_l, sum_r = cum[k - 1], total - cum[k - 1]
        mean_l, mean_r = sum_l / k, sum_r / (n - k)
        sse = np.sum((ys[:k] - mean_l)**2) + np.sum((ys[k:] - mean_r)**2)
        if sse < best[3]: best = (thr, mean_l, mean_r, sse)
    return best[:3]

def predict_stumps(stumps, x):
    F = np.zeros_like(x)
    for eta, (thr, ml, mr) in stumps:
        F = F + eta * np.where(x < thr, ml, mr)
    return F

def stagewise_fit_stumps(x, y, n_rounds=40, eta=0.3):
    F = np.zeros_like(y); stumps = []
    for _ in range(n_rounds):
        thr, ml, mr = fit_stump(x, y - F)
        stumps.append((eta, (thr, ml, mr)))
        F = F + eta * np.where(x < thr, ml, mr)
    return stumps

boost_preds = []
for t in range(200):
    x, y = make_data(rng=np.random.default_rng(1000 + t))
    stumps = stagewise_fit_stumps(x, y, n_rounds=40, eta=0.3)
    boost_preds.append(predict_stumps(stumps, x_test))
boost_preds = np.array(boost_preds)

bias_boost = np.mean((boost_preds.mean(axis=0) - f_true)**2)
var_boost = np.mean(boost_preds.var(axis=0))

# Bias of a single weak learner (one stump): much higher, so we can show the drop.
single_stump_preds = []
for t in range(200):
    x, y = make_data(rng=np.random.default_rng(1000 + t))
    thr, ml, mr = fit_stump(x, y)
    single_stump_preds.append(np.where(x_test < thr, ml, mr))
single_stump_preds = np.array(single_stump_preds)
bias_single_stump = np.mean((single_stump_preds.mean(axis=0) - f_true)**2)

print(f"single stump:    bias^2 = {bias_single_stump:.3f}")
print(f"boosted stumps:  bias^2 = {bias_boost:.3f}, variance = {var_boost:.3f}  (bias ↓ vs single stump)")

Bagging the deep polynomial cuts variance while leaving bias roughly unchanged; boosting stumps drops the bias far below a single stump because each round refits the residual with a fresh split.

Exercises

Exercise 1. Using the bagging variance formula ρσ2+1−ρBσ2\rho\sigma^2 + \frac{1-\rho}{B}\sigma^2, compute the variance of the averaged predictor for σ2=4\sigma^2 = 4, B=25B = 25, at ρ=0\rho = 0 and at ρ=0.5\rho = 0.5. What fraction of the original σ2\sigma^2 remains in each case, and what does the comparison say about decorrelation?

Solution

Step 1. At ρ=0\rho = 0 the first term drops and the formula is 1Bσ2=425=0.16\frac{1}{B}\sigma^2 = \frac{4}{25} = 0.16, which is 0.16/4=4%0.16/4 = 4\% of the original variance.

Step 2. At ρ=0.5\rho = 0.5:

0.5⋅4+1−0.525⋅4=2+0.08=2.08,0.5 \cdot 4 + \frac{1 - 0.5}{25} \cdot 4 = 2 + 0.08 = 2.08,

which is 2.08/4=52%2.08/4 = 52\% of the original variance.

Step 3. With independent models (ρ=0\rho = 0) averaging removes almost all the variance; with ρ=0.5\rho = 0.5 more than half survives, and no amount of extra trees removes the ρσ2=2\rho\sigma^2 = 2 floor. Decorrelation, not the number of models, is what unlocks variance reduction: this is the entire point of the random feature subsets in a random forest.

Exercise 2. Derive the bagging variance formula. Let f^1,…,f^B\hat f_1, \dots, \hat f_B be identically distributed predictors, each with variance σ2\sigma^2 and pairwise correlation ρ\rho. Show that the average fˉ=1B∑bf^b\bar f = \frac{1}{B}\sum_b \hat f_b has variance ρσ2+1−ρBσ2\rho\sigma^2 + \frac{1-\rho}{B}\sigma^2.

Solution

Step 1. Expand the variance of the average into its own and cross terms:

Var⁡(fˉ)=1B2(∑bVar⁡(f^b)+∑i≠jCov⁡(f^i,f^j)).\operatorname{Var}(\bar f) = \frac{1}{B^2}\left(\sum_b \operatorname{Var}(\hat f_b) + \sum_{i \ne j}\operatorname{Cov}(\hat f_i, \hat f_j)\right).

Step 2. Each variance is σ2\sigma^2 (BB of them) and each covariance is ρσ2\rho\sigma^2 (there are B(B−1)B(B-1) ordered off-diagonal pairs):

Var⁡(fˉ)=1B2(Bσ2+B(B−1)ρσ2).\operatorname{Var}(\bar f) = \frac{1}{B^2}\left(B\sigma^2 + B(B-1)\rho\sigma^2\right).

Step 3. Divide through by B2B^2 and regroup:

Var⁡(fˉ)=σ2B+(B−1)ρσ2B=ρσ2+1−ρBσ2,\operatorname{Var}(\bar f) = \frac{\sigma^2}{B} + \frac{(B-1)\rho\sigma^2}{B} = \rho\sigma^2 + \frac{1-\rho}{B}\sigma^2,

using B−1B=1−1B\frac{B-1}{B} = 1 - \frac{1}{B}. As B→∞B \to \infty the second term vanishes and the irreducible floor ρσ2\rho\sigma^2 remains.

Exercise 3 (code). Simulate BB identically distributed predictors with pairwise correlation ρ\rho (build them from a shared common factor) and verify empirically that the variance of their average matches the formula at several (ρ,B)(\rho, B) pairs, and that it approaches the ρσ2\rho\sigma^2 floor as BB grows.

Solution
python · numpy
import numpy as np

rng = np.random.default_rng(0)

# X_b = sqrt(rho)*Z + sqrt(1-rho)*E_b, shared factor Z and private noise E_b,
# scaled by sigma. Then Var(X_b) = sigma^2 and Corr(X_i, X_j) = rho for i != j.
def correlated_predictors(B, rho, sigma, n_samples, rng):
    Z = rng.normal(size=(n_samples, 1))
    E = rng.normal(size=(n_samples, B))
    return (np.sqrt(rho) * Z + np.sqrt(1 - rho) * E) * sigma

sigma = 2.0
n_samples = 400_000
for rho, B in [(0.0, 10), (0.3, 10), (0.3, 50), (0.8, 50)]:
    X = correlated_predictors(B, rho, sigma, n_samples, rng)
    empirical = X.mean(axis=1).var()
    formula = rho * sigma**2 + (1 - rho) / B * sigma**2
    print(f"rho={rho}, B={B}: empirical={empirical:.4f}, formula={formula:.4f}")
    assert np.isclose(empirical, formula, rtol=0.03)

# As B grows with rho fixed, the average's variance approaches rho*sigma^2.
X_big = correlated_predictors(2000, 0.3, sigma, n_samples, rng)
floor = 0.3 * sigma**2
assert abs(X_big.mean(axis=1).var() - floor) < 0.05
print(f"large-B variance {X_big.mean(axis=1).var():.4f} -> floor {floor:.4f}")

The empirical variance tracks the formula to within a few percent at every (ρ,B)(\rho, B), and at B=2000B = 2000 with ρ=0.3\rho = 0.3 it sits near the floor ρσ2=1.2\rho\sigma^2 = 1.2, confirming that correlation, not ensemble size, sets the limit of variance reduction.

The next lesson combines multiple learners explicitly via stacking.


References

  1. [1]Dietterich, T. G. (2000). "Ensemble methods in machine learning". Multiple Classifier Systems (MCS). Surveys why ensembles outperform single learners (doi:10.1007/3-540-45014-9_1).↩
  2. [2]Hansen, L. K., & Salamon, P. (1990). "Neural network ensembles". IEEE TPAMI, 12(10), 993-1001. Shows averaging independent classifiers reduces error when individual errors are uncorrelated (doi:10.1109/34.58871).↩
  3. [3]Breiman, L. (1996). "Bagging predictors". Machine Learning, 24(2), 123-140. Introduces bootstrap aggregation and the variance-reduction argument (doi:10.1007/BF00058655).↩