Monocurl Essays
Series Download

Overfitting: when a model memorizes instead of learning

Fit polynomials of degree 1, 3 and 9 to ten noisy points, watch training and test error part ways, and derive the bias-variance decomposition.

21 min·Early college·

AssumesWhat "learning" means: fitting a line to dataMean, median and standard deviation: what each one says

Least-squares polynomials of degree 1 to 9 fitted to ten noisy points (black) from the blue curve $y = \sin \pi x$. The training error is measured on the black points and falls to zero. The test error is measured on twenty new points (rings) and ends more than nine times worse than at degree 3.
Monocurl source
let Read = |d| [
    center{[2.65, 1.2, 0]} Math(to_string(d), 1.25, EMBER),
    center{[2.65, 0.0, 0]} Math(to_string(mse(FITS[d], TX, TY), 3), 1.0, INK),
    center{[2.65, -1.2, 0]} Math(to_string(mse(FITS[d], HX, HY), 3), 1.0, AZURE)
]
mesh plot = DataPlot(fm)
mesh true_curve = Truth(fm)
mesh held = Rings(fm, HX, HY)
mesh fit = PolyCurve(fm, FITS[1])
mesh pts = Dots(fm, TX, TY)
mesh names = [
    center{[2.65, 1.75, 0]} Note("degree", 0.76),
    center{[2.65, 0.48, 0]} Cap("training error", 0.74, INK),
    center{[2.65, -0.72, 0]} Cap("test error", 0.74, AZURE)
]
mesh read = Read(1)

slide "degrees"
    play Wait(1)
    for (d in range(2, 10)) {
        read = []
        play Fade(0.2, [&read])
        fit = PolyCurve(fm, FITS[d])
        read = Read(d)
        play Fade(0.5)
        play Wait(0.45)
    }
    play Wait(1.5)

Uses the shared definitions for this lesson's figures.

Here are ten data points and nine curves fitted to them. The last curve, a polynomial of degree 9, passes through all ten points exactly. Its error on the data is zero, and no model could do better than that. It is also the worst of the nine at the one job a model has, which is to predict points it has not been shown.

So a perfect score on the data in hand can go with poor predictions, and we need to understand how. The trouble is that we never see the error we care about while we are fitting. We see the error on the data we have, and the fitting procedure is busy making exactly that number small.

The data, the models and the fit

To study the problem we need a source of data whose truth we know. Ours produces, for an input $x$ between $-1$ and $1$, the output

$$ y = \sin(\pi x) + \varepsilon, $$

where $\varepsilon$ is noise: a random number, drawn afresh for each observation from a normal distribution with mean 0 and standard deviation $\sigma = 0.3$. In real problems the function $f(x) = \sin(\pi x)$ is the unknown thing we are after. Here we know it, so we can check our work. The ten training points are

$x$$-0.9$$-0.7$$-0.5$$-0.3$$-0.1$$0.1$$0.3$$0.5$$0.7$$0.9$
$y$$-0.49$$-1.39$$-0.40$$-1.18$$-0.42$$0.57$$1.27$$1.23$$0.61$$0.61$

The models are polynomials. The model of degree $d$ is

$$ \hat y = w_0 + w_1x + w_2x^2 + \dots + w_dx^d, $$

with $d + 1$ adjustable weights. As in the lesson on fitting a line, the training error of a model is its mean squared error on the $n = 10$ training points, $\frac1n\sum_i (\hat y_i - y_i)^2$, and to fit the model is to choose the weights that make the training error as small as possible. This is the method of least squares.

For degree 1 the fit can be done by hand. The inputs are symmetric about 0, so $\sum x_i = 0$, and the least-squares line $\hat y = w_0 + w_1x$ then has $w_0 = \bar y$, the mean of the outputs, and $w_1 = \sum x_iy_i / \sum x_i^2$. From the table, $\bar y = 0.041$, $\sum x_iy_i = 4.039$ and $\sum x_i^2 = 3.3$, so the line is $\hat y = 0.041 + 1.224x$. Its training error is $0.314$.

For a general degree the recipe is the same with more unknowns. Setting the partial derivative of the training error with respect to each weight to zero gives $d + 1$ linear equations in the $d + 1$ weights,

$$ \sum_{k=0}^{d}\Bigl(\sum_i x_i^{\,j+k}\Bigr)w_k = \sum_i x_i^{\,j}\,y_i \qquad (j = 0, 1, \dots, d), $$

called the normal equations. Every fit in the figures of this lesson is computed in the figure by solving these equations with Gaussian elimination.

  1. Degree 1, the line found by hand. It misses most points by a wide margin.
  2. Degree 3. Two more weights let the curve bend twice, and the training error falls by more than half.
  3. Degree 9. Ten weights, ten points: the curve passes through every point, and the training error is zero.
Three least-squares fits to the ten training points. The gray segments are the misses, and the training error is the mean of their squares.
Monocurl source
let Misses = |w| map(range(0, 10), |i| Seg(at(fm, TX[i], TY[i]), at(fm, TX[i], clamp(-2.2, peval(w, TX[i]), 2.2)), SLATE, 2.5))
let Read = |d| [
    center{[2.65, 1.2, 0]} Math(to_string(d), 1.25, EMBER),
    center{[2.65, 0.0, 0]} Math(to_string(d + 1), 1.0, INK),
    center{[2.65, -1.2, 0]} Math(to_string(mse(FITS[d], TX, TY), 3), 1.0, INK)
]
mesh plot = DataPlot(fm)
mesh misses = []
mesh fit = []
mesh pts = Dots(fm, TX, TY)
mesh names = [
    center{[2.65, 1.75, 0]} Note("degree", 0.76),
    center{[2.65, 0.48, 0]} Note("weights", 0.76),
    center{[2.65, -0.72, 0]} Cap("training error", 0.74, INK)
]
mesh read = []

slide "Degree 1, the line found by hand. It misses most points by a wide margin."
    misses = Misses(FITS[1])
    fit = PolyCurve(fm, FITS[1])
    read = Read(1)
    play Fade(0.7)

slide "Degree 3. Two more weights let the curve bend twice, and the training error falls by more than half."
    read = []
    play Fade(0.25, [&read])
    misses = Misses(FITS[3])
    fit = PolyCurve(fm, FITS[3])
    read = Read(3)
    play Fade(0.7)

slide "Degree 9. Ten weights, ten points: the curve passes through every point, and the training error is zero."
    read = []
    play Fade(0.25, [&read])
    misses = Misses(FITS[9])
    fit = PolyCurve(fm, FITS[9])
    read = Read(9)
    play Fade(0.7)

Uses the shared definitions for this lesson's figures.

Pause and try

The training errors for degrees $0$ to $9$ are $0.808, 0.314, 0.309, 0.122, 0.119, 0.097, 0.056, 0.020, 0.003, 0$. They never go up. Why is that guaranteed, and why is the last one exactly zero?

Show answerHide answer

Every polynomial of degree $d$ is also a polynomial of degree $d + 1$, with $w_{d+1} = 0$. So the degree $d + 1$ fit chooses the best from a larger collection that contains the degree-$d$ fit, and its training error cannot be larger. At degree 9 there are ten weights, and asking the curve to pass through ten points with distinct inputs is ten linear equations in ten unknowns. They have exactly one solution: if two polynomials of degree at most 9 agreed at ten inputs, their difference would have ten roots, and a nonzero polynomial of degree at most 9 has at most 9 roots.

Error on points the model has not seen

By the only score we have, degree 9 wins outright. Before testing it, one number should make us suspicious.

Pause and try

Suppose you were handed the true function $f(x) = \sin(\pi x)$ and used it as your model. Each observation is $y = f(x) + \varepsilon$. What would its mean squared error be, on average? Compare with the degree-9 fit’s training error.

Show answerHide answer

The miss at each point would be exactly the noise $\varepsilon$, so the mean squared error would average $\sigma^2 = 0.09$, the variance of the noise. (On our ten points it comes to $0.134$.) No model can be expected to beat $0.09$ on fresh data, because the noise in a new observation is unpredictable. A training error of 0 is below this floor, which means the degree-9 curve has matched the noise in these ten outputs along with the signal.

To find out what that costs, draw twenty new points from the same source and compare.

A test error is an estimate of the expected test error, in the same way that a sample mean estimates a population mean.

Training error (black) and test error on twenty new points (blue) for the least-squares fit of each degree from 0 to 9. The dashed line is the noise variance $0.09$, which no model can be expected to beat on new data.
Monocurl source
let fe = Frame(0, 9, 0, 1.4, [-0.75, 0.05, 0], 5.0, 3.0)
let TRAIN = map(range(0, 10), |d| mse(FITS[d], TX, TY))
let TEST = map(range(0, 10), |d| mse(FITS[d], HX, HY))
let Trace = |vals, k, col| [
    Path(map(range(0, max(k, 1) + 1), |d| at(fe, d, vals[d])), col, 3.5),
    map(range(0, k + 1), |d| Pt(at(fe, d, vals[d]), col, 0.075))
]
let Run = |u| {
    let k = min(to_int(floor(u + 0.0001)), 9)
    return [
        Trace(TRAIN, k, INK), Trace(TEST, k, AZURE),
        center{[2.9, 1.2, 0]} scale{1.25} color{EMBER} Number(k, 0),
        center{[2.9, 0.0, 0]} scale{1.0} color{INK} Number(TRAIN[k], 3),
        center{[2.9, -1.2, 0]} scale{1.0} color{AZURE} Number(TEST[k], 3)
    ]
}
mesh plot = [
    GridLines(fe, range(1, 10), [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4]),
    Seg(at(fe, 0, 0), at(fe, 9, 0), SLATE, 2.5),
    Seg(at(fe, 0, 0), at(fe, 0, 1.4), SLATE, 2.5),
    map(range(0, 10), |d| center{[fx(fe, d), fy(fe, 0) - 0.3, 0]} Math(to_string(d), 0.72, SLATE)),
    map([0, 0.4, 0.8, 1.2], |v| center{[fx(fe, 0) - 0.38, fy(fe, v), 0]} Math(to_string(v, 1), 0.72, SLATE)),
    center{[fx(fe, 4.5), fy(fe, 0) - 0.66, 0]} Note("degree of the polynomial", 0.74),
    center{[fx(fe, 0) + 1.3, fy(fe, 1.4) + 0.28, 0]} Note("mean squared error", 0.74),
    stroke{LEAF, 2.5} dashed{[0.12, 0.08]} Line(at(fe, 0, 0.09), at(fe, 9, 0.09))
]
mesh names = [
    center{[2.9, 1.75, 0]} Note("degree", 0.76),
    center{[2.9, 0.48, 0]} Cap("training", 0.74, INK),
    center{[2.9, -0.72, 0]} Cap("test", 0.74, AZURE)
]
mesh run = Run(u: 0)
mesh tags = []

slide "sweep"
    play Wait(0.8)
    run.u = 9
    play Lerp(6, [&run], linear)
    tags = [
        center{at(fe, 1.3, 0.95)} Cap("too rigid", 0.74, ROSE),
        center{at(fe, 6.6, 1.15)} Cap("too flexible", 0.74, ROSE)
    ]
    play Fade(0.5, [&tags])
    play Wait(1.6)

Uses the shared definitions for this lesson's figures.

The two curves start together and part ways. At degree 1 the training error is $0.314$ and the test error $0.250$: both are high, because no straight line can follow a sine wave. That failure is called underfitting. At degree 3 they are $0.122$ and $0.133$, close to each other and not far above the floor of $0.09$. From there the training error keeps falling while the test error turns around and climbs: $0.381$ at degree 7 and $1.257$ at degree 9, against a training error of exactly 0. A model whose training error is low and whose test error is much higher is overfitting. At $x = -0.8$, where the truth is $\sin(-0.8\pi) = -0.59$, the degree-9 curve predicts $-3.54$.

Why flexible models fit the noise

To see why passing closer to the data can make a curve worse, remember that our ten outputs are one draw of the noise. Suppose the same ten inputs were measured again. The values of $f$ would be the same, the noise would be new, and each fit would come out differently. The next figure does this twenty times.

Twenty reruns of the experiment: the same ten inputs, fresh noise each time (seeded in the scene), and a least-squares fit of degree 1, 3 and 9 to each. The blue curve is the truth. Lines hardly move and are wrong in the same way every time. Degree-9 fits are right on average and wild individually.
Monocurl source
let wrap = |v| mod(v + 1, 1)
let gauss = |s, t| sqrt(-2 * ln(max(wrap(seeded_random(s, t)), 0.000001))) * cos(TAU * wrap(seeded_random(s, t + 7)))
let DEGS = [1, 3, 9]
let fp = |j| Frame(-1, 1, -2.6, 2.6, [-2.3 + 2.4 * j, -0.1, 0], 2.05, 2.9)
let XS = sample(-1, 1, 61)
# RERUN[j][k] is the k-th refit of degree DEGS[j], as a list of data points along the curve
let RERUN = map([0, 1, 2], |j| map(range(0, 20), |k| {
    let ys = map(range(0, 10), |i| truth(TX[i]) + SIGMA * gauss(k * 10 + i + 1, 3))
    let w = polyfit(TX, ys, DEGS[j])
    return map(XS, |x| [x, peval(w, x)])
}))
let Show = |n| map([0, 1, 2], |j| map(take(RERUN[j], to_int(floor(n + 0.0001))), |c| Clipped(fp(j), c, [0.89, 0.42, 0.12, 0.55], 1.8)))
mesh panels = map([0, 1, 2], |j| [
    DataPlot(fp(j), [-2, 0, 2], 0, [1, 0, 0][j]),
    center{[-2.3 + 2.4 * j, 1.75, 0]} Cap(str_join(["degree ", to_string(DEGS[j])]), 0.76, INK)
])
mesh fits = Show(n: 1)
mesh true_curves = map([0, 1, 2], |j| Curve(fp(j), truth, AZURE, 3))

slide "reruns"
    play Wait(0.6)
    fits.n = 20
    play Lerp(6, [&fits], linear)
    play Wait(1.8)

Uses the shared definitions for this lesson's figures.

There are two different ways to be wrong in this picture. The lines are all nearly the same line, and all of them cut across the sine wave: the average of the fits is itself far from the truth. The degree-9 curves have the opposite character. Their average is almost exactly the truth, yet each individual curve swings far from it, because each one chases the noise of its own training set. We have only one training set, so we get one curve from the bundle, and how good it is depends on both things: where the bundle is centered and how widely it spreads.

To make this exact, fix an input $x_0$ and write $E[\,\cdot\,]$ for the mean over repeated draws of everything random.

The proof used two facts about means: the mean of a sum is the sum of the means, and the mean of a product of independent quantities is the product of their means. Each clause of the statement was needed. Independence of the new observation killed the term $E[AC]$, and it fails if the test point was used in training. The mean-zero noise is what lets us call $f$ the target.

In our experiment the ten inputs stay fixed and only the noise is redrawn, so $E$ averages over the noise in the ten training outputs and in the new observation. For least squares the three terms can then be computed exactly, because the fitted weights are a linear function of the outputs. At $x_0 = -0.8$ the line has bias$^2 = 0.039$ and variance $0.026$. The cubic has $0.004$ and $0.037$. The degree-9 fit has bias$^2 = 0$ to four decimals and variance $3.39$: a typical prediction there is off by $\sqrt{3.39} \approx 1.8$. Averaging over inputs between $-0.9$ and $0.9$ gives the next figure.

The expected test error of each degree, split by the theorem into noise (gray), squared bias (blue) and variance (orange), averaged over inputs from -0.9 to 0.9 and computed exactly in the scene. The rings are the test errors measured on our twenty points.
The expected test error of each degree, split by the theorem into noise (gray), squared bias (blue) and variance (orange), averaged over inputs from $-0.9$ to $0.9$ and computed exactly in the scene. The rings are the test errors measured on our twenty points.
Monocurl source
let GX = sample(-0.9, 0.9, 37)
let unit = |i| map(range(0, 10), |k| [0, 1][k == i])
# the fit is linear in the outputs: prediction(x) = sum_i y_i * ell_i(x), where ell_i is the fit to the i-th unit vector
let var_of = |d| {
    let ells = map(range(0, 10), |i| polyfit(TX, unit(i), d))
    return mean(map(GX, |x| SIGMA ^ 2 * sum(map(ells, |w| peval(w, x) ^ 2))))
}
let bias_of = |d| {
    let w = polyfit(TX, map(TX, truth), d)
    return mean(map(GX, |x| (peval(w, x) - truth(x)) ^ 2))
}
let fb = Frame(-0.6, 9.6, 0, 1.4, [-0.2, 0.05, 0], 6.0, 3.0)
let Slab = |d, lo, hi, col| Region([at(fb, d - 0.3, lo), at(fb, d + 0.3, lo), at(fb, d + 0.3, hi), at(fb, d - 0.3, hi)], col)
mesh grid = [
    GridLines(fb, [], [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4]),
    map([0, 0.4, 0.8, 1.2], |v| center{[fx(fb, -0.6) - 0.38, fy(fb, v), 0]} Math(to_string(v, 1), 0.72, SLATE)),
    map(range(0, 10), |d| center{[fx(fb, d), fy(fb, 0) - 0.3, 0]} Math(to_string(d), 0.72, SLATE)),
    center{[fx(fb, 4.5), fy(fb, 0) - 0.66, 0]} Note("degree of the polynomial", 0.74),
    center{[fx(fb, -0.6) + 1.5, fy(fb, 1.4) + 0.28, 0]} Note("expected test error", 0.74)
]
mesh bars = map(range(0, 10), |d| {
    let b = bias_of(d)
    let v = var_of(d)
    return [
        Slab(d, 0, SIGMA ^ 2, MIST),
        Slab(d, SIGMA ^ 2, SIGMA ^ 2 + b, AZURE),
        Slab(d, SIGMA ^ 2 + b, SIGMA ^ 2 + b + v, EMBER)
    ]
})
mesh axis = [
    Seg(at(fb, -0.6, 0), at(fb, 9.6, 0), SLATE, 2.5),
    Seg(at(fb, -0.6, 0), at(fb, -0.6, 1.4), SLATE, 2.5)
]
mesh measured = map(range(0, 10), |d| Ring(at(fb, d, mse(FITS[d], HX, HY)), INK, 0.08, 2.5))
mesh legend = [
    Slab(2.2, 1.22, 1.32, MIST), center{at(fb, 3.15, 1.27)} Cap("noise", 0.72, SLATE),
    Slab(2.2, 1.02, 1.12, AZURE), center{at(fb, 3.3, 1.07)} Cap("bias²", 0.72, AZURE),
    Slab(2.2, 0.82, 0.92, EMBER), center{at(fb, 3.6, 0.87)} Cap("variance", 0.72, EMBER),
    Ring(at(fb, 5.3, 1.27), INK, 0.08, 2.5), center{at(fb, 6.7, 1.27)} Cap("our test set", 0.72, INK)
]

Uses the shared definitions for this lesson's figures.

As the degree rises, the squared bias collapses, from $0.156$ at degree 1 to $0.003$ at degree 3 and essentially nothing after that, while the variance creeps up from $0.017$ to $0.031$ and then explodes to $0.568$ at degree 9. Their sum is smallest at degree 3. Our twenty test points are one sample, and they follow the same shape.

Choosing the degree without cheating

We picked out degree 3 by looking at the test error. That was legitimate for studying the phenomenon and would be cheating in practice, for the same reason that the training error misleads. If the twenty points are used to choose among ten models, the winner’s error on those points is partly luck: the minimum of ten noisy estimates tends to lie below the true value of the one that wins. Data used to make a choice has been used for training.

The remedy is to hold out data twice. Split the twenty new points into two sets of ten. The first ten are the validation set: the degree is chosen by the error on them. The last ten are the test set, looked at once, after every choice has been made.

The three-way split. Left: training points (black), validation points (purple) and test points (blue), with the degree-3 fit. Right: the validation error of each degree. Degree 3 has the smallest, and its error on the untouched test set is reported once.
The three-way split. Left: training points (black), validation points (purple) and test points (blue), with the degree-3 fit. Right: the validation error of each degree. Degree 3 has the smallest, and its error on the untouched test set is reported once.
Monocurl source
let fl = Frame(-1, 1, -2.2, 2.2, [-1.85, 0.0, 0], 3.3, 3.2)
let fr = Frame(-0.6, 9.6, 0, 1.2, [2.05, -0.2, 0], 2.9, 2.5)
let VAL = map(range(0, 10), |d| mse(FITS[d], VX, VY))
let best = min_of(range(0, 10), |d| VAL[d])
mesh left = [
    DataPlot(fl),
    PolyCurve(fl, FITS[best], EMBER, 3.5),
    Rings(fl, SX, SY, AZURE, 0.075),
    Rings(fl, VX, VY, PLUM, 0.075),
    Dots(fl, TX, TY)
]
mesh right = [
    GridLines(fr, [], [0.4, 0.8, 1.2]),
    Seg(at(fr, -0.6, 0), at(fr, 9.6, 0), SLATE, 2.5),
    Seg(at(fr, -0.6, 0), at(fr, -0.6, 1.2), SLATE, 2.5),
    map([0, 0.4, 0.8, 1.2], |v| center{[fx(fr, -0.6) - 0.36, fy(fr, v), 0]} Math(to_string(v, 1), 0.72, SLATE)),
    map([0, 3, 6, 9], |d| center{[fx(fr, d), fy(fr, 0) - 0.3, 0]} Math(to_string(d), 0.72, SLATE)),
    center{[fx(fr, 4.5), fy(fr, 0) - 0.66, 0]} Note("degree", 0.74),
    map(range(0, 10), |d| Region([at(fr, d - 0.3, 0), at(fr, d + 0.3, 0), at(fr, d + 0.3, VAL[d]), at(fr, d - 0.3, VAL[d])], [tint(PLUM, 0.55), LEAF][d == best]))
]
mesh notes = [
    center{[2.05, 1.95, 0]} Cap("validation error", 0.74, PLUM),
    center{at(fr, 3.2, 0.95)} Cap(str_join(["degree ", to_string(best)]), 0.74, LEAF),
    center{at(fr, 3.2, 0.72)} Math(to_string(VAL[best], 3), 0.8, LEAF),
    center{[2.05, 1.5, 0]} Math(str_join(["\text{test error of the choice: }", to_string(mse(FITS[best], SX, SY), 3)]), 0.74, AZURE)
]

Uses the shared definitions for this lesson's figures.

The validation errors for degrees 0 to 9 are $0.667, 0.326, 0.378, 0.134, 0.134, 0.144, 0.195, 0.403, 0.670, 1.133$. Degree 3 is the smallest, ahead of degree 4 only in the fourth decimal place, and when two models tie the simpler one is the safer choice. Its error on the ten test points is $0.132$. That is the number to report, and the validation error $0.134$ is not, even though here they happen to agree.

Ridge regression: penalizing large weights

Lowering the degree is a blunt way to control variance: it removes weights one at a time. There is a finer one, and a look at the fitted weights suggests it. The cubic fit is $0.12 + 3.03x - 0.24x^2 - 3.08x^3$. The degree-9 fit has weights as large as $292$ and $-211$, which nearly cancel one another to pass through the points and fail to cancel in between. Wild curves need large weights. So keep all ten weights, and charge the model for making them large.

Setting $\lambda = 0$ gives least squares back. To see what a positive $\lambda$ does, take degree 1 on our data. With $\sum x_i = 0$ the constant is still $\bar y$, and setting the derivative with respect to $w_1$ to zero gives $\frac2n\sum_i (\bar y + w_1x_i - y_i)x_i + 2\lambda w_1 = 0$, that is,

$$ w_1 = \frac{\sum x_iy_i}{\sum x_i^2 + n\lambda} = \frac{4.039}{3.3 + 10\lambda}. $$

Pause and try

Compute the ridge slope for $\lambda = 0$, $0.1$ and $1$. What happens as $\lambda$ grows without bound?

Show answerHide answer

$4.039/3.3 = 1.224$, $4.039/4.3 = 0.939$ and $4.039/13.3 = 0.304$. The penalty adds $n\lambda$ to the denominator, so the slope shrinks toward 0 as $\lambda$ grows, and the fit flattens toward the constant $\bar y$.

In general the penalty adds $n\lambda$ to the diagonal coefficient of each normal equation with $j \ge 1$, and the equations are solved as before. Ridge trades variance for bias: shrinking the weights makes the fit less sensitive to the noise in the outputs and pulls it away from the least-squares answer. The right amount is a choice like the degree, and it is made the same way, on the validation set.

The degree-9 ridge fit as the strength $\lambda$ goes from $10^{-7}$ to $1$ and back to $10^{-3}$, where the validation error is close to its smallest. Too little penalty leaves the wild curve, too much flattens it. Right: training error (black) and validation error (purple) against $\lambda$.
Monocurl source
let fl = Frame(-1, 1, -2.2, 2.2, [-1.9, 0.1, 0], 3.2, 2.8)
let fr = Frame(-7, 0, 0, 0.8, [2.0, 0.1, 0], 2.8, 2.8)
let US = sample(-7, 0, 29)
let RIDGE = map(US, |u| polyfit(TX, TY, 9, 10 ^ u))
let Sweep = |u| {
    let w = polyfit(TX, TY, 9, 10 ^ u)
    return [
        PolyCurve(fl, w, EMBER, 4),
        Seg(at(fr, u, 0), at(fr, u, mse(w, TX, TY)), EMBER, 2.5),
        Seg(at(fr, u, mse(w, VX, VY)), at(fr, u, 0.8), EMBER, 2.5),
        Pt(at(fr, u, mse(w, VX, VY)), PLUM, 0.09),
        Pt(at(fr, u, mse(w, TX, TY)), INK, 0.09),
        center{[2.55, 1.85, 0]} scale{1.0} color{PLUM} Number(mse(w, VX, VY), 3)
    ]
}
mesh plots = [
    DataPlot(fl),
    Truth(fl),
    GridLines(fr, [-6, -4, -2], [0.2, 0.4, 0.6, 0.8]),
    Seg(at(fr, -7, 0), at(fr, 0, 0), SLATE, 2.5),
    Seg(at(fr, -7, 0), at(fr, -7, 0.8), SLATE, 2.5),
    map([-6, -4, -2, 0], |e| center{[fx(fr, e), fy(fr, 0) - 0.3, 0]} Math(str_join(["10^{", to_string(e), "}"]), 0.72, SLATE)),
    map([0, 0.4, 0.8], |v| center{[fx(fr, -7) - 0.36, fy(fr, v), 0]} Math(to_string(v, 1), 0.72, SLATE)),
    center{[fx(fr, -3.5), fy(fr, 0) - 0.68, 0]} Math("\text{strength } \lambda", 0.76, SLATE),
    Path(map(range(0, 29), |i| at(fr, US[i], mse(RIDGE[i], TX, TY))), INK, 3),
    Path(map(range(0, 29), |i| at(fr, US[i], mse(RIDGE[i], VX, VY))), PLUM, 3),
    center{[-1.9, 1.85, 0]} Cap("degree 9 with ridge", 0.74, EMBER),
    center{[1.45, 1.85, 0]} Cap("validation", 0.74, PLUM),
    center{at(fr, -5.3, 0.14)} Cap("training", 0.72, INK)
]
mesh sweep = Sweep(u: -7)
mesh pts = [Rings(fl, VX, VY, PLUM, 0.07), Dots(fl, TX, TY)]

slide "strength"
    play Wait(0.8)
    sweep.u = 0
    play Lerp(5, [&sweep], linear)
    play Wait(0.5)
    sweep.u = -3
    play Lerp(2, [&sweep])
    play Wait(1.8)

Uses the shared definitions for this lesson's figures.

On a grid of strengths $10^{-7}, 10^{-6.5}, \dots, 1$ the validation error is smallest at $\lambda = 10^{-3}$, where it is $0.145$. The training error there is $0.103$, no longer zero, and the largest weight is $3.2$ in size where it was $292$. On the test set this model scores $0.151$, against $1.380$ for the unpenalized degree-9 fit and $0.132$ for the cubic. With the penalty, ten weights predict about as well as the cubic’s four.

Ridge and a lower degree both reduce variance by restricting the model. A third cure restricts nothing: more data. The noise in many observations averages out, and the variance of a fit shrinks roughly in proportion to $1/n$, as the last problem makes precise. With enough points, degree 9 stops being too flexible.

Worked examples

Stepping back

The difficulty here was that the quantity we can compute and the quantity we care about come apart, and the fitting procedure pushes on the first. The decomposition says exactly how they come apart: flexibility buys low bias and pays for it in variance, and the variance is invisible from inside the training set. Held-out data makes it visible, and a penalty or a smaller model or more data brings it down.

None of this was special to polynomials. A neural network with millions of weights is far more flexible than a degree-9 polynomial, and training and validation error are tracked for it in the same way.

Check yourself

A team fits models of increasing size. For the largest, the training error is $0.01$ and the validation error is $0.90$; for a mid-sized one both are about $0.15$. What describes the largest model?

Problems

The first three are computations, the next two ask for explanations, and the last two go beyond the lesson.

Problem 1

For the three training points $(-1, 1)$, $(0, 0)$, $(1, 2)$ of the first worked example, fit the degree-0 model $\hat y = w_0$. Give its training error and its squared error on the test point $(2, 2.5)$, and rank the models of degree 0, 1 and 2 by each kind of error.

Show solutionHide solution

The constant that minimizes the mean squared error is the mean, $w_0 = \bar y = 1$. The misses are $0, 1, -1$, so the training error is $\frac{0 + 1 + 1}{3} = 0.667$. On the test point the squared error is $(1 - 2.5)^2 = 2.25$. Training errors: degree 0 has $0.667$, degree 1 has $0.5$, degree 2 has $0$, so the ranking from best to worst is $2, 1, 0$. Test errors: $2.25$, $0.25$ and $20.25$, so the ranking is $1, 0, 2$. The model that is best on the training set is worst on the test point.

Problem 2

For the lesson’s ten training points, the ridge fit of degree 1 has slope $w_1 = 4.039/(3.3 + 10\lambda)$. (a) Find the slope for $\lambda = 0.5$. (b) Which $\lambda$ makes the slope exactly half of the least-squares slope?

Show solutionHide solution

(a) $4.039/(3.3 + 5) = 4.039/8.3 = 0.487$. (b) The slope is halved when the denominator doubles: $3.3 + 10\lambda = 6.6$, so $\lambda = 0.33$. In general the slope is halved at $n\lambda = \sum x_i^2$.

Problem 3

Estimating a constant as in the second worked example, take $\mu = 2$, $\sigma^2 = 9$ and $n = 9$. (a) Find the expected squared error of the prediction $c\,\bar y$ for $c = 1$. (b) Find the $c$ that minimizes it, and the minimum.

Show solutionHide solution

The expected squared error is $\sigma^2 + (1 - c)^2\mu^2 + c^2\sigma^2/n = 9 + 4(1 - c)^2 + c^2$. (a) At $c = 1$ it is $9 + 0 + 1 = 10$. (b) The derivative with respect to $c$ is $-8(1 - c) + 2c$, which is zero at $c = 0.8$; the second derivative, 10, is positive, so this is the minimum. The value is $9 + 4(0.04) + 0.64 = 9.8$. Most of the error, 9 out of 9.8, is noise that no estimate can remove.

Problem 4

A classmate argues: “The degree-9 polynomial contains the degree-3 polynomial as a special case, so it can do everything the cubic can. It cannot be the worse model.” Which part of this is true, and where does the argument fail?

Show solutionHide solution

The first part is true: every cubic is a degree-9 polynomial with $w_4 = \dots = w_9 = 0$, and this is exactly why the degree-9 fit has the lower training error. The argument fails in assuming that the fitting procedure will pick the good cubic from the larger collection. Least squares picks whichever member has the lowest training error, and in the larger collection that member is one that also matches the noise. Having good models available is different from choosing them. In the language of the theorem, the larger collection has lower bias, but the choice made from it has higher variance.

Problem 5

A model is tuned by trying 50 values of a setting and keeping the one with the lowest validation error, $0.20$. On the test set the chosen model scores $0.26$. (a) Which number estimates its error on new data? (b) Why is the validation number likely to be the lower of the two? (c) The team now tries 50 more settings and picks the one with the best test error. What have they lost?

Show solutionHide solution

(a) The test error, $0.26$. (b) Each validation error is the true expected error of its model plus a random deviation due to the particular validation points. Selecting the minimum of 50 favors models whose deviation happened to be negative, so the winner’s validation error tends to understate its expected error. The test set took no part in the selection, so its deviation is equally likely to have either sign. (c) They have turned the test set into a second validation set. Its error for the new winner is biased downward for the same reason, and no untouched data remains to give an honest estimate.

Problem 6

(Variance of a fitted line.) Let the training inputs $x_1, \dots, x_n$ be fixed with $\sum x_i = 0$, and let $y_i = f(x_i) + \varepsilon_i$ with independent noise of variance $\sigma^2$. The least-squares line predicts $\hat g(x_0) = \bar y + w_1x_0$ with $w_1 = \sum x_iy_i/\sum x_i^2$. (a) Write $\hat g(x_0)$ as $\sum_i a_iy_i$ and find the $a_i$. (b) Using the fact that the variance of a sum of independent quantities is the sum of their variances, show that the variance of $\hat g(x_0)$ is $\sigma^2\bigl(\frac1n + \frac{x_0^2}{\sum x_i^2}\bigr)$. (c) Evaluate it for the lesson’s design at $x_0 = -0.8$.

Show solutionHide solution

(a) $\bar y = \sum_i \frac1n y_i$ and $w_1x_0 = \sum_i \frac{x_0x_i}{S}y_i$ with $S = \sum x_i^2$, so $a_i = \frac1n + \frac{x_0x_i}{S}$. (b) The $f(x_i)$ are constants, so the variance of $a_iy_i$ is $a_i^2\sigma^2$, and by independence the variance of the sum is $\sigma^2\sum a_i^2$. Expanding, $\sum_i a_i^2 = \sum_i \frac{1}{n^2} + \frac{2x_0}{nS}\sum_i x_i + \frac{x_0^2}{S^2}\sum_i x_i^2 = \frac1n + 0 + \frac{x_0^2}{S}$, where the middle term vanishes because $\sum x_i = 0$. (c) With $\sigma^2 = 0.09$, $n = 10$ and $S = 3.3$: $0.09\,(0.1 + 0.64/3.3) = 0.09 \times 0.2939 = 0.026$, the value quoted in the lesson. The variance is smallest at the center of the data and grows toward the edges.

Problem 7

(More data.) It is a theorem of linear regression that for a least-squares polynomial of degree $d$ fitted to $n > d$ points with distinct inputs and independent noise of variance $\sigma^2$, the variance of the prediction, averaged over the $n$ training inputs, is exactly $\sigma^2(d + 1)/n$. (a) Verify this directly for $d = 0$. (b) Verify it for $d = 9$, $n = 10$. (c) With $\sigma^2 = 0.09$, how many training points does a degree-9 fit need for this average variance to match that of a cubic fitted to 10 points?

Show solutionHide solution

(a) The degree-0 fit predicts $\bar y$ everywhere, and the variance of a mean of $n$ independent observations is $\sigma^2/n = \sigma^2(0 + 1)/n$. (b) With ten points the degree-9 fit passes through every training point, so its prediction at $x_i$ is $y_i = f(x_i) + \varepsilon_i$, whose variance is $\sigma^2 = \sigma^2 \cdot 10/10$. (c) The cubic on 10 points has average variance $0.09 \times 4/10 = 0.036$. Degree 9 on $n$ points has $0.09 \times 10/n$, which equals $0.036$ when $n = 25$. Flexibility is relative to the amount of data: each weight needs its share of observations to pin it down.

Shared definitions used by this lesson's figures
# house kit: prepended to every essay figure after the std imports

# palette. AZURE and EMBER are the two actors, LEAF marks an answer, ROSE a problem
let PAPER = [1, 1, 1, 1]
let INK = [0.08, 0.09, 0.11, 1]
let SLATE = [0.40, 0.44, 0.50, 1]
let MIST = [0.85, 0.86, 0.88, 1]
let AZURE = [0.13, 0.42, 0.68, 1]
let EMBER = [0.89, 0.42, 0.12, 1]
let LEAF = [0.13, 0.55, 0.36, 1]
let ROSE = [0.78, 0.22, 0.24, 1]
let PLUM = [0.47, 0.30, 0.66, 1]
let GOLD = [0.85, 0.62, 0.08, 1]

background = PAPER

# an opaque pastel of col; use for fills so overlaps do not darken
let tint = |col, amount = 0.18| [
    1 - (1 - col[0]) * amount,
    1 - (1 - col[1]) * amount,
    1 - (1 - col[2]) * amount,
    1
]

# text: Cap for labels, Note for quiet labels, Math for typeset maths
let Cap = |txt, size = 0.75, col = INK| color{col} Text(txt, size, "Charter")
let Note = |txt, size = 0.65| color{SLATE} Text(txt, size, "Charter")
let Math = |tex, size = 0.9, col = INK| color{col} Tex(tex, size)

# a paper rectangle to sit behind text that crosses lines
let Plate = |pos, size| center{pos} fill{PAPER} stroke{alpha{0} PAPER, 0} Rect(size)

# shapes with fill and stroke both set, so no stray black appears
let Pt = |pos, col = INK, r = 0.075| center{pos} fill{col} stroke{col, 1} Circle(r, 48)
let Ring = |pos, col = INK, r = 0.1, w = 3| center{pos} fill{PAPER} stroke{col, w} Circle(r, 48)
let Seg = |a, b, col = INK, w = 3| stroke{col, w} Line(a, b)
let Path = |pts, col = INK, w = 3| stroke{col, w} Polyline(pts)
let Region = |pts, col| fill{col} stroke{alpha{0} col, 0} Polygon(pts)
let Outline = |pts, col = INK, w = 3| fill{alpha{0} PAPER} stroke{col, w} Polygon(pts)
let Shape = |pts, col, amount = 0.18, w = 3| fill{tint(col, amount)} stroke{col, w} Polygon(pts)
let Disc = |pos, r, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Circle(r, 96)
let Box = |pos, size, col, amount = 0.18, w = 3| center{pos} fill{tint(col, amount)} stroke{col, w} Rect(size)
# a line from a to b ending in a small solid arrowhead
let Tip = |a, b, col = INK, w = 3, head = 0.17| {
    let d = normalize([b[0] - a[0], b[1] - a[1], 0])
    let n = [-d[1], d[0], 0]
    let base = [b[0] - d[0] * head, b[1] - d[1] * head, 0]
    return [
        stroke{col, w} Line(a, base),
        fill{col} stroke{col, 1} Polygon([
            b,
            [base[0] + n[0] * head * 0.42, base[1] + n[1] * head * 0.42, 0],
            [base[0] - n[0] * head * 0.42, base[1] - n[1] * head * 0.42, 0]
        ])
    ]
}

# plots. a frame is [x0, x1, y0, y1, centre, width, height]: a data window and the scene box it fills
let Frame = |x0, x1, y0, y1, c = [0, 0, 0], w = 6.4, h = 3.6| [x0, x1, y0, y1, c, w, h]
let fx = |fr, x| fr[4][0] + (x - (fr[0] + fr[1]) / 2) * fr[5] / (fr[1] - fr[0])
let fy = |fr, y| fr[4][1] + (y - (fr[2] + fr[3]) / 2) * fr[6] / (fr[3] - fr[2])
let at = |fr, x, y| [fx(fr, x), fy(fr, y), 0]

# axes through the data origin (or the nearest edge of the window), with optional names
let Axes = |fr, x_name = "", y_name = "", col = SLATE| {
    let ox = clamp(fr[0], 0, fr[1])
    let oy = clamp(fr[2], 0, fr[3])
    var parts = [
        Tip(at(fr, fr[0], oy), [fx(fr, fr[1]) + 0.25, fy(fr, oy), 0], col, 2.5, 0.15),
        Tip(at(fr, ox, fr[2]), [fx(fr, ox), fy(fr, fr[3]) + 0.25, 0], col, 2.5, 0.15)
    ]
    if (x_name != "") {
        parts .= center{[fx(fr, fr[1]) + 0.5, fy(fr, oy), 0]} Math(x_name, 0.8, col)
    }
    if (y_name != "") {
        parts .= center{[fx(fr, ox), fy(fr, fr[3]) + 0.5, 0]} Math(y_name, 0.8, col)
    }
    return parts
}

# tick marks with numbers at the listed data values
let XTicks = |fr, xs, col = SLATE, places = 0| block {
    let oy = fy(fr, clamp(fr[2], 0, fr[3]))
    for (x in xs) {
        . Seg([fx(fr, x), oy - 0.07, 0], [fx(fr, x), oy + 0.07, 0], col, 2)
        . center{[fx(fr, x), oy - 0.33, 0]} Math(to_string(x, places), 0.72, col)
    }
}
let YTicks = |fr, ys, col = SLATE, places = 0| block {
    let ox = fx(fr, clamp(fr[0], 0, fr[1]))
    for (y in ys) {
        . Seg([ox - 0.07, fy(fr, y), 0], [ox + 0.07, fy(fr, y), 0], col, 2)
        . center{[ox - 0.36, fy(fr, y), 0]} Math(to_string(y, places), 0.72, col)
    }
}

# faint grid lines at the listed data values
let GridLines = |fr, xs, ys, col = MIST| block {
    for (x in xs) {
        . Seg(at(fr, x, fr[2]), at(fr, x, fr[3]), col, 1.5)
    }
    for (y in ys) {
        . Seg(at(fr, fr[0], y), at(fr, fr[1], y), col, 1.5)
    }
}

# the graph of f over [a, b], drawn in the frame. values are clamped to the window
let Graph = |fr, f, a, b, col = AZURE, w = 4, n = 160|
    stroke{col, w} Polyline(map(sample(a, b, n), |x| at(fr, x, clamp(fr[2], f(x), fr[3]))))

# the region between the graph of f and the x axis over [a, b]
let AreaUnder = |fr, f, a, b, col = AZURE, amount = 0.2, n = 80| {
    var pts = [at(fr, a, 0)]
    for (x in sample(a, b, n)) {
        pts .= at(fr, x, clamp(fr[2], f(x), fr[3]))
    }
    pts .= at(fr, b, 0)
    return fill{tint(col, amount)} stroke{alpha{0} col, 0} Polygon(pts)
}

# the source: y = sin(pi x) + noise with standard deviation 0.3
let truth = |x| sin(PI * x)
let SIGMA = 0.3
# ten training points
let TX = [-0.9, -0.7, -0.5, -0.3, -0.1, 0.1, 0.3, 0.5, 0.7, 0.9]
let TY = [-0.49, -1.39, -0.4, -1.18, -0.42, 0.57, 1.27, 1.23, 0.61, 0.61]
# twenty held-out points: the first ten become the validation set, the last ten the test set
let VX = [-0.47, 0.01, -0.22, -0.35, -0.86, -0.41, -0.27, 0.13, -0.24, -0.2]
let VY = [-1.02, -0.03, 0.09, -1.46, -0.25, -1.32, -0.79, 0.12, -0.46, -0.59]
let SX = [-0.56, -0.49, -0.67, 0.26, -0.26, 0.17, 0.0, -0.81, 0.54, -0.56]
let SY = [-0.76, -0.41, -0.8, 0.65, -0.77, 0.51, -0.39, -0.27, 1.25, -1.25]
let HX = flatten([VX, SX])
let HY = flatten([VY, SY])

# solve a linear system by Gauss-Jordan elimination with partial pivoting; each row is [a_0 .. a_(n-1), b]
let solve = |rows| {
    let n = len(rows)
    var m = rows
    for (k in range(0, n)) {
        let cur = m
        let kk = k
        var best = k
        for (i in range(k + 1, n)) {
            if (abs(cur[i][kk]) > abs(cur[best][kk])) { best = i }
        }
        let bi = best
        let prow = map(cur[bi], |v| v / cur[bi][kk])
        m = map(range(0, n), |i| {
            let src = [[cur[i], cur[kk]][i == bi], cur[bi]][i == kk]
            return [map(range(0, n + 1), |j| src[j] - src[kk] * prow[j]), prow][i == kk]
        })
    }
    return map(m, |r| r[n])
}
# least-squares polynomial of degree d; lam > 0 adds the ridge penalty lam * (w_1^2 + .. + w_d^2)
let polyfit = |xs, ys, d, lam = 0| {
    let n = len(xs)
    return solve(map(range(0, d + 1), |i| {
        let row = map(range(0, d + 1), |j| sum(map(xs, |x| x ^ (i + j))) + [0, n * lam][i == j and i > 0])
        return row .. sum(map(range(0, n), |t| (xs[t] ^ i) * ys[t]))
    }))
}
let peval = |w, x| {
    var s = 0
    for (j in range(0, len(w))) {
        s = s * x + w[len(w) - 1 - j]
    }
    return s
}
let mse = |w, xs, ys| mean(map(range(0, len(xs)), |i| (peval(w, xs[i]) - ys[i]) ^ 2))
let FITS = map(range(0, 10), |d| polyfit(TX, TY, d))

# plotting
let Clipped = |fr, pts, col, th| block {
    var cur = []
    for (p in pts) {
        if (p[0] >= fr[0] and p[0] <= fr[1] and p[1] >= fr[2] and p[1] <= fr[3]) {
            cur .= at(fr, p[0], p[1])
        } else {
            if (len(cur) >= 2) { . Path(cur, col, th) }
            cur = []
        }
    }
    if (len(cur) >= 2) { . Path(cur, col, th) }
}
let Curve = |fr, f, col = EMBER, th = 4, n = 200| Clipped(fr, map(sample(fr[0], fr[1], n), |x| [x, f(x)]), col, th)
let PolyCurve = |fr, w, col = EMBER, th = 4| Curve(fr, |x| peval(w, x), col, th)
let DataPlot = |fr, ys = [-2, -1, 0, 1, 2], named = 1, ylabels = 1| [
    GridLines(fr, [-1, -0.5, 0, 0.5, 1], ys),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[1], fr[2]), SLATE, 2.5),
    Seg(at(fr, fr[0], fr[2]), at(fr, fr[0], fr[3]), SLATE, 2.5),
    map([-1, 0, 1], |v| center{[fx(fr, v), fy(fr, fr[2]) - 0.3, 0]} Math(to_string(v), 0.72, SLATE)),
    map(take(ys, ylabels * len(ys)), |v| center{[fx(fr, fr[0]) - 0.32, fy(fr, v), 0]} Math(to_string(v), 0.72, SLATE)),
    map(take(["x"], named), |s| center{[fx(fr, 0.5), fy(fr, fr[2]) - 0.3, 0]} Math(s, 0.82, SLATE)),
    map(take(["y"], named), |s| center{[fx(fr, fr[0]) - 0.32, fy(fr, 1.5), 0]} Math(s, 0.82, SLATE))
]
let Truth = |fr| Curve(fr, truth, AZURE, 2.5)
let Dots = |fr, xs, ys, col = INK, r = 0.085| map(range(0, len(xs)), |i| Pt(at(fr, xs[i], ys[i]), col, r))
let Rings = |fr, xs, ys, col = AZURE, r = 0.07| map(range(0, len(xs)), |i| Ring(at(fr, xs[i], ys[i]), col, r, 2.5))
let fm = Frame(-1, 1, -2.2, 2.2, [-1.05, 0.15, 0], 4.7, 3.3)