06 Ensemble Methods — First Principles¶
Status: ✅ Complete | Phase: 2 | Prerequisites: 05 Decision Tree, 02 Gradient Descent
Goal. Build a Random Forest classifier and a Gradient Boosting regressor from
scratch using NumPy — bootstrap sampling, random feature subsets, sequential
residual fitting — then compare with the src/ library and sklearn, and
demonstrate failure modes.
Prerequisites¶
- See theory.md for derivations (variance reduction, AdaBoost weights, gradient boosting as functional gradient descent, OOB error)
- Required topics:
import random
import matplotlib.pyplot as plt
import numpy as np
%load_ext autoreload
%autoreload 2
SEED = 42
random.seed(SEED)
rng = np.random.default_rng(SEED)
plt.rcParams["figure.dpi"] = 90
1. Problem Setup — WHY¶
A single decision tree is unstable: small perturbations in the training data produce very different trees (topic 05, §8.2). Let's see this directly by training trees on bootstrap samples and observing how much their decision boundaries differ.
from ml_first_principles.data_utils import generate_classification_data
# Generate a 2-class dataset
X_all, y_all = generate_classification_data(
n_samples=300, n_features=2, n_classes=2, random_state=42
)
# Train 4 trees on bootstrap samples — observe instability
from ml_first_principles.tree_models import DecisionTreeClassifier
fig, axes = plt.subplots(2, 2, figsize=(11, 10))
for idx, ax in enumerate(axes.ravel()):
boot = rng.choice(len(y_all), size=len(y_all), replace=True)
tree = DecisionTreeClassifier(max_depth=None, random_state=idx)
tree.fit(X_all[boot], y_all[boot])
acc = tree.score(X_all, y_all)
# Plot decision boundary
x_min, x_max = X_all[:, 0].min() - 1, X_all[:, 0].max() + 1
y_min, y_max = X_all[:, 1].min() - 1, X_all[:, 1].max() + 1
xx, yy = np.meshgrid(
np.linspace(x_min, x_max, 200), np.linspace(y_min, y_max, 200)
)
Z = tree.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
ax.contourf(xx, yy, Z, alpha=0.3, cmap="RdBu")
for c in np.unique(y_all):
mask = y_all == c
ax.scatter(X_all[mask, 0], X_all[mask, 1], alpha=0.5,
label=f"class {c}", edgecolor="k", s=15)
ax.set_xlabel("$x_1$")
ax.set_ylabel("$x_2$")
ax.set_title(f"Bootstrap {idx+1} (acc={acc:.2f})")
ax.legend(fontsize=8)
plt.suptitle("Single trees on different bootstrap samples → different boundaries",
fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
print("Each bootstrap sample yields a visibly different decision boundary.")
print("Ensembles fix this by averaging many such trees.")
Each bootstrap sample yields a visibly different decision boundary. Ensembles fix this by averaging many such trees.
2. Mathematical Core — WHAT¶
Variance reduction by averaging¶
If $M$ predictors have equal variance $\sigma^2$ and pairwise correlation $\rho$, the variance of their average is:
$$\text{Var}(H) = \rho\sigma^2 + \frac{1-\rho}{M}\sigma^2$$
- Uncorrelated ($\rho=0$): $\text{Var} = \sigma^2/M$ — ideal $1/M$ shrinkage.
- Identical ($\rho=1$): $\text{Var} = \sigma^2$ — no benefit.
- Random forest reduces $\rho$ via random feature subsets at each split.
Gradient boosting¶
For squared loss, the pseudo-residual is $r_{im} = y_i - F_{m-1}(x_i)$. Each new tree fits these residuals, and the ensemble updates: $F_m(x) = F_{m-1}(x) + \eta \cdot h_m(x)$.
See theory.md for the full derivation.
# Demonstrate the variance reduction formula numerically
M_values = np.arange(1, 101)
sigma2 = 1.0
fig, ax = plt.subplots(figsize=(8, 4.5))
for rho in [0.0, 0.1, 0.3, 0.5, 0.8, 1.0]:
var = rho * sigma2 + (1 - rho) / M_values * sigma2
ax.plot(M_values, var, label=f"$\\rho={rho}$", lw=2)
ax.set_xlabel("Number of predictors $M$")
ax.set_ylabel("Variance of average")
ax.set_title("Variance reduction by averaging: effect of correlation $\\rho$")
ax.legend()
ax.set_ylim(0, 1.05)
plt.show()
print("Lower correlation → more variance reduction. RF reduces ρ via feature subsampling.")
Lower correlation → more variance reduction. RF reduces ρ via feature subsampling.
3. Solution Method — HOW¶
Random Forest algorithm¶
- For $m = 1, \dots, M$: draw bootstrap sample, grow tree with random $m_{\text{try}} = \lfloor\sqrt{d}\rfloor$ features at each split.
- Predict by majority vote.
Gradient Boosting algorithm (squared loss)¶
- Initialize $F_0 = \bar{y}$ (mean of targets).
- For $m = 1, \dots, M$: compute residuals $r_i = y_i - F_{m-1}(x_i)$, fit a shallow regression tree to $\{(x_i, r_i)\}$, update $F_m = F_{m-1} + \eta \cdot h_m$.
class ScratchDecisionTreeRegressor:
"""Minimal regression tree using MSE splits."""
def __init__(self, max_depth=3, min_samples_split=2):
self.max_depth = max_depth
self.min_samples_split = min_samples_split
self.tree_ = None
def _mse(self, y):
if len(y) == 0:
return 0.0
return float(np.var(y))
def _best_split(self, X, y):
n_samples, n_features = X.shape
best_gain = -1.0
best_feat, best_thresh = None, None
parent_mse = self._mse(y)
for feat in range(n_features):
order = np.argsort(X[:, feat], kind="mergesort")
sorted_vals = X[order, feat]
sorted_y = y[order]
# Incremental computation of left/right means and MSE
left_sum, left_sq_sum = 0.0, 0.0
total_sum = float(sorted_y.sum())
total_sq_sum = float((sorted_y ** 2).sum())
for i in range(1, n_samples):
val = float(sorted_y[i - 1])
left_sum += val
left_sq_sum += val * val
if sorted_vals[i] == sorted_vals[i - 1]:
continue
n_l, n_r = i, n_samples - i
right_sum = total_sum - left_sum
right_sq_sum = total_sq_sum - left_sq_sum
mse_l = left_sq_sum / n_l - (left_sum / n_l) ** 2
mse_r = right_sq_sum / n_r - (right_sum / n_r) ** 2
weighted = (n_l * mse_l + n_r * mse_r) / n_samples
gain = parent_mse - weighted
if gain > best_gain + 1e-15:
best_gain = gain
best_feat = feat
best_thresh = float((sorted_vals[i] + sorted_vals[i - 1]) / 2)
return best_feat, best_thresh
def _build(self, X, y, depth):
node = {"value": float(np.mean(y))}
at_limit = self.max_depth is not None and depth >= self.max_depth
if at_limit or len(y) < self.min_samples_split or np.all(y == y[0]):
return node
feat, thresh = self._best_split(X, y)
if feat is None:
return node
mask = X[:, feat] < thresh
node["feature"] = feat
node["threshold"] = thresh
node["left"] = self._build(X[mask], y[mask], depth + 1)
node["right"] = self._build(X[~mask], y[~mask], depth + 1)
return node
def fit(self, X, y):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)
self.tree_ = self._build(X, y, depth=0)
return self
def _predict_one(self, x, node):
while "threshold" in node:
if x[node["feature"]] < node["threshold"]:
node = node["left"]
else:
node = node["right"]
return node["value"]
def predict(self, X):
X = np.asarray(X, dtype=float)
return np.array([self._predict_one(x, self.tree_) for x in X])
# Quick sanity check
X_reg = rng.standard_normal((50, 2))
y_reg = 3.0 * X_reg[:, 0] - 2.0 * X_reg[:, 1] + rng.normal(0, 0.5, 50)
tree_reg = ScratchDecisionTreeRegressor(max_depth=4)
tree_reg.fit(X_reg, y_reg)
preds_reg = tree_reg.predict(X_reg)
train_mse = float(np.mean((y_reg - preds_reg) ** 2))
print(f"Regression tree train MSE: {train_mse:.4f}")
assert train_mse < 1.0, "Regression tree should fit training data reasonably"
Regression tree train MSE: 0.7005
4.2 From-scratch Random Forest Classifier¶
class ScratchRandomForestClassifier:
"""Random forest: bagging + random feature subsets.
Parameters
----------
n_estimators : int
Number of trees.
max_depth : int or None
Maximum tree depth.
max_features : str or int
Number of features to consider at each split.
'sqrt' → floor(sqrt(d)), int → exact count.
random_state : int or None
Seed for reproducibility.
"""
def __init__(self, n_estimators=100, max_depth=None, max_features="sqrt",
random_state=None):
self.n_estimators = n_estimators
self.max_depth = max_depth
self.max_features = max_features
self.random_state = random_state
self.trees_ = []
self.oob_indices_ = [] # track OOB samples for each tree
self.classes_ = None
def _feature_count(self, n_features):
if self.max_features == "sqrt":
return max(1, int(np.sqrt(n_features)))
if isinstance(self.max_features, int):
return min(self.max_features, n_features)
return n_features
def fit(self, X, y):
X = np.asarray(X, dtype=float)
y = np.asarray(y)
n_samples = X.shape[0]
self.classes_ = np.unique(y)
feat_count = self._feature_count(X.shape[1])
rng_local = np.random.default_rng(self.random_state)
self.trees_ = []
self.oob_indices_ = []
for _ in range(self.n_estimators):
# Bootstrap sample
boot = rng_local.integers(0, n_samples, size=n_samples)
oob_mask = np.ones(n_samples, dtype=bool)
oob_mask[boot] = False
self.oob_indices_.append(np.where(oob_mask)[0])
# Train tree with random feature subsets
seed = int(rng_local.integers(0, np.iinfo(np.int32).max))
tree = DecisionTreeClassifier(
max_depth=self.max_depth,
max_features=feat_count,
random_state=seed,
)
tree.fit(X[boot], y[boot])
self.trees_.append(tree)
return self
def predict(self, X):
X = np.asarray(X, dtype=float)
predictions = np.array([tree.predict(X) for tree in self.trees_])
# Majority vote
result = []
for col in predictions.T:
counts = np.array([np.sum(col == c) for c in self.classes_])
result.append(self.classes_[np.argmax(counts)])
return np.array(result)
def score(self, X, y):
return float(np.mean(self.predict(X) == np.asarray(y)))
def oob_score(self, X, y):
"""Compute out-of-bag accuracy."""
X = np.asarray(X, dtype=float)
y = np.asarray(y)
n_samples = X.shape[0]
# For each sample, collect predictions from trees that didn't train on it
votes = {i: [] for i in range(n_samples)}
for m, tree in enumerate(self.trees_):
for i in self.oob_indices_[m]:
pred = tree.predict(X[i:i+1])[0]
votes[i].append(pred)
correct, total = 0, 0
for i in range(n_samples):
if len(votes[i]) == 0:
continue # no OOB prediction available
# Majority vote among OOB trees
preds = np.array(votes[i])
counts = np.array([np.sum(preds == c) for c in self.classes_])
oob_pred = self.classes_[np.argmax(counts)]
correct += int(oob_pred == y[i])
total += 1
return correct / total if total > 0 else 0.0
# Quick test
rf_scratch = ScratchRandomForestClassifier(
n_estimators=20, max_depth=5, random_state=42
)
rf_scratch.fit(X_all, y_all)
acc_rf = rf_scratch.score(X_all, y_all)
print(f"Scratch RF accuracy: {acc_rf:.4f}")
assert acc_rf > 0.90, f"RF should achieve high accuracy, got {acc_rf}"
Scratch RF accuracy: 1.0000
4.3 From-scratch Gradient Boosting Regressor¶
class ScratchGradientBoostingRegressor:
"""Gradient boosting for regression with squared loss.
Parameters
----------
n_estimators : int
Number of boosting rounds.
learning_rate : float
Shrinkage parameter η.
max_depth : int
Maximum depth of each regression tree.
"""
def __init__(self, n_estimators=100, learning_rate=0.1, max_depth=3):
self.n_estimators = n_estimators
self.learning_rate = learning_rate
self.max_depth = max_depth
self.trees_ = []
self.initial_prediction_ = None
self.train_losses_ = []
def fit(self, X, y):
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)
# Step 1: initialise with the mean
self.initial_prediction_ = float(np.mean(y))
F = np.full(len(y), self.initial_prediction_)
self.trees_ = []
self.train_losses_ = []
for _ in range(self.n_estimators):
# Step 2a: compute pseudo-residuals (negative gradient of MSE)
residuals = y - F
# Step 2b: fit a regression tree to the residuals
tree = ScratchDecisionTreeRegressor(max_depth=self.max_depth)
tree.fit(X, residuals)
self.trees_.append(tree)
# Step 2c: update ensemble
F += self.learning_rate * tree.predict(X)
# Track training loss
mse = float(np.mean((y - F) ** 2))
self.train_losses_.append(mse)
return self
def predict(self, X):
X = np.asarray(X, dtype=float)
F = np.full(X.shape[0], self.initial_prediction_)
for tree in self.trees_:
F += self.learning_rate * tree.predict(X)
return F
# Quick test on regression data
from ml_first_principles.data_utils import generate_regression_data
X_gbt, y_gbt = generate_regression_data(
n_samples=200, n_features=4, noise=1.0, random_state=42
)
gbt = ScratchGradientBoostingRegressor(
n_estimators=100, learning_rate=0.1, max_depth=3
)
gbt.fit(X_gbt, y_gbt)
gbt_preds = gbt.predict(X_gbt)
gbt_mse = float(np.mean((y_gbt - gbt_preds) ** 2))
print(f"GBT train MSE: {gbt_mse:.4f}")
assert gbt_mse < 1.0, f"GBT should fit well, got MSE={gbt_mse}"
# Plot training loss curve
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(range(1, len(gbt.train_losses_) + 1), gbt.train_losses_, lw=2)
ax.set_xlabel("Boosting round")
ax.set_ylabel("Train MSE")
ax.set_title("Gradient Boosting: training loss decreases with each round")
plt.show()
print("Each round reduces the training error by fitting residuals.")
GBT train MSE: 0.2780
Each round reduces the training error by fitting residuals.
from ml_first_principles.ensemble_models import RandomForestClassifier as LibRF
from ml_first_principles.metrics import accuracy
from ml_first_principles.data_utils import train_test_split
# Generate a 3-class dataset for comparison
X_cmp, y_cmp = generate_classification_data(
n_samples=400, n_features=6, n_classes=3, random_state=42
)
X_train, X_test, y_train, y_test = train_test_split(
X_cmp, y_cmp, test_size=0.25, random_state=42
)
# Train all three implementations
from sklearn.ensemble import RandomForestClassifier as SklearnRF
models_rf = {
"scratch": ScratchRandomForestClassifier(
n_estimators=50, max_depth=8, random_state=42
),
"library": LibRF(n_estimators=50, max_depth=8, random_state=42),
"sklearn": SklearnRF(n_estimators=50, max_depth=8, random_state=42),
}
print(f"{'model':<12} {'train_acc':>10} {'test_acc':>10}")
for name, model in models_rf.items():
model.fit(X_train, y_train)
train_acc = accuracy(y_train, model.predict(X_train))
test_acc = accuracy(y_test, model.predict(X_test))
print(f"{name:<12} {train_acc:>10.4f} {test_acc:>10.4f}")
# All should achieve reasonable accuracy
for name, model in models_rf.items():
ta = accuracy(y_test, model.predict(X_test))
assert ta > 0.80, f"{name} test accuracy too low: {ta}"
print("\n✓ All RF implementations achieve comparable accuracy!")
model train_acc test_acc
scratch 1.0000 1.0000
library 1.0000 1.0000 sklearn 1.0000 1.0000 ✓ All RF implementations achieve comparable accuracy!
5.2 Gradient Boosting — scratch vs sklearn¶
from sklearn.ensemble import GradientBoostingRegressor as SklearnGBT
from ml_first_principles.metrics import r2_score
# Split regression data
X_gbt_train, X_gbt_test, y_gbt_train, y_gbt_test = train_test_split(
X_gbt, y_gbt, test_size=0.25, random_state=42
)
# Scratch
gbt_scratch = ScratchGradientBoostingRegressor(
n_estimators=100, learning_rate=0.1, max_depth=3
)
gbt_scratch.fit(X_gbt_train, y_gbt_train)
r2_scratch = r2_score(y_gbt_test, gbt_scratch.predict(X_gbt_test))
# sklearn
gbt_sk = SklearnGBT(
n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42
)
gbt_sk.fit(X_gbt_train, y_gbt_train)
r2_sk = r2_score(y_gbt_test, gbt_sk.predict(X_gbt_test))
print(f"GBT R² — scratch: {r2_scratch:.4f}, sklearn: {r2_sk:.4f}")
assert r2_scratch > 0.5, f"Scratch GBT R² too low: {r2_scratch}"
print("✓ Both implementations achieve positive R² on test data.")
GBT R² — scratch: 0.9421, sklearn: 0.9426 ✓ Both implementations achieve positive R² on test data.
n_est_values = [1, 2, 5, 10, 20, 50, 100]
train_accs_rf, test_accs_rf = [], []
for n_est in n_est_values:
rf_n = ScratchRandomForestClassifier(
n_estimators=n_est, max_depth=8, random_state=42
)
rf_n.fit(X_train, y_train)
train_accs_rf.append(rf_n.score(X_train, y_train))
test_accs_rf.append(rf_n.score(X_test, y_test))
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(n_est_values, train_accs_rf, "o-", label="Train accuracy", markersize=5)
ax.plot(n_est_values, test_accs_rf, "s-", label="Test accuracy", markersize=5)
ax.set_xlabel("Number of trees ($M$)")
ax.set_ylabel("Accuracy")
ax.set_title("Random Forest: more trees → stable, higher accuracy")
ax.legend()
ax.set_xscale("log")
plt.show()
print(f"Test accuracy with 1 tree: {test_accs_rf[0]:.4f}")
print(f"Test accuracy with 100 trees: {test_accs_rf[-1]:.4f}")
print("More trees help, but with diminishing returns after ~50.")
Test accuracy with 1 tree: 0.9900 Test accuracy with 100 trees: 0.9900 More trees help, but with diminishing returns after ~50.
6.2 Out-of-Bag (OOB) error estimation¶
# Compute OOB score and compare with test accuracy
rf_oob = ScratchRandomForestClassifier(
n_estimators=100, max_depth=8, random_state=42
)
rf_oob.fit(X_train, y_train)
oob_acc = rf_oob.oob_score(X_train, y_train)
test_acc = rf_oob.score(X_test, y_test)
print(f"OOB accuracy: {oob_acc:.4f}")
print(f"Test accuracy: {test_acc:.4f}")
print(f"Difference: {abs(oob_acc - test_acc):.4f}")
print("\nOOB error ≈ test error — a free estimate of generalisation performance.")
OOB accuracy: 0.9967 Test accuracy: 0.9900 Difference: 0.0067 OOB error ≈ test error — a free estimate of generalisation performance.
6.3 Single tree vs Random Forest vs Gradient Boosting¶
# Compare on the same classification dataset
single_tree = DecisionTreeClassifier(max_depth=8, random_state=42)
single_tree.fit(X_train, y_train)
# GBT for classification via sklearn (our scratch GBT is for regression)
from sklearn.ensemble import GradientBoostingClassifier as SklearnGBC
gbt_clf = SklearnGBC(
n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42
)
gbt_clf.fit(X_train, y_train)
results = {
"Single tree": {
"train": accuracy(y_train, single_tree.predict(X_train)),
"test": accuracy(y_test, single_tree.predict(X_test)),
},
"Random Forest (100)": {
"train": rf_oob.score(X_train, y_train),
"test": rf_oob.score(X_test, y_test),
},
"GBT (sklearn)": {
"train": accuracy(y_train, gbt_clf.predict(X_train)),
"test": accuracy(y_test, gbt_clf.predict(X_test)),
},
}
print(f"{'Model':<22} {'Train Acc':>10} {'Test Acc':>10}")
print("-" * 44)
for name, accs in results.items():
print(f"{name:<22} {accs['train']:>10.4f} {accs['test']:>10.4f}")
print("\nEnsembles outperform a single tree, especially on test data.")
Model Train Acc Test Acc -------------------------------------------- Single tree 1.0000 0.9800 Random Forest (100) 1.0000 0.9900 GBT (sklearn) 1.0000 1.0000 Ensembles outperform a single tree, especially on test data.
6.4 Failure case: Boosting overfits noisy data¶
Boosting aggressively fits residuals. With noisy labels, it memorises the noise.
# Generate clean regression data, then add heavy noise
X_noisy, y_clean = generate_regression_data(
n_samples=200, n_features=4, noise=0.1, random_state=42
)
# Add large noise to 20% of labels
noise_idx = rng.choice(200, size=40, replace=False)
y_noisy = y_clean.copy()
y_noisy[noise_idx] += rng.normal(0, 20, size=40)
X_n_train, X_n_test, y_n_train, y_n_test = train_test_split(
X_noisy, y_noisy, test_size=0.25, random_state=42
)
# Track test MSE as boosting rounds increase
n_rounds = [5, 10, 20, 50, 100, 200, 500]
train_mses, test_mses = [], []
for n_r in n_rounds:
gbt_n = ScratchGradientBoostingRegressor(
n_estimators=n_r, learning_rate=0.1, max_depth=3
)
gbt_n.fit(X_n_train, y_n_train)
train_mses.append(float(np.mean((y_n_train - gbt_n.predict(X_n_train)) ** 2)))
test_mses.append(float(np.mean((y_n_test - gbt_n.predict(X_n_test)) ** 2)))
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(n_rounds, train_mses, "o-", label="Train MSE", markersize=5)
ax.plot(n_rounds, test_mses, "s-", label="Test MSE", markersize=5)
ax.set_xlabel("Number of boosting rounds")
ax.set_ylabel("MSE")
ax.set_title("Gradient Boosting on noisy data: overfitting with more rounds")
ax.set_xscale("log")
ax.legend()
plt.show()
print(f"Train MSE with 5 rounds: {train_mses[0]:.2f}")
print(f"Train MSE with 500 rounds: {train_mses[-1]:.2f}")
print(f"Test MSE with 5 rounds: {test_mses[0]:.2f}")
print(f"Test MSE with 500 rounds: {test_mses[-1]:.2f}")
print("\nTrain MSE → 0, but test MSE increases at high rounds.")
print("Boosting memorises noisy labels — early stopping is essential.")
Train MSE with 5 rounds: 65.05 Train MSE with 500 rounds: 0.00 Test MSE with 5 rounds: 128.69 Test MSE with 500 rounds: 103.91 Train MSE → 0, but test MSE increases at high rounds. Boosting memorises noisy labels — early stopping is essential.
7. Connections¶
| Topic | Link |
|---|---|
| Decision Tree (05) | Base learner — high-variance trees are the building block for RF |
| Gradient Descent (02) | GBT is gradient descent in function space; learning rate = step size |
| Bias–Variance (synthesis) | Bagging reduces variance; boosting reduces bias |
| Regularization (03) | Shrinkage (η < 1), early stopping, and max_depth all regularise boosting |
Takeaway¶
Ensemble methods combine many weak learners to build a strong one:
- Random Forest = bagging + random feature subsets → reduces variance.
- Gradient Boosting = sequential residual fitting → reduces bias.
Strengths: generally best-in-class for tabular data, robust to hyperparameters (RF), powerful with tuning (GBT). Weaknesses: loss of interpretability, boosting overfits noisy data, cannot extrapolate beyond training range.