Learn machine learning from scratch

Start at chapter 1 and build up: what a model is, how it measures its own mistakes, and how it learns. Everything is worked out in plain Python, with the maths shown rather than assumed. Two shorter tracks hold reference notes on the maths and on modern architectures.

Track Progress: 0 Completed
0%
Core CourseLEVEL 2 · INTERMEDIATEChapter 11

Too Good to Be True: Overfitting and Regularization

A model that is perfect on its training data can be badly wrong on new data. Hold some data back to catch it, then rein the model in with a penalty on large weights, by stopping early, or best of all with more data.

43 min Prerequisites: Chapters 7, 9 and 10 (tensor.py, and Chapter 10's Adam saved as optim.py)
Inspect Architecture: Multi-layer perceptron

1. The idea

Every chapter so far has judged a model by its loss on the data it was trained on. That's like grading a student on the exact questions they practised: a good score might mean understanding, or it might mean they memorized the answers. Chapter 9 hinted at the problem. Its labels follow a simple rule, except that one day in ten people did something random, and no amount of weather data can predict those days. A model that gets them right anyway has learned the particular days, not the pattern.

The cure is to hold data back. Train on one set, then measure on a validation set the model never saw, and keep a test set locked away for a single final check. Take twelve rides from Chapter 7's year and fit polynomials of rising degree. The training error falls every time, from 1.59 for a straight line to 0.81 for degree 10. On the other 228 rides the straight line scores 2.18, close to the noise in the data itself, while degree 9 scores 773 and predicts that a 15 km ride takes 300 minutes. The extra knobs went into fitting the noise in twelve particular rides.

Networks do the same thing. A 3-64-3 network has 451 knobs and Chapter 9 gives it 150 days. After 110 epochs of Adam it gets 94% of the training days and 90.1% of 1,000 new days right. After 3,000 epochs it gets every training day right, 100%, while on new days it has slipped to 86.2% and its loss there has risen from 0.43 to 2.47. It became confidently wrong.

There are three standard defences. Regularization adds a penalty for large weights to the loss, so the model can only use big, wild weights when the data really demands them: on the degree-9 fit it cuts the validation error from 773 to 5.2, and on the network it keeps new-day accuracy at 90.2% after all 3,000 epochs. Early stopping simply keeps the weights from the epoch with the best validation loss. And more data makes memorizing harder: with 1,500 training days instead of 150, the same unregularized network reaches 93.0% on new days. The test set, used once at the end, confirms the order of all four.

Two charts. Left, twelve recorded rides, minutes against distance, roughly on a straight line from about 8 minutes at 1 km to 46 at 13.6 km. A straight-line fit runs through the middle of them. A degree-9 polynomial wiggles through almost every point but shoots off the top of the chart below 1.5 km and above 14 km. Right, the network's loss against epoch on a log scale. Training loss falls steadily from 0.75 to 0.013 over 3,000 epochs. The loss on 1,000 new days falls to 0.429 at epoch 110, marked with a dashed line, then climbs steadily to 2.47.Two charts. Left, twelve recorded rides, minutes against distance, roughly on a straight line from about 8 minutes at 1 km to 46 at 13.6 km. A straight-line fit runs through the middle of them. A degree-9 polynomial wiggles through almost every point but shoots off the top of the chart below 1.5 km and above 14 km. Right, the network's loss against epoch on a log scale. Training loss falls steadily from 0.75 to 0.013 over 3,000 epochs. The loss on 1,000 new days falls to 0.429 at epoch 110, marked with a dashed line, then climbs steadily to 2.47.
Two pictures of overfitting. The wiggly curve and the late network both fit their training data better and new data far worse. The gap between the two loss curves is what a validation set exists to measure.

2. The math

Three kinds of data
training set: used to compute gradients validation set: used to choose settings (degree, λ, when to stop) test set: used once, at the very end, to report the result
Every choice made by looking at the validation score tunes the model a little to that particular set. The test set stays untouched so the final number is an honest estimate for new data.
Twelve rides, ten polynomials
ŷ = c₀ + c₁z + c₂z² + … + c_d·z^d, z = standardized km degree 1: train 1.588 validation 2.183 predicts 15 km: 49.9 min degree 3: train 1.503 validation 2.227 degree 9: train 1.242 validation 773 predicts 15 km: 300.5 min
Training error can only go down as knobs are added, because a higher degree can always copy a lower one. Degree 10 goes further, to a training error of 0.807 and a validation error of about 2.5 million.
The noise floor
rides: minutes = 3·km + 5 + noise, noise spread σ = 1.5 best possible average error on new rides: σ² = 2.25 activities: the labelling rule scores 94.1% (validation), 92.6% (test)
No model can predict the noise, so no model beats these numbers on average. The straight line's 2.18 is already at the floor (a little under it, by the luck of these 228 rides). A training score better than the floor means memorization.
Bias and variance
average error on new data = bias² + variance + noise σ² bias: how far the average fitted model is from the truth variance: how much the fit changes from one training set to another
Too simple a model has high bias (a line can't follow a curve). Too flexible a model has high variance: degree 9 would wiggle differently for every 12 rides. Derived in the first derivation.
L2 regularization: a penalty on large weights
L_reg(θ) = L(θ) + λ · Σ w² ∂L_reg/∂w = ∂L/∂w + 2λ·w polynomial (ridge): c = (AᵀA + λN·I)⁻¹ Aᵀy
λ sets the price of large weights. Biases and the constant c₀ are usually left out of the penalty: shifting the output up or down isn't the kind of complexity we want to discourage. The ridge formula is the exact minimum for the polynomial.
It's the same as weight decay
gradient step on L_reg: w ← w − η(∂L/∂w + 2λw) = (1 − 2ηλ)·w − η·∂L/∂w η = 0.1, λ = 0.01: each step first shrinks every weight by 0.2%
For plain gradient descent, an L2 penalty and 'decay every weight a little each step' are identical. With Adam they differ, because Adam rescales the penalty's gradient per knob; AdamW (Chapter 10's third exercise) applies the decay separately to keep it uniform.
Ridge on the degree-9 fit
λ = 0: validation 773 Σc² = 33,219 λ = 0.001: validation 7.83 Σc² = 142.6 λ = 0.01: validation 5.18 Σc² = 114.6 λ = 0.1: validation 37.0 (too strong: it flattens the slope too)
A little penalty removes the wild coefficients and most of the damage. Too much penalty starves even the slope the data needs. Nothing beats the straight line here, because the truth is a straight line.
A network memorizes
3 → 64 → 3, ReLU: 451 knobs; 150 training days; Adam, η = 0.01 epoch 110: train 0.278 (94.0%) new days 0.429 (90.1%) epoch 3000: train 0.013 (100.0%) new days 2.469 (86.2%)
Validation loss bottoms out at epoch 110, then climbs for 2,900 epochs while training loss keeps falling. The accuracy drop is smaller than the loss rise because the wrong answers became very confident ones.
Early stopping and L2 on the network
early stopping: keep the weights from epoch 110: validation 90.1% L2 for 3,000 epochs: λ = 0.0001: 86.6% λ = 0.001: 90.2% λ = 0.003: 90.2% (loss 0.411) λ = 0.01: 88.6%
Both defences land around 90%. L2 has a sweet spot like the polynomial: too small does nothing, too large underfits. Early stopping needs no new knob, only a validation check every few epochs.
The final exam, and the best fix
test accuracy (rule: 92.6%) no regularization: 84.7% early stopping: 87.2% L2, λ = 0.003: 88.4% 1,500 training days, no penalty: 90.8%
Every model scores a little lower on the test set than on validation, partly because these test days are harder (the rule itself drops from 94.1% to 92.6%). The order holds, and ten times more data beats every trick.

• Fix an input x. The truth is y = f(x) + ε, where ε is noise with mean 0 and variance σ². A model trained on a random training set predicts ŷ(x); write ȳ for its average over all the training sets we might have drawn.

• Split the error into three pieces: ŷ − y = (ŷ − ȳ) + (ȳ − f) − ε.

• Square and average over both the training set and the new noise. The cross terms vanish: ε is independent of the training set and has mean 0, and ŷ − ȳ has mean 0 by the definition of ȳ.

• What's left is E[(ŷ − y)²] = E[(ŷ − ȳ)²] + (ȳ − f)² + σ² = variance + bias² + noise. ∎

• Adding knobs usually lowers bias and raises variance; regularization and more data lower variance. The noise term is untouchable, which is why the rides' validation error can't go below about 2.25.

• Without a penalty the least-squares coefficients solve (AᵀA)·c = Aᵀy. Decompose the symmetric matrix AᵀA into perpendicular directions u_k with eigenvalues s_k ≥ 0 (as in Chapter 7's curvatures).

• Along direction u_k, the solution is c_k = (u_kᵀAᵀy) / s_k. A small s_k means the data barely pins that direction down, and dividing by it blows up whatever noise lands there. For degree 9 on twelve rides some s_k are tiny, which is where the 33,219 comes from.

• With the penalty the equation becomes (AᵀA + λN·I)·c = Aᵀy, and each component is c_k = (u_kᵀAᵀy) / (s_k + λN) = [s_k / (s_k + λN)] × (the unpenalized c_k).

• The factor s_k / (s_k + λN) is close to 1 where the data is informative (s_k ≫ λN) and close to 0 where it isn't (s_k ≪ λN). ∎ So ridge leaves the well-supported directions alone and shrinks the noise-driven ones.

• The same picture explains why too much λ hurts: once λN is comparable to the large s_k, even the slope the data clearly supports gets shrunk, as λ = 0.1 shows.

3. How it works

1
Split before you look
Set aside a validation set and a test set before training anything. Shuffle first, so all three sets come from the same mix of examples.
2
Watch both curves
Log the validation loss every few epochs alongside the training loss. While both fall, keep going. When validation turns upward, the model has started memorizing.
3
Keep the best checkpoint
Save the weights whenever the validation loss reaches a new low. That's early stopping, and it costs nothing but the check.
4
Add a penalty if needed
Add λ·Σw² over the weight matrices (not the biases) and try a few λ values on the validation set, spaced by factors of 3 or 10.
5
Test once, then stop tuning
Report the chosen model's test score. If you change anything after seeing it, the test set has become a second validation set and you need a fresh one.

4. The code (python)

core_ch11.py
# Chapter 11: overfitting, and three ways to fight it: regularization, early stopping, more data.
# Uses the Tensor class from Chapter 6 (tensor.py) and the Adam class from Chapter 10, saved as optim.py
# with 'import numpy as np' at the top.
import numpy as np
from tensor import Tensor
from optim import Adam

# === 1. Twelve rides, ten polynomials ===========================================
rng = np.random.default_rng(7)                     # Chapter 7's year of rides
year_km = np.round(rng.uniform(1, 15, 240), 1)
year_min = np.round(3 * year_km + 5 + rng.normal(0, 1.5, 240), 1)
train_km, train_min = year_km[:12], year_min[:12]  # we only get to see 12 rides
val_km, val_min = year_km[12:], year_min[12:]      # the other 228 judge the fit
mu, sd = train_km.mean(), train_km.std()


def powers(km, degree):
    z = (km - mu) / sd                             # standardized, as in Chapter 7
    return np.column_stack([z ** k for k in range(degree + 1)])


def fit(degree, lam=0.0):
    """Least squares on 1, z, z², …, plus lam × (sum of squared coefficients, except the constant)."""
    A = powers(train_km, degree)
    penalty = lam * len(A) * np.eye(degree + 1)
    penalty[0, 0] = 0
    return np.linalg.solve(A.T @ A + penalty, A.T @ train_min)


def mse(c, km, minutes):
    return float(np.mean((powers(km, len(c) - 1) @ c - minutes) ** 2))

print("degree   train MSE   validation MSE   predicts 15 km as")
for degree in range(1, 11):
    c = fit(degree)
    print(f"{degree:6}   {mse(c, train_km, train_min):9.3f}   {mse(c, val_km, val_min):14.3f}"
          f"   {(powers(np.array([15.0]), degree) @ c)[0]:10.1f} min")
print("(the noise in these rides has variance 1.5² = 2.25: no model can do better on average)")

print("\ndegree 9 with an L2 penalty:")
print("   lam    train MSE   validation MSE   sum of squared coefficients")
for lam in [0, 0.001, 0.01, 0.1, 1]:
    c = fit(9, lam)
    print(f"{lam:6}   {mse(c, train_km, train_min):9.3f}   {mse(c, val_km, val_min):14.3f}   {np.sum(c[1:] ** 2):12.1f}")

# === 2. A network that memorizes: Chapter 9's activities ===========================
def make_days(n, seed):
    """Same process as Chapter 9: read if rainy, else kite if windy, else picnic; 1 day in 10 at random."""
    r = np.random.default_rng(seed)
    X = np.round(r.random((n, 3)), 2)
    rule = np.where(X[:, 1] > 0.5, 2, np.where(X[:, 2] > 0.6, 1, 0))
    labels = rule.copy()
    noisy = r.random(n) < 0.1
    labels[noisy] = r.integers(0, 3, noisy.sum())
    return X, labels, rule

X_train, y_train, _ = make_days(150, 9)            # Chapter 9's 150 days
X_val, y_val, rule_val = make_days(1000, 99)       # new days, for choosing settings
X_test, y_test, rule_test = make_days(1000, 123)   # more new days, looked at once, at the very end
print(f"\nthe rule itself: {np.mean(rule_val == y_val):.1%} of validation days, {np.mean(rule_test == y_test):.1%} of test days")


def softmax_cross_entropy(Z, labels):
    E = (Z + (-Z.data.max(axis=1, keepdims=True))).exp()
    P = E / (E @ Tensor(np.ones((3, 1))))
    return -(Tensor(np.eye(3)[labels]) * P.log()).sum() / Z.shape[0], P


def scores(params, X):
    W1, b1, W2, b2 = params
    return (Tensor(X) @ W1.T + b1).relu() @ W2.T + b2


def evaluate(params, X, labels):
    loss, P = softmax_cross_entropy(scores(params, X), labels)
    return float(loss.data), float(np.mean(P.data.argmax(axis=1) == labels))


def train(X, labels, epochs=3000, lam=0.0, hidden=64, log_every=None, seed=0):
    """Full-batch Adam (η = 0.01) on a 3-64-3 network, with an optional L2 penalty lam·(ΣW1² + ΣW2²)."""
    r = np.random.default_rng(seed)
    W1, b1 = Tensor(r.normal(size=(hidden, 3))), Tensor(np.zeros(hidden))
    W2, b2 = Tensor(r.normal(size=(3, hidden)) * 0.5), Tensor(np.zeros(3))
    params = [W1, b1, W2, b2]
    opt = Adam(params, 0.01)
    log = []
    for epoch in range(1, epochs + 1):
        loss, _ = softmax_cross_entropy(scores(params, X), labels)
        if lam:
            loss = loss + lam * ((W1 * W1).sum() + (W2 * W2).sum())
        for p in params:
            p.grad = np.zeros_like(p.data)
        loss.backward()
        opt.step()
        if log_every and epoch % log_every == 0:
            log.append((epoch, *evaluate(params, X, labels), *evaluate(params, X_val, y_val)))
    return params, log

print("\n64 hidden units (451 knobs), no regularization:")
print(" epoch   train loss  train acc   val loss  val acc")
params, log = train(X_train, y_train, log_every=10)
for epoch, tl, ta, vl, va in log:
    if epoch in (10, 50, 110, 300, 600, 1000, 2000, 3000):
        print(f"{epoch:6}   {tl:9.3f}   {ta:7.1%}   {vl:8.3f}  {va:6.1%}")
best = min(log, key=lambda row: row[3])
print(f"lowest validation loss at epoch {best[0]}: {best[3]:.3f} ({best[4]:.1%})")

print("\nsame network, 3,000 epochs, with an L2 penalty:")
for lam in [0.0001, 0.001, 0.003, 0.01]:
    p, _ = train(X_train, y_train, lam=lam)
    tl, ta = evaluate(p, X_train, y_train)
    vl, va = evaluate(p, X_val, y_val)
    print(f"  lam = {lam:<7} train {tl:.3f} ({ta:.1%})   validation {vl:.3f} ({va:.1%})")

# === 3. The final exam: the test set, once ===========================================
print("\ntest set (1,000 days never used for any choice):")
candidates = [("no regularization, 3,000 epochs", train(X_train, y_train)[0]),
              (f"early stopping at epoch {best[0]}", train(X_train, y_train, epochs=best[0])[0]),
              ("L2, lam = 0.003", train(X_train, y_train, lam=0.003)[0])]
X_more, y_more, _ = make_days(1500, 10)
candidates.append(("1,500 training days, no regularization", train(X_more, y_more)[0]))
for name, p in candidates:
    vl, va = evaluate(p, X_val, y_val)
    tl, ta = evaluate(p, X_test, y_test)
    print(f"  {name:40} validation {va:.1%}   test {ta:.1%} (loss {tl:.3f})")

5. Practice

Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.

P1 How many knobs does a 3-64-3 network have, counting biases?
W₁ is (64 × 3), b₁ has 64, W₂ is (3 × 64), b₂ has 3.
Solution. 192 + 64 + 192 + 3 = 451, three times as many knobs as there are training days.
P2 A weight w = 3 sits under an L2 penalty with λ = 0.01. How much does the penalty add to its gradient?
The penalty λ·w² has derivative 2λw.
Solution. 2 · 0.01 · 3 = 0.06, a steady pull towards zero on top of the data's gradient.
P3 Plain gradient descent with η = 0.1 and an L2 penalty λ = 0.01. By what factor is each weight multiplied before the usual update?
w ← (1 − 2ηλ)·w − η·∂L/∂w.
Solution. 1 − 2 · 0.1 · 0.01 = 0.998: each step decays every weight by 0.2%.
P4 The rides were generated with noise of spread σ = 1.5 minutes. What is the lowest average squared error any model can expect on new rides?
The noise term in the bias–variance sum is σ².
Solution. 1.5² = 2.25. The straight line's validation error of 2.18 is already at that floor.
P5 A model has bias² = 1.0 and variance = 0.5 on data with noise variance 2.25. What average error should it expect on new data?
Add the three terms.
Solution. 1.0 + 0.5 + 2.25 = 3.75.
P6 Validation losses logged during training are 0.453 at epoch 50, 0.429 at epoch 110 and 0.434 at epoch 150. Which epoch does early stopping keep?
Pick the lowest validation loss.
Solution. Epoch 110, with 0.429. Training loss keeps falling after it, but new-data loss doesn't.
P7 Ridge shrinks the coefficient along a direction with eigenvalue s by the factor s / (s + λN). With s = 3 and λN = 1, what is the factor?
3 / (3 + 1).
Solution. 0.75: a direction the data pins down well keeps three quarters of its size. With s = 0.01 the factor would be about 0.01.
P8 After 3,000 epochs the network scores 100.0% on the training days and 86.2% on the validation days. How large is the gap, in percentage points?
Subtract.
Solution. 100.0 − 86.2 = 13.8 points. A gap that wide is the clearest sign of memorization.

6. Go further

  1. Add dropout: during training, multiply the hidden layer's output by a random mask that zeroes each unit with probability p = 0.5 and multiplies the survivors by 1/(1 − p) = 2. Use no mask when evaluating. Train the 3-64-3 network for 3,000 epochs with dropout instead of L2. Where does its validation accuracy end up, and why is the 1/(1 − p) factor needed?
  2. Choose the polynomial degree using only the twelve training rides: leave each ride out in turn, fit on the other eleven, and score the one you left out (leave-one-out cross-validation). Average the twelve scores for each degree from 1 to 9. Which degree does it pick, and how close is its estimate to the true validation error?
  3. Draw a learning curve: train the unregularized 3-64-3 network for 3,000 epochs on 25, 50, 100, 150, 300, 600 and 1,500 days (all from make_days with different seeds) and record validation accuracy for each. How does the gap to the rule's 94.1% shrink as the training set grows?

7. Check yourself

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 A model's training loss keeps falling while its validation loss has been rising for hundreds of epochs. What is happening?
Q2/5 Why keep a test set separate from the validation set?
Q3/5 What does an L2 penalty do to a weight during a plain gradient-descent step?
Q4/5 The degree-9 polynomial fits the twelve training rides better than the straight line. Why does it do so much worse on the other 228 rides?
Q5/5 Of the four networks in the final test, which did best, and why?

Finished this chapter?