Search Tech Journey

Find topics, journeys and posts

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

S088 · Bias–Variance Trade-off & Learning Curves

The single most important theoretical idea in ML — decompose error into bias, variance, and noise, then read learning curves to know which one is killing you.

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

🎯 Decompose prediction error into bias + variance + noise, read learning curves fluently, and choose the right next action when your model is stuck.

Why this session exists

The bias–variance decomposition is the single lens through which every experienced ML engineer looks at a failing model. You look at the learning curve, you know instantly whether the model is underfitting (bias) or overfitting (variance), and you know exactly what to change next — more data, more features, more regularization, or a different model family. Without this lens, ML debugging is guessing. With it, you can systematically improve any pipeline.

You will be able to
  • State the bias-variance decomposition formula and explain each term.
  • Read a learning curve and diagnose high bias vs high variance in under 30 seconds.
  • List the correct next action for each pathology (more data, more features, more regularization, etc).
  • Explain double descent — the modern twist that overturns the classical U-curve.
  • Compute bias and variance empirically by bootstrapping a model.

Prerequisites

  • S084 · ML mental model — train / val / test.
  • S085 · Linear regression from scratch.
  • S087 · Regularization.


(a) Intuition · 5 min

Two archers, two very different failures
🌍 Real world

Archer A has a strong bow arm, always pulls the same way, but her sight is misaligned. Her arrows form a tight cluster — off-centre. High bias, low variance. Her problem is systematic; you fix it by adjusting the sight (or getting a better archer).

Archer B has a perfectly calibrated sight but shaky hands. Her arrows are all over the target, average roughly at the bullseye, but no single shot lands close. Low bias, high variance. Her problem is noise; you fix it with more practice (more data) or steadier hands (regularization).

💻 Code world

An ML model's prediction error, averaged over datasets drawn from the same distribution, decomposes into three pieces:

E[(y - ŷ)²] = Bias(ŷ)² + Var(ŷ) + σ²

Bias = how far the average prediction is from the truth (systematic error). Variance = how much the prediction wobbles as training data changes (fitting noise). σ² = irreducible noise in the labels themselves.

The three pieces of every prediction error

The bias-variance decomposition in three sentences
  • Bias — the model can't represent the true function. Fixes: more expressive model (polynomial features, tree, NN), more features.
  • Variance — the model overreacts to noise in the training data. Fixes: more data, regularization, simpler model, ensemble.
  • Noise (σ²) — irreducible. Even a perfect model can't beat it. Recognise when you've hit the noise floor and stop chasing it.

The intellectual history

  1. 1948
    Bias-variance in signal processing
    Statistical estimation theory formalises MSE = bias² + variance. Applied to filter design.
  2. 1992
    ‘Neural networks and the bias/variance dilemma’ · Geman
    The canonical ML paper. Frames overfitting for the ML community.
  3. 2001
    Random Forest paper · Breiman
    Ensembles as variance reduction. Bagging cuts variance without touching bias.
  4. 2019
    ‘Deep Double Descent’ · Nakkiran et al
    The classical U-curve gets a second dip for overparameterised models. Rewrites the textbook.
  5. 2020
    Neural scaling laws · Kaplan et al
    Bias, variance, and noise as functions of model size, data size, compute. The theory of ‘just make it bigger.’

(b) Visual walkthrough · 15 min

The classical U-curve

Traditional textbook says: total error = bias² + variance + noise. Complexity increases → bias falls monotonically, variance rises. Total error is U-shaped. Pick the model at the U's bottom.

Reading a learning curve — the debugging playbook

What a learning curve tells you

Both train and val error HIGH, converged
Model is too simple to fit even the training data. This is HIGH BIAS. Adding more data won't help — the model can't represent the truth.
under
Train error LOW, val error HIGH, big gap
Model fits train perfectly, generalises poorly. HIGH VARIANCE. Adding data closes the gap; so does regularization.
over
Train and val error both low, close together
You're good! Stop tuning. Ship it and monitor.
shipped
Train and val both DECREASING, still gap
Model still learning. Give it more epochs / more data.
learning
Val error INCREASING at some point
Overfitting kicking in during training. Use EARLY STOPPING at the val minimum.
stop

The action matrix — what to change based on diagnosis

High bias (underfitting)

Model too simple

  • → Add features (polynomial, interactions, engineered).
  • → More expressive model (deeper tree, NN, kernel).
  • → Reduce regularization.
  • → Train longer / more epochs.
  • ❌ NOT: more data (won't help).
High variance (overfitting)

Model too complex

  • → More training data (single biggest lever).
  • → Regularization (L1/L2/dropout).
  • → Ensemble methods (bagging, random forest).
  • → Simpler model (fewer features, shallower tree).
  • → Data augmentation (for vision/text).
Hit noise floor

Both errors low, close, and stuck

  • → Get better labels (harder to game).
  • → Add features that reduce label noise.
  • → Report the ceiling to the business honestly.
  • → Focus on latency, cost, robustness instead.
  • ❌ NOT: keep tuning hyperparameters (waste of time).

The double descent surprise


Common misconception
✗ What most people think

"Bias and variance trade off against each other, so there's always a sweet spot in the middle. More model capacity means less bias and more variance — that's a law, and the U-shaped test-error curve always applies."

✓ What is actually true

The decomposition is exact, but the "capacity up ⇒ variance up" rule is not a law — it is an observation about the underparameterised regime. Past the interpolation threshold, where the model has enough capacity to fit the training data exactly, test error can fall again. That double-descent behaviour is why massively overparameterised networks generalise at all, and it is why "the model is too big, it will overfit" is not a valid argument by itself.

Why the myth is so sticky

The myth is sticky because the classic U-curve is genuinely correct everywhere you first encounter it: polynomial degree on a small dataset, tree depth, k in kNN. In all those settings you stay well below the interpolation threshold, so the observed relationship holds perfectly and gets encoded as a universal principle. It also survives because the bias-variance decomposition is a theorem — true always — and people transfer that certainty onto the U-shaped curve, which is only an empirical regularity in one regime.

Prove it to yourself

Measure both components directly instead of reasoning about them. Bootstrap-resample the training set, refit, and look at how predictions for a fixed test point behave:

import numpy as np
preds = []
for _ in range(200):
    idx = np.random.randint(0, len(Xtr), len(Xtr))
    m = make_model().fit(Xtr[idx], ytr[idx])
    preds.append(m.predict(Xte))
P = np.array(preds)                 # (200, n_test)

variance  = P.var(axis=0).mean()            # spread across refits
bias2     = ((P.mean(axis=0) - yte)**2).mean()  # systematic offset
print(bias2, variance)

# high variance, low bias2 -> more data or more regularisation
# high bias2, low variance -> more capacity or better features
From first principles
Start with the question

Why does the expected squared error split cleanly into exactly three terms — bias², variance, and irreducible noise — with no cross terms? That the messy sum of all errors decomposes so tidily is not an accident of algebra.

  1. 1
    Fix a test point x. The label is y = f(x) + ε, where f is the true function and ε is noise with mean 0. Your prediction ĝ(x) is a random variable, because it depends on which training sample you happened to draw.
    forced by · two sources of randomness exist — the label noise and the training set — and they must be tracked separately
  2. 2
    Write the error as (y − ĝ) and insert the average prediction over all possible training sets, ḡ = E[ĝ]: y − ĝ = (f + ε − ḡ) + (ḡ − ĝ). Nothing has changed; you added and subtracted the same quantity.
    forced by · the mean prediction is the natural pivot separating "systematically wrong" from "unstable"
  3. 3
    Square it and take expectations. The three squared terms give E[ε²] = σ², (f − ḡ)² = bias², and E[(ḡ − ĝ)²] = variance.
    forced by · squaring a three-part sum yields three squares plus three cross terms
  4. 4
    Every cross term vanishes. Those involving ε vanish because E[ε] = 0 and ε is independent of the training draw. The term (f − ḡ)·E[ḡ − ĝ] vanishes because ḡ is defined as E[ĝ], so that expectation is exactly zero.
    forced by · subtracting the mean makes the deviation orthogonal to any constant — the same orthogonality that makes least squares a projection
  5. 5
    The σ² term contains no model quantity at all. No algorithm, no amount of data, and no capacity can touch it — it is the noise in the labels themselves.
    forced by · ε is independent of everything you control
⇒ Therefore

Therefore the decomposition is exact and the cross terms vanish by construction, because deviations from a mean are orthogonal to that mean. Error = (wrong on average) + (unstable across samples) + (unknowable).

And note what this predicts: the clean three-way split depends on the loss being squared error, since that is what made the orthogonality argument work. For 0–1 loss or cross-entropy there is no equally clean decomposition — the analogues are approximate and the terms can interact. So "bias-variance tradeoff" is a precise statement for regression and a useful metaphor for classification, and treating the metaphor as arithmetic is where people go wrong.

Mental modelDartboard: aim versus shake

Each retraining on a fresh sample from the same distribution is one throw. Bias is where the cluster of darts is centred relative to the bullseye — a systematic aiming error that stays even if you throw a thousand times. Variance is how wide the cluster is spread — the throw-to-throw instability. Irreducible noise is the board itself wobbling.

The critical insight: you cannot diagnose either one from a single throw. A single model's test score tells you the total error and nothing about its composition — which is why the diagnosis requires either a learning curve or multiple refits, and why "add more data" and "add more capacity" are opposite prescriptions that a single score cannot distinguish between.

  • High bias = high train error AND high validation error, close together. Adding data will not help. Add capacity, add features, weaken regularisation.
  • High variance = low train error, much higher validation error. Adding capacity will hurt. Add data, add regularisation, simplify, or ensemble.
  • More data reduces variance and does nothing for bias. This is the single most useful asymmetry in the whole framework.
  • Ensembling attacks variance directly by averaging many high-variance models (bagging); boosting attacks bias by sequentially fitting the residuals. That is the whole difference between them.
🔔 Fires when you see

Fire this the moment you see: someone proposing more data for a model that already underfits · someone proposing a bigger model for a train/val gap of 30 points · a learning curve nobody plotted before deciding · a demand to hit an accuracy target with no discussion of the noise floor · wildly different scores across CV folds.

The tradeoff

Your validation error is too high and you have one sprint. Do you buy more data, add capacity, or invest in regularisation and ensembling?

More/better data
+ you gain reduces variance without adding any bias — the only lever with no downside on the error decomposition. It also usually improves robustness to distribution shift, which none of the other options do, and better labels can even lower the apparent noise floor.
− you pay slowest and most expensive, often requiring labelling budget or a collection pipeline that does not exist. And it is completely wasted effort if the model is bias-limited — a bigger sample of a pattern your model cannot represent changes nothing.
pick when the learning curve still shows validation error declining as training size grows, i.e. the curves have not converged — that is the direct test for whether data will help
More capacity (bigger model, more features, deeper trees)
+ you gain the only fix for a genuinely bias-limited model, and typically the fastest to try — change a parameter, refit, see if train error drops. Better features in particular reduce bias without necessarily raising variance much.
− you pay raises variance in the classical regime, so without enough data you trade an underfit model for an overfit one and end up no better; also raises training cost, inference latency, and serving footprint permanently
pick when training error itself is unacceptably high — the model cannot even fit data it has seen, which is a pure capacity statement with no ambiguity
Regularisation and ensembling
+ you gain reduces variance with no new data and no labelling spend; bagging and averaging in particular reduce variance almost for free, and this is usually the highest score-per-hour intervention available in a short sprint
− you pay regularisation adds bias by construction, so it helps only if you were variance-limited; ensembling multiplies training and inference cost and destroys the interpretability of a single model; and both are hyperparameter searches, which consume the sprint you were trying to save
pick when the train/validation gap is large — an unambiguous variance signature — and you need improvement within days rather than months
What a senior engineer actually does

Do not choose from intuition; plot the learning curve first. It costs one afternoon and it distinguishes the three cases unambiguously: converged-and-high means bias, a persistent gap means variance, and a still-falling validation curve means data will pay. Teams routinely spend a quarter collecting data for a model that was bias-limited from day one, and the curve would have said so immediately.

In practice the ordering that wins most often is: better features first (reduces bias cheaply, and is where domain knowledge actually converts into accuracy), then regularisation and ensembling (fast variance reduction), then more data (slow but the only durable fix). And know your noise floor — if two human labellers disagree 8% of the time, chasing 97% accuracy is chasing σ², and no engineering effort will ever reach it.


(c) Hands-on · 25 min

We're going to (1) plot a real learning curve, (2) empirically decompose bias and variance via bootstrapping, and (3) reproduce the classical U-curve by varying polynomial degree.

# bias_variance.py — the debugging playbook in ~130 lines.
import numpy as np
from sklearn.datasets import make_regression
from sklearn.model_selection import train_test_split, learning_curve
from sklearn.linear_model import Ridge
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import mean_squared_error
 
np.random.seed(42)
 
# --- 1. Synthetic non-linear data: y = sin(2·x) + noise ---
def make_data(n=300, noise=0.3):
    X = np.random.uniform(-3, 3, size=(n, 1))
    y = np.sin(2 * X.ravel()) + np.random.normal(0, noise, size=n)
    return X, y
 
X, y = make_data(n=300)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
 
# =====================================================
# 1. LEARNING CURVE — vary training set size
# =====================================================
print("=== Learning curve — model = poly-deg-9 + ridge ===")
model = Pipeline([
    ("poly", PolynomialFeatures(degree=9)),
    ("scaler", StandardScaler()),
    ("ridge", Ridge(alpha=1e-3)),
])
sizes, train_scores, val_scores = learning_curve(
    model, X_train, y_train,
    train_sizes=np.linspace(0.1, 1.0, 8),
    cv=5, scoring="neg_root_mean_squared_error", random_state=42
)
print(f"  {'train_size':>10} {'train_RMSE':>12} {'val_RMSE':>10} {'gap':>8}")
for n, tr, va in zip(sizes, -train_scores.mean(1), -val_scores.mean(1)):
    gap = va - tr
    diag = "high var" if gap > 0.2 else ("high bias" if tr > 0.4 else "OK")
    print(f"  {int(n):>10}  {tr:>12.4f}  {va:>10.4f}  {gap:>8.4f}   {diag}")
 
# =====================================================
# 2. CLASSICAL U-CURVE — vary polynomial degree
# =====================================================
print("\n=== U-curve — vary complexity (poly degree) ===")
print(f"  {'degree':>7} {'train_RMSE':>12} {'test_RMSE':>10} {'diag':>15}")
for degree in [1, 2, 3, 5, 7, 9, 12, 15, 20]:
    m = Pipeline([
        ("poly", PolynomialFeatures(degree=degree)),
        ("scaler", StandardScaler()),
        ("ridge", Ridge(alpha=1e-6)),   # tiny alpha so complexity, not reg, dominates
    ])
    m.fit(X_train, y_train)
    tr_err = mean_squared_error(y_train, m.predict(X_train), squared=False)
    te_err = mean_squared_error(y_test, m.predict(X_test), squared=False)
    if degree <= 2:
        d = "high bias"
    elif degree >= 15:
        d = "high variance"
    else:
        d = "balanced"
    print(f"  {degree:>7}  {tr_err:>12.4f}  {te_err:>10.4f}  {d:>15}")
 
# =====================================================
# 3. EMPIRICAL BIAS + VARIANCE via bootstrapping
# =====================================================
def empirical_bias_variance(model_ctor, X_train, y_train, X_test, y_test, n_boot=50):
    """
    Fit n_boot models on bootstrap samples of train.
    At each test point, compute bias² + variance across the ensemble.
    """
    predictions = np.zeros((n_boot, len(X_test)))
    n = len(X_train)
    for b in range(n_boot):
        idx = np.random.choice(n, size=n, replace=True)
        m = model_ctor()
        m.fit(X_train[idx], y_train[idx])
        predictions[b] = m.predict(X_test)
    mean_pred = predictions.mean(axis=0)
    bias_sq = np.mean((mean_pred - y_test) ** 2)
    variance = np.mean(predictions.var(axis=0))
    total_mse = np.mean((predictions - y_test) ** 2)
    noise = total_mse - bias_sq - variance     # residual — approximates σ²
    return bias_sq, variance, noise, total_mse
 
print("\n=== Empirical bias-variance across model complexity ===")
print(f"  {'degree':>7} {'bias²':>10} {'variance':>10} {'noise':>10} {'total':>10}")
for degree in [1, 3, 5, 9, 15]:
    def ctor(d=degree):
        return Pipeline([
            ("poly", PolynomialFeatures(degree=d)),
            ("scaler", StandardScaler()),
            ("ridge", Ridge(alpha=1e-6)),
        ])
    b2, v, n_, t = empirical_bias_variance(ctor, X_train, y_train, X_test, y_test, n_boot=30)
    print(f"  {degree:>7}  {b2:>10.4f}  {v:>10.4f}  {n_:>10.4f}  {t:>10.4f}")

What each block does

Anatomy of the script

Synthetic sin(2x) data
We know the truth: y = sin(2x) + noise. Any model's error decomposes cleanly here because we control everything.
data
learning_curve API
sklearn's helper. Train on progressively larger fractions, report train + val error at each. This is the diagnostic tool.
diagnostic
Gap interpretation
gap = val - train. Big gap = variance. Small gap + high error = bias. Print the diagnosis inline.
diag
U-curve via degree sweep
Same model family, vary complexity. Watch train error drop monotonically, test error dip then rise. Textbook picture.
u-curve
Empirical bootstrap decomposition
Fit 50 models on bootstrap samples. mean(predictions) - truth = bias. var(predictions) = variance. Remaining error ≈ noise. Cleanest way to SEE the decomposition.
decompose
Try itCut variance in half by ensembling

Take a degree-15 polynomial (the high-variance case). Fit 20 of them on 20 bootstrap samples. Average their predictions. Now:

  • Individual model test RMSE: ~0.6
  • Ensemble prediction RMSE: ~0.4

You've reduced variance by ensembling — same idea as random forest. Bias stays the same; only variance drops. This is why bagging works.

💡 Hint · A random forest is exactly this idea, scaled up: many high-variance trees averaged into one low-variance ensemble. That's S090's whole session.

(d) Production reality · 15 min

War story Netflix Prize — bagging vs boosting divergence· 2009$1M prize · thousands of teams
🔥 What broke

Teams that overfit early — huge stacked ensembles with insufficient regularization — climbed the public leaderboard fast, then collapsed on the private leaderboard. The teams that eventually won carefully monitored train vs val error gap and stopped adding models when the gap widened.

🧯 The fix
The winning team (BellKor's Pragmatic Chaos) built each ensemble component to be individually low-bias but slightly high-variance, then averaged them (variance reduction via bagging). They tracked the train/val gap on every submission.
🎓 Lesson to steal
Blind ensembling makes overfitting worse, not better, if each model is already overfit. Bagging only reduces variance when the base models have low correlation.
War story Google Ads · CTR prediction · 2010spetabytes of data
🔥 What broke

Early CTR models used simple logistic regression on ~100 features. Bias was high — the model couldn't capture rare feature interactions. Adding polynomial features exploded dimensionality and made training infeasible.

🧯 The fix
Solution: hashing trick + feature crosses + FTRL-Proximal optimizer with L1 regularization. Kept the model high-capacity (billions of features) but sparse (few nonzero). Bias dropped without variance exploding — exactly the bias-variance trade the theory predicts.
🎓 Lesson to steal
At web scale, more data is basically infinite, so variance is not the constraint — bias is. Add capacity, add regularization, watch the two curves.
Post-mortem
War story OpenAI · GPT scaling · 2019-2022· 2020GPT-3: 175B params, 300B tokens
🔥 What broke

Classical bias-variance theory predicts that a 175B-parameter model trained on 300B tokens should overfit catastrophically. It doesn't. Instead, val loss keeps decreasing with more parameters even in the ‘overparameterised’ regime.

🧯 The fix
The explanation is double descent + implicit regularization from SGD + architecture priors (attention, LayerNorm, residuals). Kaplan et al's 2020 scaling laws paper formalised how bias, variance, and noise scale with model + data size — the theory that guides every LLM training run today.
🎓 Lesson to steal
The classical U-curve is a fantastic mental model for classical ML. For overparameterised deep learning, it needs the double-descent extension. But the underlying decomposition still applies — you're still trading bias for variance for compute.
Post-mortem

Where this shows up in the rest of the plan

Bias-variance is the debugging lens for every ML session ahead
S087 · Regularization
The direct knob for trading variance for bias.
S089 · Decision trees
Unbounded trees = extreme variance. Pruning = bias-variance trade.
S090 · Random forest & bagging
Pure variance reduction technique. Bias basically unchanged.
S091 · Gradient boosting
Sequentially reduces bias. Variance controlled by tree depth + learning rate.
S096 · Feature engineering
Adds capacity → reduces bias. But watch for variance if you're not careful.
S102 · Deep learning generalisation
The double-descent story. Modern extension of classical theory.

(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 is bias, what is variance, what is noise? (one-sentence each)
  2. How do you read a learning curve to diagnose which one is your problem?
  3. When would you pick regularization over more data, and vice versa?

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.