Search Tech Journey

Find topics, journeys and posts

6-month learning plan90 / 130
back to blog
mlintermediate 55m read

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.

🤖Machine LearningM11 · Classical ML· Session 090 of 130 90 min

🎯 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.

You will be able to
  • 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

A hundred slightly-wrong doctors, together
🌍 Real world

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.

💻 Code world

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

Random forest in three sentences
  • 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

  1. 1994
    Bagging · Leo Breiman
    Bootstrap AGGregatING. Grow many decision trees on bootstrap samples, average their outputs. Cuts variance without touching bias.
  2. 1995
    Random Subspace · Ho
    Tin Kam Ho at Bell Labs invents feature randomisation. Combined with bagging = random forest.
  3. 2001
    Random Forest · Breiman
    The paper that names it. Dominates ML competitions for a decade.
  4. 2003
    ExtraTrees · Geurts
    Random forest + randomised thresholds. Even faster, sometimes better.
  5. 2014
    XGBoost overtakes for tabular
    Gradient boosting beats random forest on structured Kaggle. RF stays the ‘safe baseline.’
  6. 2020s
    Still the industry standard baseline
    Every 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

Sample n items with replacement, from n items
P(specific item NOT picked in one draw) = (n-1)/n = 1 - 1/n.
step 1
P(NOT picked in n draws)
= (1 - 1/n)ⁿ
step 2
As n → ∞
→ 1/e ≈ 0.368. So ~37 % of items are NEVER selected → out-of-bag.
step 3
The other ~63 % are the bootstrap sample
Some appear once, some twice or more. Each tree sees ~63 % unique training data + duplicates.
step 4

Bagging vs Random Forest — one tweak makes a big difference

Plain Bagging

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.
Random Forest

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.
Extra Trees

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)

1⚠️
Mean Decrease Impurity (MDI · sklearn default)

Sum the impurity reduction each feature provides across all splits in all trees. Fast but biased toward high-cardinality features.

2
Permutation Importance

Permute one feature at a time in the validation set. Measure the drop in accuracy. Slower but honest — reflects actual predictive power.

3🏆
SHAP values

Game-theoretic attribution. Each feature's contribution to each prediction. Gold standard for interpretability.

4💡
Never trust default importances blindly

Especially with correlated features — importance is arbitrarily split between them. Use permutation or SHAP for real decisions.


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

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.

  1. 1
    For 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
  2. 2
    The 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
  3. 3
    Therefore 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
  4. 4
    So 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
  5. 5
    But 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

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.

Mental modelA committee of deliberately narrow-minded experts

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.
🔔 Fires when you see

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.

The tradeoff

You want a stronger tabular model than a single tree. Random Forest, or gradient boosting?

Random Forest
+ you gain works well with near-default settings, which makes it the fastest path from data to a trustworthy baseline; trains fully in parallel across cores; cannot overfit by adding trees, so the main failure mode is removed; and OOB error gives you a validation estimate for free, without a holdout or a CV loop
− you pay typically leaves accuracy on the table versus a well-tuned booster, because it only attacks variance and never reduces the bias of the base learner; the model is large (hundreds of deep trees) so memory and inference cost are substantial; and it does not extrapolate
pick when you need a strong, robust result quickly with minimal tuning, you have cores to spare, or your data is noisy enough that a booster would chase the noise
Gradient boosting
+ you gain reduces bias by fitting each tree to the previous ensemble's residuals, which is why it typically wins on structured tabular data; the shallow base trees make the final model far smaller and faster to serve than a forest of equivalent accuracy
− you pay genuinely can overfit as you add trees, so it needs early stopping and a real hyperparameter search over learning rate, depth, and regularisation; sequential training resists parallelisation across trees; and it is more sensitive to noisy labels, because it explicitly focuses capacity on the examples it currently gets wrong — including the mislabelled ones
pick when accuracy is the dominant objective, you have a clean validation set and the budget to tune, and label noise is low
Random Forest as a diagnostic, booster in production
+ you gain the forest gives you a fast honest baseline plus OOB error and permutation importances for leakage detection; the booster then gets tuned against that known floor, so you can tell whether tuning actually bought anything
− you pay two models to build and maintain during development, and the discipline to actually delete the first one
pick when any serious tabular project where someone will later ask "was all that tuning worth it"
What a senior engineer actually does

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

Single-tree baseline
The comparison point. Unbounded tree memorises training (100 % train accuracy), generalises modestly.
baseline
RFScratch.fit
For each of n_estimators trees: bootstrap sample rows, pick √d random features, fit a tree, remember which samples were OOB.
grow
predict_proba averaging
Each tree votes with a probability; we average. This is the ensemble step — where variance drops.
predict
oob_score
For each sample, average predictions across trees that DIDN'T train on it. This is a proper generalisation estimate without a val set.
oob
n_estimators sweep
Watch test accuracy climb from ~93 % (1 tree) to ~97 % (200 trees), then plateau. Additional trees don't hurt.
n-effect
MDI vs permutation importance
MDI is fast but biased toward high-cardinality / high-variance features. Permutation is honest. Use permutation for real decisions.
explain
Try itWatch feature importance mislead you with correlated features

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.

💡 Hint · Random forest's default importance splits credit arbitrarily between correlated features. Permutation importance handles this better — but SHAP is even more principled.

(d) Production reality · 15 min

War story Kaggle · almost every tabular competition · 2001-2015thousands of competitions
🔥 What broke

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.

🧯 The fix
Modern competition strategy: always fit an RF as your first serious baseline. If you can't beat it with a linear model, LR isn't the right tool. If you can't beat it with an XGBoost, your XGBoost is under-tuned.
🎓 Lesson to steal
RF is the answer to ‘what's my first serious ML baseline?’ for any tabular dataset. Cheap to fit, honest OOB score, gives feature importances, needs zero preprocessing.
War story LinkedIn People You May Know · early dayshundreds of millions of users
🔥 What broke

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.

🧯 The fix
The initial deep-learning replacement candidates didn't beat the RF baseline by enough to justify the added infra + latency. RF stayed as production model for years while deep alternatives incubated.
🎓 Lesson to steal
Production ML lives on the margin between ‘just barely better’ and ‘worth the additional infra cost.’ RF's simplicity buys it years of production life against ‘slightly better’ deep alternatives.
War story Genomics · disease-risk prediction · 2005-presentclinical use
🔥 What broke

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.

🧯 The fix
RF is well-suited: it handles high-dimensional data, provides feature importances (which SNPs matter), and works with limited sample sizes without overfitting. The alternative — deep learning on genomics — often produces uninterpretable models that fail regulatory review.
🎓 Lesson to steal
Sample-size-constrained + high-dimensional + interpretability-required = RF's sweet spot. Deep learning isn't automatically better; it's a different tool for a different regime.

Where this shows up in the rest of the plan

Random forest is the workhorse baseline and the ensemble teacher
S089 · Decision trees
The atomic unit. Every RF is 100+ of these under the hood.
S091 · Gradient boosting
Different ensembling paradigm — sequential, bias reduction, not variance reduction. XGBoost, LightGBM.
S088 · Bias-variance
RF is the poster child for variance reduction via bagging. Bias is unchanged.
S096 · Feature engineering
RF is relatively robust — you can skip most feature engineering and still get 90% of the way there.
S099 · Model interpretability
Permutation importance + SHAP work naturally on tree ensembles.
S119 · Recommender systems
RF is common in the ranking layer for hybrid recs (content + collaborative signals).

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. What's bagging and why does it reduce variance?
  2. Why does random forest add random features on top of bagging?
  3. 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.