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.
Inspect Architecture: Multi-layer perceptron1. 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.
2. The math
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ŷ = 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 minrides: 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)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 anotherL_reg(θ) = L(θ) + λ · Σ w² ∂L_reg/∂w = ∂L/∂w + 2λ·w
polynomial (ridge): c = (AᵀA + λN·I)⁻¹ Aᵀygradient 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%λ = 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)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%)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%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%• 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
4. The code (python)
# 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.
6. Go further
- 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?
- 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?
- 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
In the catalog
Finished this chapter?