S090 · Random Forest & Bagging
The most reliable tabular model ever built — average many high-variance trees and watch the variance evaporate. From Breiman's 2001 paper to today's Kaggle warhorse.
🎯 Understand bagging + random feature subsets, build a random forest from scratch, and know why it's the safest ‘first real model’ in industry.
Why this session exists
Random forest is the model you'd ship if you were only allowed one algorithm for the rest of your career. It works out of the box, handles messy data, needs almost no tuning, and gives you feature importances for free. Every senior data scientist has a random forest in their back pocket as the ‘first thing that actually works’ baseline. This session builds one from single trees so you understand exactly why bagging cuts variance and why adding randomness makes it stronger, not weaker.
- Explain bagging (bootstrap aggregating) and prove it reduces variance by 1/n.
- Explain why random forest adds feature subsetting on top of bagging.
- Build a random forest in ~80 lines using your S089 tree.
- Interpret out-of-bag (OOB) score as a free cross-validation estimate.
- Use feature importances responsibly (and know their pitfalls).
Prerequisites
- S089 · Decision trees — you can build a single tree.
- S088 · Bias-variance — you understand what variance reduction achieves.
- S040 · Probability basics — CLT, bootstrap sampling.
(a) Intuition · 5 min
You have a strange rash. You see one doctor: they might be right, might be wrong, might have a bad day. Now you see 100 doctors, each with slightly different training. Most say ‘eczema.’ A few say ‘psoriasis.’ One says ‘lupus.’ You go with the majority — you're much more likely to be right than trusting any single one.
For it to work, the doctors must be (a) individually competent — better than random — and (b) not all trained at the same school. Diverse errors cancel; correlated errors don't.
A random forest is the same. You grow 100+ decision trees, each on a different bootstrap sample of the data and considering a random subset of features at each split. Each tree is a slightly wrong doctor with high variance. The average is a very good doctor with low variance.
Bias is unchanged (averaging can't fix systematic error). But variance falls roughly like 1/n if the trees are independent, and by (ρ + (1-ρ)/n) if they're correlated with correlation ρ. Random feature subsets reduce ρ — that's the ‘random’ in random forest.
The three ideas to own before we build
- Bagging — train each tree on a bootstrap sample (n samples drawn with replacement from n). Each tree sees ~63 % unique data.
- Random feature subset — at each split, consider only √d (classification) or d/3 (regression) random features. Forces trees to be diverse.
- Ensemble — average predictions (regression) or majority vote (classification). OOB score = free CV using the ~37 % samples each tree didn't see.
A brief history
- 1994Bagging · Leo BreimanBootstrap AGGregatING. Grow many decision trees on bootstrap samples, average their outputs. Cuts variance without touching bias.
- 1995Random Subspace · HoTin Kam Ho at Bell Labs invents feature randomisation. Combined with bagging = random forest.
- 2001Random Forest · BreimanThe paper that names it. Dominates ML competitions for a decade.
- 2003ExtraTrees · GeurtsRandom forest + randomised thresholds. Even faster, sometimes better.
- 2014XGBoost overtakes for tabularGradient boosting beats random forest on structured Kaggle. RF stays the ‘safe baseline.’
- 2020sStill the industry standard baselineEvery production ML team has an RF baseline before shipping anything fancier.
(b) Visual walkthrough · 15 min
The bagging pipeline
Why bootstrap covers ~63 %
The 63 % magic number
Bagging vs Random Forest — one tweak makes a big difference
Bootstrap trees only
- Each tree sees bootstrap sample of rows.
- Each tree considers ALL features at every split.
- Trees are correlated — the same strong feature dominates in every tree.
- Variance reduction limited by tree correlation.
- Good, but not the best.
Bagging + random features
- Each tree sees bootstrap sample of rows.
- Each split considers only √d (or d/3) random features.
- Trees are decorrelated — different features get to shine in different trees.
- Lower correlation → more variance reduction from averaging.
- The industry standard tabular baseline.
RF + randomised thresholds
- Row sampling optional (default = no bootstrap in sklearn).
- Random feature subsets like RF.
- But: threshold picked RANDOMLY from feasible range, not optimally.
- Even more decorrelated → sometimes wins vs RF.
- Much faster to train (no exhaustive threshold search).
The math of variance reduction
Feature importance — the free bonus (with caveats)
Sum the impurity reduction each feature provides across all splits in all trees. Fast but biased toward high-cardinality features.
Permute one feature at a time in the validation set. Measure the drop in accuracy. Slower but honest — reflects actual predictive power.
Game-theoretic attribution. Each feature's contribution to each prediction. Gold standard for interpretability.
Especially with correlated features — importance is arbitrarily split between them. Use permutation or SHAP for real decisions.
"Random Forest works because you train many trees and average them — the averaging cancels their individual mistakes. Bootstrapping the rows is what makes them different from each other."
Bootstrapping alone is not enough. Trees fit on bootstrap samples of the same data are highly correlated — if one feature dominates, nearly every tree picks it at the root and they all make the same mistakes. Averaging correlated predictors barely reduces variance. The essential and often-forgotten ingredient is random feature subsampling at every split (max_features), which forces trees to be structurally different. That is the difference between plain bagging and a Random Forest.
The myth is sticky because "bagging" is literally bootstrap aggregating, so the bootstrap gets top billing in the name and the explanation. And bagging genuinely does help on its own — you see an improvement, so the story looks complete. What you never see is how much more you would have got from decorrelating the trees, because you never ran the ablation. The clue hides in plain sight: max_features='sqrt' is a default nobody questions, and it is doing at least as much work as the bootstrap.
Ablate the feature subsampling and watch the ensemble get worse while everything else stays identical:
from sklearn.ensemble import RandomForestClassifier
# real Random Forest: bootstrap + feature subsampling
rf = RandomForestClassifier(n_estimators=300, max_features='sqrt')
# plain bagged trees: bootstrap only, every split sees all features
bag = RandomForestClassifier(n_estimators=300, max_features=None)
print(rf.fit(Xtr, ytr).score(Xte, yte))
print(bag.fit(Xtr, ytr).score(Xte, yte))
# same trees, same bootstrap, same count.
# the gap is pure decorrelation.Why does averaging n models reduce variance by a factor far less than n, and why does adding more trees eventually stop helping entirely? The naive answer says variance divides by n and should approach zero. It doesn't, and the reason names the design.
- 1For n identically distributed predictors each with variance σ² and pairwise correlation ρ, the variance of their average is
ρσ² + (1−ρ)σ²/n. This is just the variance of a sum, keeping the covariance terms.forced by · Var(ΣXᵢ)/n² includes n variance terms and n(n−1) covariance terms, and the covariances do not vanish - 2The second term, (1−ρ)σ²/n, decays to zero as you add trees. The first term, ρσ², contains no n at all — it is a hard floor set entirely by how correlated the trees are.forced by · averaging cancels independent errors but cannot cancel errors the models share
- 3Therefore past some point, extra trees only shave the already-small second term while the floor stays put. That is exactly why the OOB error curve flattens: more trees never hurt, but they stop helping.forced by · the marginal benefit is O(1/n²) once the first term dominates
- 4So the only lever with real headroom is lowering ρ. Bootstrapping rows lowers it a little. Restricting each split to a random subset of features lowers it a lot, by preventing every tree from locking onto the same dominant predictor at the root.forced by · correlation between trees is driven mainly by shared structure, and structure is determined by which feature wins each split
- 5But lowering ρ costs you: each individual tree, denied the best feature at many nodes, becomes weaker, so σ² rises. The product ρσ² is what you are actually minimising, and there is an interior optimum.forced by · you are trading individual model strength against ensemble diversity, and both appear in the same term
Therefore max_features is the single most important Random Forest hyperparameter: it is the direct control on ρ, and it sits at the exact tension between diversity and individual strength. n_estimators is not a real hyperparameter — it has no optimum to find, only a point of diminishing returns.
And note what this predicts: more trees can never overfit, since adding them only reduces the second term and cannot raise the floor. That is why "just use more trees" is safe advice bounded only by compute — and it also predicts that Random Forests should be relatively insensitive to tuning in general, since the one parameter that matters has a good default (√p for classification) derived from exactly this tradeoff.
Picture a committee where each member is shown a random subset of the evidence and, at every decision point, is allowed to consider only a random handful of the available criteria. Individually each member is worse than a fully-informed expert — noticeably so. But their errors point in different directions, and the majority vote beats any of them.
The handicap is the mechanism, not a bug. If you let every member see everything, they would all reason the same way, make the same mistakes, and the vote would be no better than one member. Diversity is manufactured by deliberate restriction.
- Bagging reduces variance, not bias. So the base learner should be low-bias and high-variance — deep, unpruned trees. Bagging shallow stumps accomplishes almost nothing.
- Trees train independently, so it is embarrassingly parallel. Boosting is sequential by construction and cannot be parallelised the same way.
- Each bootstrap sample leaves out about 37% of rows (the limit of (1−1/n)ⁿ = 1/e). Those out-of-bag rows give you a free validation estimate with no separate holdout.
- More trees never overfit. If your forest overfits, that is tree depth or too few samples per leaf — not
n_estimators.
Fire this the moment you see: a single deep tree in production · someone tuning n_estimators with cross-validation · max_features left unexamined · impurity importances quoted from a forest as ground truth · a strongly imbalanced dataset fed to a forest with default class weights · an ensemble of shallow trees expected to fix underfitting.
You want a stronger tabular model than a single tree. Random Forest, or gradient boosting?
Fit a Random Forest first, essentially always. It costs one line, it rarely embarrasses you, and its OOB score establishes the number every subsequent model must beat. A surprising amount of tuning effort in the industry is spent reaching a score a default forest already achieved.
One discipline worth adopting regardless of which you ship: use permutation importance on held-out data rather than impurity importance. Impurity importance inherits the same high-cardinality bias as a single tree and is computed on training data, so it will happily rank a leaked ID column at the top. Permutation importance measures what the model actually loses when the feature is destroyed — which is the question you meant to ask.
(c) Hands-on · 25 min
We're going to build a random forest from single trees, compute the OOB score, compare against sklearn, then interpret feature importances.
# random_forest.py — RF from scratch + sklearn comparison, ~140 lines.
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
from sklearn.inspection import permutation_importance
np.random.seed(42)
# --- Data ---
data = load_breast_cancer()
X, y = data.data, data.target
feature_names = list(data.feature_names)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, stratify=y, random_state=42)
# =====================================================
# 1. Single-tree baseline
# =====================================================
tree = DecisionTreeClassifier(random_state=42).fit(X_train, y_train)
print(f"[single tree] train={accuracy_score(y_train, tree.predict(X_train)):.4f} "
f"test={accuracy_score(y_test, tree.predict(X_test)):.4f}")
# =====================================================
# 2. Random Forest from scratch
# =====================================================
class RFScratch:
def __init__(self, n_estimators=100, max_features="sqrt", max_depth=None, random_state=None):
self.n_estimators = n_estimators
self.max_features = max_features
self.max_depth = max_depth
self.random_state = random_state
self.trees = []
self.feature_subsets = [] # which features each tree was trained on
self.oob_masks = [] # which samples were OOB for each tree
def _feature_count(self, d):
if self.max_features == "sqrt":
return max(1, int(np.sqrt(d)))
if isinstance(self.max_features, int):
return self.max_features
return d # "all"
def fit(self, X, y):
rng = np.random.default_rng(self.random_state)
n, d = X.shape
n_feat = self._feature_count(d)
for b in range(self.n_estimators):
# Bootstrap sample
idx = rng.integers(0, n, size=n)
oob = np.setdiff1d(np.arange(n), np.unique(idx))
# Random feature subset PER TREE (simpler than per-split for scratch)
feats = rng.choice(d, size=n_feat, replace=False)
tree = DecisionTreeClassifier(max_depth=self.max_depth, random_state=b)
tree.fit(X[idx][:, feats], y[idx])
self.trees.append(tree)
self.feature_subsets.append(feats)
self.oob_masks.append(oob)
return self
def predict_proba(self, X):
n_classes = 2 # binary here for simplicity
probs = np.zeros((X.shape[0], n_classes))
for tree, feats in zip(self.trees, self.feature_subsets):
probs += tree.predict_proba(X[:, feats])
return probs / len(self.trees)
def predict(self, X):
return self.predict_proba(X).argmax(axis=1)
def oob_score(self, X, y):
"""OOB score: for each sample, average predictions from trees that DIDN'T see it."""
n = X.shape[0]
n_classes = 2
oob_probs = np.zeros((n, n_classes))
oob_counts = np.zeros(n)
for tree, feats, oob in zip(self.trees, self.feature_subsets, self.oob_masks):
if len(oob) == 0:
continue
preds = tree.predict_proba(X[oob][:, feats])
oob_probs[oob] += preds
oob_counts[oob] += 1
mask = oob_counts > 0
oob_probs[mask] /= oob_counts[mask, None]
return accuracy_score(y[mask], oob_probs[mask].argmax(axis=1))
# --- Fit + evaluate ---
rf = RFScratch(n_estimators=200, max_features="sqrt", max_depth=None, random_state=42)
rf.fit(X_train, y_train)
print(f"\n[scratch RF] train={accuracy_score(y_train, rf.predict(X_train)):.4f} "
f"test={accuracy_score(y_test, rf.predict(X_test)):.4f} "
f"OOB={rf.oob_score(X_train, y_train):.4f}")
# =====================================================
# 3. sklearn comparison
# =====================================================
sk = RandomForestClassifier(n_estimators=200, max_features="sqrt", oob_score=True, random_state=42)
sk.fit(X_train, y_train)
print(f"[sklearn RF] train={accuracy_score(y_train, sk.predict(X_train)):.4f} "
f"test={accuracy_score(y_test, sk.predict(X_test)):.4f} "
f"OOB={sk.oob_score_:.4f}")
# =====================================================
# 4. Show how ensemble accuracy grows with N
# =====================================================
print("\n=== Test accuracy vs number of trees ===")
for n in [1, 5, 10, 25, 50, 100, 200]:
m = RandomForestClassifier(n_estimators=n, max_features="sqrt", random_state=42).fit(X_train, y_train)
print(f" n_estimators={n:>4}: test = {accuracy_score(y_test, m.predict(X_test)):.4f}")
# =====================================================
# 5. Feature importance — DEFAULT (biased) vs PERMUTATION (honest)
# =====================================================
print("\n=== Feature importance ===")
mdi_imp = sk.feature_importances_
perm_imp = permutation_importance(sk, X_test, y_test, n_repeats=20, random_state=42, n_jobs=-1).importances_mean
# Sort by permutation importance
order = np.argsort(perm_imp)[::-1][:10]
print(f" {'feature':<28} {'MDI':>8} {'permutation':>13}")
for i in order:
print(f" {feature_names[i][:27]:<28} {mdi_imp[i]:>8.4f} {perm_imp[i]:>13.4f}")What each block does
Anatomy of the script
Duplicate X_train[:, 0] as a new column with tiny noise. Refit RF and print feature_importances_ for the original column and its clone.
Both get importance ~50 % of what the single feature had originally, even though they contain the same information. If you had 10 copies, each would get ~10 %.
The fix: permutation_importance groups correlated features via n_repeats and shows the joint effect. Even better: SHAP values with tree-explainer.
(d) Production reality · 15 min
Between the introduction of RF (2001) and the rise of XGBoost (2014), random forest was the top solo model on nearly every tabular Kaggle. Even after XGBoost took over, most winning solutions blended an RF into their ensemble because its errors were decorrelated with boosted trees.
LinkedIn's PYMK relied heavily on random forests for years — features included mutual friends, shared workplaces, email address patterns, profile view history. The model produced calibrated probabilities and interpretable feature importances, which was critical for the trust-and-safety team's investigations.
SNP-based disease risk prediction has traditionally used random forests + logistic regression stacks. Deep learning has struggled: sample sizes (~10K–100K) are dwarfed by feature dimensionality (~1M SNPs), and interpretability matters for clinical adoption.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What's bagging and why does it reduce variance?
- Why does random forest add random features on top of bagging?
- What's OOB and why is it a free lunch?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.