Classical machine learning all 5 written
Regression, classification and clustering, each built from scratch and measured.
8 Linear regression, trained by hand
Linear regression fits y = wx + b to data that does not
sit on a line. Two parameters, one loss, and an answer you can get two completely different
ways. Everything hard about it survives into every larger model: the best fit is not the
truth, the metric flatters you, and one bad row can move the whole line.
Noisy data, and why the true answer is not the best answer
The data below was made by taking y = 3x - 2 and adding gaussian noise, so
we know the parameters that generated it. That makes it the rare case where you can ask a
question you normally cannot: is the line that made the data also the line that best fits
the data? It is not, and the gradient proves it.
# Twenty points from a line, with noise added. The noise is the entire difficulty.
import random
TRUE_W, TRUE_B = 3.0, -2.0
rng = random.Random(42) # seeded, so these numbers are the same every build
XS = [i * 0.5 for i in range(20)]
NOISE = [rng.gauss(0.0, 1.5) for _ in XS]
YS = [TRUE_W * x + TRUE_B + e for x, e in zip(XS, NOISE)]
def predict(w, b, x):
return w * x + b
def mse(w, b):
"""Mean squared error. One number for how wrong the whole line is."""
n = len(XS)
return sum((predict(w, b, x) - y) ** 2 for x, y in zip(XS, YS)) / n
def gradient(w, b):
"""dL/dw and dL/db, by the chain rule, exactly as in lesson 7."""
n = len(XS)
errors = [predict(w, b, x) - y for x, y in zip(XS, YS)]
dw = sum(2.0 * e * x for e, x in zip(errors, XS)) / n
db = sum(2.0 * e for e in errors) / n
return dw, db
print(" x y noiseless noise")
for i in (0, 1, 2, 3, 18, 19):
line = TRUE_W * XS[i] + TRUE_B
print(" {:4.1f} {:9.4f} {:9.4f} {:+8.4f}".format(XS[i], YS[i], line, YS[i] - line))
honest = mse(TRUE_W, TRUE_B)
print()
print("loss at the parameters that GENERATED the data: {:.6f}".format(honest))
print("the noise variance we asked for : {:.6f}".format(1.5 ** 2))
print("Twenty draws is a small sample. This one came out quieter than requested.")
# Now the part worth sitting with. If w = 3 and b = -2 were the best answer, the
# gradient there would be zero. It is not.
dw, db = gradient(TRUE_W, TRUE_B)
print()
print("gradient there: dL/dw = {:+.6f} dL/db = {:+.6f}".format(dw, db))
nudged = mse(TRUE_W - 0.01 * dw, TRUE_B - 0.01 * db)
print("one small step downhill from them: {:.6f}".format(nudged))
test("the noise moved every single point off the line",
all(not close(y, TRUE_W * x + TRUE_B) for x, y in zip(XS, YS)), True)
test("so the loss at the generating parameters is not zero", honest > 0.0, True)
test("the loss there is exactly the mean squared noise",
close(honest, sum(e * e for e in NOISE) / len(NOISE)), True)
test("the gradient there is not zero either", [close(dw, 0.0), close(db, 0.0)], [False, False])
test("one step downhill from the true parameters lowers the loss", nudged < honest, True)
What it printed, when the build ran it
x y noiseless noise 0.0 -2.2161 -2.0000 -0.2161 0.5 -0.7594 -0.5000 -0.2594 1.0 0.8330 1.0000 -0.1670 1.5 3.5530 2.5000 +1.0530 9.0 25.0625 25.0000 +0.0625 9.5 26.3405 26.5000 -0.1595 loss at the parameters that GENERATED the data: 0.930950 the noise variance we asked for : 2.250000 Twenty draws is a small sample. This one came out quieter than requested. gradient there: dL/dw = -1.273844 dL/db = -0.077312 one small step downhill from them: 0.919768 ok the noise moved every single point off the line ok so the loss at the generating parameters is not zero ok the loss there is exactly the mean squared noise ok the gradient there is not zero either ok one step downhill from the true parameters lowers the loss
Read that last assertion again. The line that made the data is not the line that best fits the data, and there is nothing wrong with either fact. A model is fitted to a sample, and a sample carries the noise it happened to be given. This is where overfitting comes from, and it is why lesson 11 exists: a flexible enough model will chase that noise all the way down, and be worse for it.
Gradient descent, on data that fights back
Same loop as lesson 7 — gradient, step, repeat — but watch the learning rate. It is
0.01 here rather than 0.1, and not by taste. The inputs run up to
9.5 instead of 3, the curvature of the loss scales with the square
of that, and the safe step size shrinks to match.
# Gradient descent on noisy data. Note the learning rate, and why it is small.
import random
TRUE_W, TRUE_B = 3.0, -2.0
rng = random.Random(42)
XS = [i * 0.5 for i in range(20)]
YS = [TRUE_W * x + TRUE_B + rng.gauss(0.0, 1.5) for x in XS]
def mse(w, b):
n = len(XS)
return sum((w * x + b - y) ** 2 for x, y in zip(XS, YS)) / n
def gradient(w, b):
n = len(XS)
errors = [w * x + b - y for x, y in zip(XS, YS)]
return (sum(2.0 * e * x for e, x in zip(errors, XS)) / n,
sum(2.0 * e for e in errors) / n)
def descend(lr, steps, w=0.0, b=0.0):
trail = [(w, b, mse(w, b))]
for _ in range(steps):
dw, db = gradient(w, b)
w -= lr * dw
b -= lr * db
trail.append((w, b, mse(w, b)))
return trail
trail = descend(lr=0.01, steps=5000)
losses = [row[2] for row in trail]
print(" step w b loss")
for step in (0, 1, 2, 5, 20, 100, 500, 1000, 2500, 5000):
w, b, value = trail[step]
print(" {:5} {:10.6f} {:10.6f} {:12.8f}".format(step, w, b, value))
final_w, final_b, final_loss = trail[-1]
print()
print("finished at w = {:.6f}, b = {:.6f}".format(final_w, final_b))
print("true values were w = {}, b = {}".format(TRUE_W, TRUE_B))
print("loss at the true values: {:.8f}".format(mse(TRUE_W, TRUE_B)))
dw, db = gradient(final_w, final_b)
print("gradient at the end: {:+.3e} {:+.3e}".format(dw, db))
# The slope is found in twenty steps. The intercept takes thousands, because only
# the leftmost rows say much about it, and it has to creep across the whole range.
strict = next(i for i in range(len(losses) - 1) if not losses[i + 1] < losses[i])
plateau = max(losses[3000:]) - min(losses[3000:])
print()
print("the loss fell strictly for {} steps, then only jittered by {:.1e}".format(strict, plateau))
test("the loss fell on every one of the first two thousand steps",
all(losses[i + 1] < losses[i] for i in range(2000)), True)
test("after that it just jitters in the last digits of the float", plateau < 1e-12, True)
test("descent found a lower loss than the true parameters give", final_loss < mse(TRUE_W, TRUE_B), True)
test("the slope came back within a fifth of three", close(final_w, TRUE_W, 0.2), True)
test("the intercept came back within one of minus two", close(final_b, TRUE_B, 1.0), True)
test("the gradient at the end is flat to six decimal places",
[close(dw, 0.0, 1e-6), close(db, 0.0, 1e-6)], [True, True])
What it printed, when the build ran it
step w b loss
0 0.000000 0.000000 229.47285775
1 1.675238 0.245773 33.41019432
2 2.292669 0.327483 6.87665466
5 2.639109 0.339397 2.68327603
20 2.687148 0.147286 2.41429802
100 2.813587 -0.667565 1.55404107
500 3.025297 -2.031962 0.91429515
1000 3.052439 -2.206883 0.90478459
2500 3.054532 -2.220371 0.90473556
5000 3.054533 -2.220376 0.90473556
finished at w = 3.054533, b = -2.220376
true values were w = 3.0, b = -2.0
loss at the true values: 0.93095004
gradient at the end: -7.645e-13 +4.909e-12
the loss fell strictly for 2912 steps, then only jittered by 3.5e-14
ok the loss fell on every one of the first two thousand steps
ok after that it just jitters in the last digits of the float
ok descent found a lower loss than the true parameters give
ok the slope came back within a fifth of three
ok the intercept came back within one of minus two
ok the gradient at the end is flat to six decimal places
The plateau at step 2912 is not convergence, it is arithmetic running out. The loss
stops changing because the next improvement is smaller than the gap between two adjacent
floats near 0.9047, and after that it wanders in the sixteenth digit. Any
stopping rule written as while loss decreases loops forever here. Use a
tolerance — stop when the improvement is below 1e-10, or when the gradient is
small — never an exact comparison.
The closed form, and the fact that it agrees
For squared error, you do not need the loop at all. Set both derivatives to zero and solve: two equations, two unknowns, one line of algebra. Those are the normal equations, and they are why linear regression is the one model in this course with an exact answer. Below are two arrangements of the same solve, plus the descent from the previous panel, all landing on the same numbers.
# The exact answer, two ways, checked against five thousand steps of descent.
import random
TRUE_W, TRUE_B = 3.0, -2.0
rng = random.Random(42)
XS = [i * 0.5 for i in range(20)]
YS = [TRUE_W * x + TRUE_B + rng.gauss(0.0, 1.5) for x in XS]
def fit_covariance(xs, ys):
"""The textbook form: w is covariance over variance, b puts the line through the means."""
n = len(xs)
xbar = sum(xs) / n
ybar = sum(ys) / n
sxx = sum((x - xbar) ** 2 for x in xs)
if sxx < 1e-12:
raise ValueError("every x is identical, so no single line is determined")
sxy = sum((x - xbar) * (y - ybar) for x, y in zip(xs, ys))
w = sxy / sxx
return w, ybar - w * xbar
def fit_normal_equations(xs, ys):
"""The same thing as a 2x2 linear system, solved by Cramer's rule.
[ sum(x*x) sum(x) ] [ w ] [ sum(x*y) ]
[ sum(x) n ] [ b ] = [ sum(y) ]
"""
n = float(len(xs))
sx = sum(xs)
sxx = sum(x * x for x in xs)
sy = sum(ys)
sxy = sum(x * y for x, y in zip(xs, ys))
det = sxx * n - sx * sx
if abs(det) < 1e-12:
raise ValueError("the normal equations are singular")
return (sxy * n - sx * sy) / det, (sxx * sy - sx * sxy) / det
w_cov, b_cov = fit_covariance(XS, YS)
w_ne, b_ne = fit_normal_equations(XS, YS)
w_gd, b_gd = 0.0, 0.0
for _ in range(5000):
n = len(XS)
errors = [w_gd * x + b_gd - y for x, y in zip(XS, YS)]
w_gd -= 0.01 * sum(2.0 * e * x for e, x in zip(errors, XS)) / n
b_gd -= 0.01 * sum(2.0 * e for e in errors) / n
print("covariance form : w = {:.12f} b = {:.12f}".format(w_cov, b_cov))
print("normal equations : w = {:.12f} b = {:.12f}".format(w_ne, b_ne))
print("gradient descent : w = {:.12f} b = {:.12f}".format(w_gd, b_gd))
print()
print("closed form == gradient descent? == says", w_cov == w_gd)
print(" close says", close(w_cov, w_gd, 1e-6))
# Two properties every least-squares fit with an intercept has, worth checking
# rather than believing: the residuals cancel, and the line goes through the means.
residuals = [y - (w_cov * x + b_cov) for x, y in zip(XS, YS)]
xbar = sum(XS) / len(XS)
ybar = sum(YS) / len(YS)
print()
print("sum of residuals at the fit: {:.3e}".format(sum(residuals)))
print("the fit at xbar = {:.3f} is {:.9f}, and ybar is {:.9f}".format(
xbar, w_cov * xbar + b_cov, ybar))
try:
fit_covariance([2.0, 2.0, 2.0], [1.0, 5.0, 9.0])
refused = False
except ValueError as error:
refused = True
print()
print("a vertical cloud of points is refused:", error)
test("the two closed forms agree", [close(w_cov, w_ne), close(b_cov, b_ne)], [True, True])
test("five thousand descent steps reached the same answer",
[close(w_cov, w_gd, 1e-6), close(b_cov, b_gd, 1e-6)], [True, True])
test("but not bit for bit, which is why close() exists", w_cov == w_gd, False)
test("the residuals of a least-squares fit sum to zero", close(sum(residuals), 0.0, 1e-9), True)
test("the fitted line passes through the mean of the data", close(w_cov * xbar + b_cov, ybar), True)
test("no line can be fitted when every x is the same", refused, True)
What it printed, when the build ran it
covariance form : w = 3.054533098252 b = -2.220376321641
normal equations : w = 3.054533098252 b = -2.220376321641
gradient descent : w = 3.054533098251 b = -2.220376321632
closed form == gradient descent? == says False
close says True
sum of residuals at the fit: -2.132e-14
the fit at xbar = 4.750 is 12.288655895, and ybar is 12.288655895
a vertical cloud of points is refused: every x is identical, so no single line is determined
ok the two closed forms agree
ok five thousand descent steps reached the same answer
ok but not bit for bit, which is why close() exists
ok the residuals of a least-squares fit sum to zero
ok the fitted line passes through the mean of the data
ok no line can be fitted when every x is the same
So why ever run the loop? Because the closed form needs to invert a matrix the size of your feature count, which is cubic work and hopeless past a few thousand features, and because it exists only for squared error on a linear model. Change the loss, add a non-linearity, and the algebra dies while the loop carries on unchanged. Gradient descent is here because it generalises, and linear regression is the last model in this course where you can check it against an exact answer. Use the check while you have it.
R squared, and how to read it honestly
The loss is in squared units of y, so on its own it means nothing. R squared
rescales it against the simplest possible model — always predict the mean — and answers:
how much of the variation did the line account for? One is perfect, zero means you matched
a model that ignores x entirely, and it can go negative.
# R squared: the loss, rescaled against the laziest possible model.
import math
import random
TRUE_W, TRUE_B = 3.0, -2.0
rng = random.Random(42)
XS = [i * 0.5 for i in range(20)]
YS = [TRUE_W * x + TRUE_B + rng.gauss(0.0, 1.5) for x in XS]
def fit(xs, ys):
n = len(xs)
xbar = sum(xs) / n
ybar = sum(ys) / n
sxx = sum((x - xbar) ** 2 for x in xs)
sxy = sum((x - xbar) * (y - ybar) for x, y in zip(xs, ys))
w = sxy / sxx
return w, ybar - w * xbar
def r_squared(xs, ys, w, b):
"""1 - (what the line got wrong) / (what predicting the mean gets wrong)."""
ybar = sum(ys) / len(ys)
rss = sum((y - (w * x + b)) ** 2 for x, y in zip(xs, ys))
tss = sum((y - ybar) ** 2 for y in ys)
return 1.0 - rss / tss
w, b = fit(XS, YS)
ybar = sum(YS) / len(YS)
clean_y = [TRUE_W * x + TRUE_B for x in XS]
cw, cb = fit(XS, clean_y)
print("R2 of the fitted line : {:.6f}".format(r_squared(XS, YS, w, b)))
print("R2 of always predicting ybar : {:.6f} <- zero, by construction".format(
r_squared(XS, YS, 0.0, ybar)))
print("R2 of a deliberately bad line : {:.6f} <- worse than the mean".format(
r_squared(XS, YS, -1.0, 10.0)))
print("R2 on noiseless data : {:.6f}".format(r_squared(XS, clean_y, cw, cb)))
# For a single input there is an identity worth knowing: R2 is the correlation squared.
xbar = sum(XS) / len(XS)
sxx = sum((x - xbar) ** 2 for x in XS)
syy = sum((y - ybar) ** 2 for y in YS)
sxy = sum((x - xbar) * (y - ybar) for x, y in zip(XS, YS))
corr = sxy / math.sqrt(sxx * syy)
print()
print("correlation between x and y : {:.9f}".format(corr))
print("that squared : {:.9f}".format(corr * corr))
print("R2 again : {:.9f}".format(r_squared(XS, YS, w, b)))
test("the fit explains most of the variance", 0.9 < r_squared(XS, YS, w, b) < 1.0, True)
test("predicting the mean every time scores exactly zero",
close(r_squared(XS, YS, 0.0, ybar), 0.0), True)
test("a bad enough line scores below zero", r_squared(XS, YS, -1.0, 10.0) < 0.0, True)
test("a perfect fit on noiseless data scores one", close(r_squared(XS, clean_y, cw, cb), 1.0), True)
test("for one input, R2 is the squared correlation", close(corr * corr, r_squared(XS, YS, w, b)), True)
test("not even the true parameters beat the least-squares line on this sample",
r_squared(XS, YS, w, b) > r_squared(XS, YS, TRUE_W, TRUE_B), True)
What it printed, when the build ran it
R2 of the fitted line : 0.988469 R2 of always predicting ybar : 0.000000 <- zero, by construction R2 of a deliberately bad line : -1.384582 <- worse than the mean R2 on noiseless data : 1.000000 correlation between x and y : 0.994217830 that squared : 0.988469094 R2 again : 0.988469094 ok the fit explains most of the variance ok predicting the mean every time scores exactly zero ok a bad enough line scores below zero ok a perfect fit on noiseless data scores one ok for one input, R2 is the squared correlation ok not even the true parameters beat the least-squares line on this sample
An R squared of 0.988 looks like a finished job, and here it happens to be
one, because the data really was a line plus noise. Read it as one number about one
assumption. It says the line captured most of the variation in this sample; it says
nothing about whether a line was the right shape, whether the fit holds outside the range of
x it saw, or whether one row did all the work. The next panel is that last one.
One bad row
Squared error punishes a residual by its square, so a point twice as far off matters four
times as much, and a point ten times as far off matters a hundred times as much. A single
mistyped value — a price in cents rather than pounds, a sensor spike, a missing value coded
as -999 — is not a small problem for least squares. It is the loudest row in the
dataset.
# One value changed out of twenty, and what it does to the line.
import random
TRUE_W, TRUE_B = 3.0, -2.0
rng = random.Random(42)
XS = [i * 0.5 for i in range(20)]
YS = [TRUE_W * x + TRUE_B + rng.gauss(0.0, 1.5) for x in XS]
def fit(xs, ys):
n = len(xs)
xbar = sum(xs) / n
ybar = sum(ys) / n
sxx = sum((x - xbar) ** 2 for x in xs)
sxy = sum((x - xbar) * (y - ybar) for x, y in zip(xs, ys))
w = sxy / sxx
return w, ybar - w * xbar
def r_squared(xs, ys, w, b):
ybar = sum(ys) / len(ys)
rss = sum((y - (w * x + b)) ** 2 for x, y in zip(xs, ys))
tss = sum((y - ybar) ** 2 for y in ys)
return 1.0 - rss / tss
BROKEN = list(YS)
BROKEN[3] = YS[3] + 30.0 # one typo, one row, near the left of the range
w_ok, b_ok = fit(XS, YS)
w_bad, b_bad = fit(XS, BROKEN)
print(" slope intercept R2")
print("clean {:8.4f} {:9.4f} {:8.4f}".format(w_ok, b_ok, r_squared(XS, YS, w_ok, b_ok)))
print("one typo {:8.4f} {:9.4f} {:8.4f}".format(
w_bad, b_bad, r_squared(XS, BROKEN, w_bad, b_bad)))
print()
print("One row in twenty, and the slope moved {:.4f} while R2 fell by half.".format(
abs(w_bad - w_ok)))
# The residuals are how you find it. The offender is not subtle once you look.
residuals = [abs(y - (w_bad * x + b_bad)) for x, y in zip(XS, BROKEN)]
worst = residuals.index(max(residuals))
print()
print("largest residual is at row {}, size {:.3f}".format(worst, residuals[worst]))
print("second largest is {:.3f}".format(sorted(residuals)[-2]))
keep = [i for i in range(len(XS)) if i != worst]
w_trim, b_trim = fit([XS[i] for i in keep], [BROKEN[i] for i in keep])
print("refit without that row: slope {:.4f}, against {:.4f} on the clean data".format(w_trim, w_ok))
test("one bad row in twenty moved the slope by more than a tenth", abs(w_bad - w_ok) > 0.1, True)
test("and dragged the intercept the other way to compensate", b_bad > b_ok, True)
test("R2 fell once the outlier was in",
r_squared(XS, BROKEN, w_bad, b_bad) < r_squared(XS, YS, w_ok, b_ok), True)
test("the largest residual points straight at the damaged row", worst, 3)
test("it is more than twice the next worst, so it stands out",
residuals[worst] > 2.0 * sorted(residuals)[-2], True)
test("dropping it recovers the original slope", close(w_trim, w_ok, 0.05), True)
What it printed, when the build ran it
slope intercept R2 clean 3.0545 -2.2204 0.9885 one typo 2.4681 2.0653 0.5330 One row in twenty, and the slope moved 0.5865 while R2 fell by half. largest residual is at row 3, size 27.786 second largest is 4.982 refit without that row: slope 3.0808, against 3.0545 on the clean data ok one bad row in twenty moved the slope by more than a tenth ok and dragged the intercept the other way to compensate ok R2 fell once the outlier was in ok the largest residual points straight at the damaged row ok it is more than twice the next worst, so it stands out ok dropping it recovers the original slope
Notice what the fit did not do: raise, warn, or produce an obviously silly
number. It returned a slope of 2.47 and an R squared of 0.53,
both entirely plausible, and a report built on it would read as a slightly disappointing
model rather than a broken one. Plot the residuals, or at minimum print the largest few,
every single time. That is the cheapest bug-finding habit in this part of the field.
What a real project would use instead
Every function above exists in a library, and for one input against one output that is all you need to know. The moment there are several inputs, though, the closed form becomes a matrix solve and hand-rolling it stops being sensible — a least-squares solver does the pivoting, the conditioning and the rank checks that this lesson skipped.
# The same fit, the way it is done in practice.
# NOT RUN HERE - numpy and scikit-learn are not installed.
import numpy as np
from sklearn.linear_model import LinearRegression, HuberRegressor
from sklearn.metrics import r2_score
x = np.arange(20) * 0.5
y = 3.0 * x - 2.0 + np.random.default_rng(42).normal(0.0, 1.5, 20)
# The closed form, for any number of features. Column of ones for the intercept.
design = np.column_stack([x, np.ones_like(x)])
(w, b), *_ = np.linalg.lstsq(design, y, rcond=None) # fit_normal_equations, done properly
model = LinearRegression().fit(x.reshape(-1, 1), y) # the same numbers, less typing
model.coef_[0], model.intercept_
r2_score(y, model.predict(x.reshape(-1, 1))) # r_squared(...)
# And the answer to the last panel. Huber loss is squared for small residuals and
# linear for large ones, so one bad row can no longer shout down the other nineteen.
robust = HuberRegressor().fit(x.reshape(-1, 1), y)
# np.linalg.lstsq uses an SVD, not Cramer's rule. It stays usable when the columns
# are nearly collinear, which is where the hand-rolled determinant above quietly
# returns rubbish instead of raising.
numpy and scikit-learn are not installed in this environment, so this panel was never executed. Every call shown has a plain-Python counterpart above that did run and did assert. Read it for the API and for the Huber suggestion, not as verified output.
What carries forward. A model is a function with parameters; a loss scores it; a fit is whatever minimises the loss on the sample you have, which is not the truth and not supposed to be. Two ways to find the minimum, agreeing to six decimal places, is how you earn the right to trust either. And every number that comes out — the loss, R squared, the coefficients — describes the sample, so the habits that matter are checking the residuals and holding data back. The next lesson keeps the same loop and changes what comes out of the model: a probability instead of a quantity.
9 Logistic regression and a decision boundary
Logistic regression predicts a probability instead of a quantity. It is a straight line, squashed. Three pieces do all the work: the sigmoid that squashes, the log loss that scores a probability honestly, and the same gradient descent as before. Then the boundary it draws, and the shape of problem it can never draw.
The sigmoid, and the overflow in the obvious version
The sigmoid takes any real number and returns something between 0 and 1. Large positive input goes to nearly 1, large negative to nearly 0, and zero maps to exactly a half. Written the obvious way it is one line, and that one line raises on real data.
# The sigmoid. One line is not enough.
import math
def sigmoid_naive(z):
return 1.0 / (1.0 + math.exp(-z))
def sigmoid(z):
"""Two branches, so the exponent is never large and positive."""
if z >= 0.0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
print(" z sigmoid(z)")
for z in (-8.0, -2.0, -0.5, 0.0, 0.5, 2.0, 8.0):
print(" {:5.1f} {:.9f}".format(z, sigmoid(z)))
print()
print("sigmoid( 40) =", sigmoid(40.0))
print("sigmoid( 800) =", sigmoid(800.0), " <- exactly 1.0, not nearly")
print("sigmoid(-800) =", sigmoid(-800.0), " <- exactly 0.0")
# A score of -800 is not exotic. Give a trained model an outlier, or let the
# weights grow for a few thousand steps, and exp(800) is what the naive line asks for.
try:
sigmoid_naive(-800.0)
overflowed = False
except OverflowError as error:
overflowed = True
print()
print("the one-line version raises here: OverflowError:", error)
print("the two-branch version returns :", sigmoid(-800.0))
# The derivative is unusually tidy, and worth checking rather than trusting.
d = sigmoid(1.3) * (1.0 - sigmoid(1.3))
numeric = (sigmoid(1.3 + 1e-5) - sigmoid(1.3 - 1e-5)) / 2e-5
print()
print("s'(1.3) claimed as s(1 - s) : {:.12f}".format(d))
print("s'(1.3) by central difference : {:.12f}".format(numeric))
test("sigmoid of zero is a half", close(sigmoid(0.0), 0.5), True)
test("it is symmetric, so one minus s(z) is s(-z)",
[close(1.0 - sigmoid(2.3), sigmoid(-2.3)), close(1.0 - sigmoid(-7.0), sigmoid(7.0))],
[True, True])
test("it never leaves zero to one",
all(0.0 <= sigmoid(z) <= 1.0 for z in range(-900, 900, 37)), True)
test("it only ever increases",
all(sigmoid(z / 10.0) < sigmoid((z + 1) / 10.0) for z in range(-60, 60)), True)
test("the naive one-liner overflows on a large negative input", overflowed, True)
test("the two-branch version saturates to exactly zero and one instead",
[sigmoid(-800.0), sigmoid(800.0)], [0.0, 1.0])
test("its derivative really is s times one minus s", close(d, numeric, 1e-9), True)
What it printed, when the build ran it
z sigmoid(z)
-8.0 0.000335350
-2.0 0.119202922
-0.5 0.377540669
0.0 0.500000000
0.5 0.622459331
2.0 0.880797078
8.0 0.999664650
sigmoid( 40) = 1.0
sigmoid( 800) = 1.0 <- exactly 1.0, not nearly
sigmoid(-800) = 0.0 <- exactly 0.0
the one-line version raises here: OverflowError: math range error
the two-branch version returns : 0.0
s'(1.3) claimed as s(1 - s) : 0.168298362469
s'(1.3) by central difference : 0.168298362474
ok sigmoid of zero is a half
ok it is symmetric, so one minus s(z) is s(-z)
ok it never leaves zero to one
ok it only ever increases
ok the naive one-liner overflows on a large negative input
ok the two-branch version saturates to exactly zero and one instead
ok its derivative really is s times one minus s
Look at the two saturation results: sigmoid(800) is exactly
1.0 and sigmoid(-800) is exactly
0.0. Not near, not approximately — the float has no room left to be different.
A probability of exactly 0 or 1 is a lie the arithmetic tells you, and the next panel is
about what it costs, because log(0.0) is where it lands.
Log loss, and the clip that keeps it finite
Squared error scores a probability badly. Log loss scores it by asking how much probability you assigned to the answer that actually happened, and taking the negative logarithm — so being right is nearly free, being wrong is expensive, and being confidently wrong is ruinous. Ruinous means infinite, which is why every implementation clips.
# Log loss, the clip it needs, and the reason squared error is not used here.
import math
def sigmoid(z):
if z >= 0.0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
def log_loss(p, y):
"""How surprised we were by the truth. Clipped, or log(0.0) raises."""
p = min(max(p, 1e-15), 1.0 - 1e-15)
return -(y * math.log(p) + (1.0 - y) * math.log(1.0 - p))
def log_loss_unclipped(p, y):
return -(y * math.log(p) + (1.0 - y) * math.log(1.0 - p))
print(" label predicted p log loss")
for y, p in ((1, 0.99), (1, 0.75), (1, 0.5), (1, 0.25), (1, 0.01), (0, 0.01), (0, 0.99)):
print(" {} {:6.2f} {:9.4f}".format(y, p, log_loss(p, y)))
saturated = sigmoid(-800.0)
print()
print("a very confident wrong prediction gives p =", saturated)
try:
log_loss_unclipped(saturated, 1)
raised = False
except ValueError as error:
raised = True
print("log loss without clipping raises: ValueError:", error)
print("with the 1e-15 clip it returns : {:.6f}".format(log_loss(saturated, 1)))
print("which is -log(1e-15) : {:.6f}".format(-math.log(1e-15)))
# Now the question people ask: why not just square the error, as in lesson 8?
# Because of what happens to the gradient when the model is badly wrong.
z = -20.0
p = sigmoid(z)
d_log = p - 1.0 # d/dz of log loss, with y = 1
d_squared = 2.0 * (p - 1.0) * p * (1.0 - p) # d/dz of (p - y)^2, with y = 1
print()
print("at z = -20 with the true label 1, the model is confidently wrong.")
print(" p = {:.3e}".format(p))
print(" d(log loss)/dz = {:+.9f}".format(d_log))
print(" d(squared error)/dz = {:+.3e}".format(d_squared))
print("Squared error has no gradient left. Log loss still pushes at full strength.")
test("a confident correct prediction costs almost nothing", log_loss(0.999999, 1) < 1e-5, True)
test("a confident wrong prediction costs a great deal", log_loss(1e-9, 1) > 20.0, True)
test("without clipping, a saturated probability makes log() raise", raised, True)
test("the clip caps the worst possible loss at about 34.5",
close(log_loss(saturated, 1), -math.log(1e-15), 1e-9), True)
test("log loss still has a full-strength slope when confidently wrong",
close(d_log, -1.0, 1e-8), True)
test("squared error has none left, so it would stop learning", abs(d_squared) < 1e-8, True)
What it printed, when the build ran it
label predicted p log loss
1 0.99 0.0101
1 0.75 0.2877
1 0.50 0.6931
1 0.25 1.3863
1 0.01 4.6052
0 0.01 0.0101
0 0.99 4.6052
a very confident wrong prediction gives p = 0.0
log loss without clipping raises: ValueError: math domain error
with the 1e-15 clip it returns : 34.538776
which is -log(1e-15) : 34.538776
at z = -20 with the true label 1, the model is confidently wrong.
p = 2.061e-09
d(log loss)/dz = -0.999999998
d(squared error)/dz = -4.122e-09
Squared error has no gradient left. Log loss still pushes at full strength.
ok a confident correct prediction costs almost nothing
ok a confident wrong prediction costs a great deal
ok without clipping, a saturated probability makes log() raise
ok the clip caps the worst possible loss at about 34.5
ok log loss still has a full-strength slope when confidently wrong
ok squared error has none left, so it would stop learning
That gradient comparison is the whole answer to "why not squared error", and it is worth
keeping. The sigmoid derivative p(1 - p) goes to zero at both ends. Squared
error multiplies the error by it, so a point the model has got badly wrong contributes
4e-9 of correction and effectively cannot be fixed. Log loss is built so that
factor cancels exactly, leaving p - y — the same beautifully simple gradient as
linear regression. A second reason: squared error on a sigmoid is not convex, so descent can
settle in a local minimum, while log loss on a linear score has exactly one.
Training it
Two features, sixty rows, two overlapping clusters. The gradient of log loss with respect
to each weight is mean((p - y) * x), which is the linear regression gradient with
p where the prediction used to be. Same loop, same shape, different meaning.
# Logistic regression by gradient descent. The loop is unchanged from lesson 8.
import math
import random
def sigmoid(z):
if z >= 0.0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
def log_loss(p, y):
p = min(max(p, 1e-15), 1.0 - 1e-15)
return -(y * math.log(p) + (1.0 - y) * math.log(1.0 - p))
rng = random.Random(42)
ROWS = []
for _ in range(30):
ROWS.append(([rng.gauss(2.0, 0.8), rng.gauss(2.0, 0.8)], 0))
for _ in range(30):
ROWS.append(([rng.gauss(4.0, 0.8), rng.gauss(4.0, 0.8)], 1))
def probability(w, b, x):
return sigmoid(sum(wi * xi for wi, xi in zip(w, x)) + b)
def mean_loss(w, b):
return sum(log_loss(probability(w, b, x), y) for x, y in ROWS) / len(ROWS)
def gradient(w, b):
"""dL/dw is mean((p - y) * x), dL/db is mean(p - y). The sigmoid cancels out."""
n = len(ROWS)
dw = [0.0] * len(w)
db = 0.0
for x, y in ROWS:
e = probability(w, b, x) - y
for j in range(len(w)):
dw[j] += e * x[j]
db += e
return [g / n for g in dw], db / n
def accuracy(w, b):
right = sum(1 for x, y in ROWS if (1 if probability(w, b, x) >= 0.5 else 0) == y)
return right / len(ROWS)
w = [0.0, 0.0]
b = 0.0
history = [mean_loss(w, b)]
print(" step w1 w2 b loss accuracy")
for step in range(1, 4001):
dw, db = gradient(w, b)
w = [wi - 0.2 * g for wi, g in zip(w, dw)]
b -= 0.2 * db
history.append(mean_loss(w, b))
if step in (1, 2, 10, 50, 200, 1000, 4000):
print(" {:5} {:8.4f} {:8.4f} {:8.4f} {:8.5f} {:.4f}".format(
step, w[0], w[1], b, history[-1], accuracy(w, b)))
print()
print("starting loss was {:.6f}, which is log(2) = {:.6f}".format(history[0], math.log(2.0)))
print("final weights: w =", [round(v, 4) for v in w], " b = {:.4f}".format(b))
print("final loss {:.6f}, accuracy {:.4f}".format(history[-1], accuracy(w, b)))
test("with all parameters at zero every prediction is a coin flip",
close(history[0], math.log(2.0)), True)
test("the loss fell on every one of the four thousand steps",
all(history[i + 1] < history[i] for i in range(len(history) - 1)), True)
test("both weights came out positive, since both features rise with the class",
[w[0] > 0.0, w[1] > 0.0], [True, True])
test("the bias came out negative to balance them", b < 0.0, True)
test("it separates the training data almost perfectly", accuracy(w, b) >= 0.95, True)
test("every predicted probability is a real probability",
all(0.0 <= probability(w, b, x) <= 1.0 for x, _ in ROWS), True)
What it printed, when the build ran it
step w1 w2 b loss accuracy
1 0.1063 0.1011 0.0000 0.64122 0.5000
2 0.1063 0.0994 -0.0302 0.63678 0.5000
10 0.1454 0.1258 -0.2543 0.60410 0.5167
50 0.3129 0.2521 -1.2346 0.47830 0.8000
200 0.7213 0.5811 -3.6053 0.27556 0.9833
1000 1.5548 1.2529 -8.2008 0.11718 0.9833
4000 2.5242 2.0416 -13.3321 0.06527 0.9833
starting loss was 0.693147, which is log(2) = 0.693147
final weights: w = [2.5242, 2.0416] b = -13.3321
final loss 0.065266, accuracy 0.9833
ok with all parameters at zero every prediction is a coin flip
ok the loss fell on every one of the four thousand steps
ok both weights came out positive, since both features rise with the class
ok the bias came out negative to balance them
ok it separates the training data almost perfectly
ok every predicted probability is a real probability
Notice the bias at -13.33 against weights near 2.5. It has to
be: the clusters sit around (2, 2) and (4, 4), so the weighted sum
is around 9 and 18, and the bias is what shifts the crossover point
to sit between them. Weights of that size are also why the sigmoid needs to be the stable
version. If training ran long enough on separable data, they would keep growing without limit
— the loss can always be lowered a little more by becoming more confident, which is exactly
what regularisation exists to stop.
The decision boundary, printed
The boundary is where the model is undecided: w1*x1 + w2*x2 + b = 0. That is
the equation of a straight line, so you can rearrange for x2 and print it. Every
point on it has probability exactly one half.
# The boundary is a line. Here it is, with the probability checked along it.
import math
import random
def sigmoid(z):
if z >= 0.0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
rng = random.Random(42)
ROWS = []
for _ in range(30):
ROWS.append(([rng.gauss(2.0, 0.8), rng.gauss(2.0, 0.8)], 0))
for _ in range(30):
ROWS.append(([rng.gauss(4.0, 0.8), rng.gauss(4.0, 0.8)], 1))
def fit(rows, lr=0.2, steps=4000):
w = [0.0, 0.0]
b = 0.0
n = len(rows)
for _ in range(steps):
dw = [0.0, 0.0]
db = 0.0
for x, y in rows:
e = sigmoid(w[0] * x[0] + w[1] * x[1] + b) - y
dw[0] += e * x[0]
dw[1] += e * x[1]
db += e
w = [w[0] - lr * dw[0] / n, w[1] - lr * dw[1] / n]
b -= lr * db / n
return w, b
w, b = fit(ROWS)
def score(x):
"""The number before the squashing. Positive means class 1."""
return w[0] * x[0] + w[1] * x[1] + b
print("the model is p = sigmoid({:+.4f} * x1 {:+.4f} * x2 {:+.4f})".format(w[0], w[1], b))
slope = -w[0] / w[1]
intercept = -b / w[1]
print()
print("set that inner expression to zero and solve for x2:")
print(" x2 = {:+.6f} * x1 {:+.6f}".format(slope, intercept))
print()
print(" x1 x2 score p")
for x1 in (1.0, 2.0, 3.0, 4.0, 5.0):
x2 = slope * x1 + intercept
print(" {:6.2f} {:8.4f} {:+8.1e} {:.9f}".format(
x1, x2, score([x1, x2]), sigmoid(score([x1, x2]))))
mean0 = [sum(x[j] for x, y in ROWS if y == 0) / 30 for j in (0, 1)]
mean1 = [sum(x[j] for x, y in ROWS if y == 1) / 30 for j in (0, 1)]
print()
print("class 0 mean {} scores {:+.4f}".format([round(v, 3) for v in mean0], score(mean0)))
print("class 1 mean {} scores {:+.4f}".format([round(v, 3) for v in mean1], score(mean1)))
# Dividing the score by the length of w turns it into a signed distance to the line,
# which is the useful way to read "how far into the wrong side was this row".
length = math.sqrt(w[0] ** 2 + w[1] ** 2)
wrong = [(x, y, sigmoid(score(x))) for x, y in ROWS
if (1 if sigmoid(score(x)) >= 0.5 else 0) != y]
print()
print("misclassified rows:", len(wrong))
for x, y, p in wrong:
print(" label {} at {} got p = {:.4f}, distance to the boundary {:+.4f}".format(
y, [round(v, 3) for v in x], p, score(x) / length))
test("every point on the boundary line has probability one half",
all(close(sigmoid(score([x1, slope * x1 + intercept])), 0.5, 1e-12)
for x1 in (1.0, 2.0, 3.0, 4.0, 5.0)), True)
test("the boundary really is straight: the slope is the same everywhere",
close((slope * 5.0 + intercept - (slope * 1.0 + intercept)) / 4.0, slope), True)
test("the two class means fall on opposite sides of it",
[score(mean0) < 0.0, score(mean1) > 0.0], [True, True])
test("a point far into class 1 territory is called with confidence",
sigmoid(score([8.0, 8.0])) > 0.999, True)
test("only one row is misclassified, and it sits nearest the boundary",
[len(wrong), all(abs(p - 0.5) < 0.3 for _, _, p in wrong)], [1, True])
What it printed, when the build ran it
the model is p = sigmoid(+2.5242 * x1 +2.0416 * x2 -13.3321)
set that inner expression to zero and solve for x2:
x2 = -1.236398 * x1 +6.530318
x1 x2 score p
1.00 5.2939 +1.8e-15 0.500000000
2.00 4.0575 +0.0e+00 0.500000000
3.00 2.8211 +0.0e+00 0.500000000
4.00 1.5847 +0.0e+00 0.500000000
5.00 0.3483 +0.0e+00 0.500000000
class 0 mean [2.037, 1.985] scores -4.1375
class 1 mean [4.163, 4.006] scores +5.3546
misclassified rows: 1
label 1 at [2.527, 3.134] got p = 0.3647, distance to the boundary -0.1710
ok every point on the boundary line has probability one half
ok the boundary really is straight: the slope is the same everywhere
ok the two class means fall on opposite sides of it
ok a point far into class 1 territory is called with confidence
ok only one row is misclassified, and it sits nearest the boundary
Two things follow from the boundary being a line. First, the 0.5 threshold
is a choice, not a property of the model — move it and the line slides sideways without
retraining. Lesson 10 is about choosing it deliberately. Second, the weights are readable:
w1 larger than w2 means feature one moves the decision more, per
unit. That only holds if the features are on comparable scales, which is another reason to
standardise inputs before you interpret a coefficient.
The shape it cannot learn
A straight boundary is a real limit, and exclusive-or is the smallest problem that shows it. Four points, label 1 when the two inputs disagree. No straight line separates them, and what the model does about that is more interesting than simply failing.
# Exclusive-or: four points, and a wall.
import math
def sigmoid(z):
if z >= 0.0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
def log_loss(p, y):
p = min(max(p, 1e-15), 1.0 - 1e-15)
return -(y * math.log(p) + (1.0 - y) * math.log(1.0 - p))
XOR = [([-1.0, -1.0], 0), ([-1.0, 1.0], 1), ([1.0, -1.0], 1), ([1.0, 1.0], 0)]
def plain(x):
return list(x)
def with_product(x):
"""The same two inputs, plus the one feature that makes the problem linear."""
return [x[0], x[1], x[0] * x[1]]
def fit(rows, features, lr=0.5, steps=5000):
width = len(features(rows[0][0]))
w = [0.0] * width
b = 0.0
n = len(rows)
for _ in range(steps):
dw = [0.0] * width
db = 0.0
for x, y in rows:
f = features(x)
e = sigmoid(sum(wi * fi for wi, fi in zip(w, f)) + b) - y
for j in range(width):
dw[j] += e * f[j]
db += e
w = [wi - lr * g / n for wi, g in zip(w, dw)]
b -= lr * db / n
return w, b
def report(rows, features, w, b):
loss = 0.0
right = 0
for x, y in rows:
f = features(x)
p = sigmoid(sum(wi * fi for wi, fi in zip(w, f)) + b)
loss += log_loss(p, y)
right += 1 if (1 if p >= 0.5 else 0) == y else 0
return loss / len(rows), right / len(rows)
w_plain, b_plain = fit(XOR, plain)
loss_plain, acc_plain = report(XOR, plain, w_plain, b_plain)
w_prod, b_prod = fit(XOR, with_product)
loss_prod, acc_prod = report(XOR, with_product, w_prod, b_prod)
print("the label is 1 when the two inputs disagree")
print(" x1 x2 label")
for x, y in XOR:
print(" {:5.1f} {:5.1f} {}".format(x[0], x[1], y))
print()
print("fitted on x1 and x2 alone")
print(" weights {} bias {:.6f}".format([round(v, 6) for v in w_plain], b_plain))
print(" loss {:.6f} accuracy {:.2f}".format(loss_plain, acc_plain))
print(" log(2) for comparison: {:.6f}".format(math.log(2.0)))
print()
print("fitted on x1, x2 and their product")
print(" weights {} bias {:.6f}".format([round(v, 4) for v in w_prod], b_prod))
print(" loss {:.6f} accuracy {:.2f}".format(loss_prod, acc_prod))
print()
print(" x1 x2 label p")
for x, y in XOR:
f = with_product(x)
print(" {:5.1f} {:5.1f} {} {:.6f}".format(
x[0], x[1], y, sigmoid(sum(wi * fi for wi, fi in zip(w_prod, f)) + b_prod)))
test("on the raw inputs, five thousand steps learn nothing at all",
all(close(v, 0.0, 1e-9) for v in w_plain + [b_plain]), True)
test("the loss is stuck at log 2, the cost of guessing", close(loss_plain, math.log(2.0), 1e-9), True)
test("so it gets half of four points right, which is chance", close(acc_plain, 0.5), True)
test("adding the product of the two inputs fixes it completely", close(acc_prod, 1.0), True)
test("and the product is the feature carrying all the weight",
abs(w_prod[2]) > 10.0 * max(abs(w_prod[0]), abs(w_prod[1]), abs(b_prod)), True)
test("its weight is negative, since agreement means label zero", w_prod[2] < 0.0, True)
test("the loss on the fixed model is far below log 2", loss_prod < 0.05, True)
What it printed, when the build ran it
the label is 1 when the two inputs disagree
x1 x2 label
-1.0 -1.0 0
-1.0 1.0 1
1.0 -1.0 1
1.0 1.0 0
fitted on x1 and x2 alone
weights [0.0, 0.0] bias 0.000000
loss 0.693147 accuracy 0.50
log(2) for comparison: 0.693147
fitted on x1, x2 and their product
weights [0.0, 0.0, -7.822] bias -0.000000
loss 0.000401 accuracy 1.00
x1 x2 label p
-1.0 -1.0 0 0.000401
-1.0 1.0 1 0.999599
1.0 -1.0 1 0.999599
1.0 1.0 0 0.000401
ok on the raw inputs, five thousand steps learn nothing at all
ok the loss is stuck at log 2, the cost of guessing
ok so it gets half of four points right, which is chance
ok adding the product of the two inputs fixes it completely
ok and the product is the feature carrying all the weight
ok its weight is negative, since agreement means label zero
ok the loss on the fixed model is far below log 2
The failure mode is the thing to remember. Every weight stayed at exactly zero
and the loss sat at log(2) = 0.693, which is the loss of a model that outputs
one half for everything. The gradients did not vanish because training was broken; they
cancelled, because for every row pushing a weight up there is a symmetric row pushing it
down by the same amount. A loss parked at 0.693 for a two-class problem is a
specific, recognisable symptom: the features as given carry no linearly usable signal.
Two ways out, and both matter later. Hand the model a better feature — the product
x1*x2, which took accuracy from a half to one — and you are doing feature
engineering, which was most of applied machine learning for decades and is still the
cheapest fix when you understand your data. Or let the model build that feature for itself by
stacking layers, which is what a neural network is, and why lesson 15 trains one on this exact
dataset.
What a real project would use instead
# The same classifier, from a library.
# NOT RUN HERE - scikit-learn and numpy are not installed.
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.pipeline import make_pipeline
x = np.array([[2.1, 1.9], [4.2, 4.0]]) # rows of features
y = np.array([0, 1])
model = LogisticRegression().fit(x, y)
model.predict_proba(x)[:, 1] # the probabilities, not the labels
model.coef_[0], model.intercept_[0] # w and b, same meaning as above
# Two details worth knowing, because they change the numbers:
# penalty='l2' and C=1.0 are the DEFAULTS. scikit-learn regularises unless told
# not to, so its coefficients are smaller than the ones this lesson printed.
# Set C to something enormous to get the unregularised fit.
LogisticRegression(C=1e12).fit(x, y)
# And the exclusive-or fix, as a pipeline: build the product features, scale them,
# then fit. interaction_only=True gives exactly the x1*x2 column added by hand.
xor_model = make_pipeline(
PolynomialFeatures(degree=2, interaction_only=True, include_bias=False),
StandardScaler(),
LogisticRegression(),
)
scikit-learn and numpy are not installed in this environment, so this panel was never executed. Every idea in it has a plain-Python counterpart above that did run and did assert. The note about C and the default L2 penalty is the part worth carrying away, and it is documented behaviour rather than something verified here.
Logistic regression is the whole shape of supervised classification in miniature: a
linear score, a squashing function, a loss chosen so its gradient does not vanish, and
descent. Change the squashing to softmax and it does many classes. Stack it and it is a
neural network. The two habits to keep are the stable sigmoid and the clipped logarithm,
because both failures — an OverflowError and a math domain error
— appear the first time a real dataset pushes a score past 700, and neither is obvious from
the formula on the page. What this lesson has not touched is whether accuracy of
0.98 means anything, and it usually does not. That is next.
10 Accuracy is a trap: precision, recall and F1
A model that predicts "healthy" for all thousand patients, ten of whom are ill, is 99% accurate and completely useless. That is not a contrived example, it is what fraud detection, disease screening, defect detection and moderation all look like. Accuracy is the first number everyone reaches for and the one that hides the most.
The confusion matrix, which is where every other number comes from
Before any score, count the four things that can happen. Two ways to be right, two ways to be wrong, and the whole point is that the two wrongs are not interchangeable — spam in the inbox is an annoyance, a real email in the spam folder is a lost invoice.
# Four counts. Every metric in this lesson is arithmetic on these.
TRUTH = [1] * 8 + [0] * 12
PREDICTED = [1, 1, 1, 1, 1, 1, 0, 0] + [1, 1, 1] + [0] * 9
def confusion(truth, predicted):
if len(truth) != len(predicted):
raise ValueError("{} labels against {} predictions".format(len(truth), len(predicted)))
counts = {"tp": 0, "fp": 0, "fn": 0, "tn": 0}
for y, p in zip(truth, predicted):
if p == 1:
counts["tp" if y == 1 else "fp"] += 1
else:
counts["fn" if y == 1 else "tn"] += 1
return counts
m = confusion(TRUTH, PREDICTED)
print("twenty emails, 1 means spam.")
print("truth ", "".join(str(v) for v in TRUTH))
print("predicted ", "".join(str(v) for v in PREDICTED))
print()
print(" predicted spam predicted not")
print(" actually spam {:14} {:13}".format(m["tp"], m["fn"]))
print(" actually not {:14} {:13}".format(m["fp"], m["tn"]))
print()
print("true positives {:3} caught spam".format(m["tp"]))
print("false positives {:3} a real email sent to the spam folder".format(m["fp"]))
print("false negatives {:3} spam that landed in the inbox".format(m["fn"]))
print("true negatives {:3} a real email left alone".format(m["tn"]))
perfect = confusion(TRUTH, TRUTH)
print()
print("the same matrix for a perfect predictor:", perfect)
# The length check is not decoration. Predictions and labels get shuffled, filtered
# and re-indexed on the way to a report, and zip() would quietly score the shorter one.
try:
confusion([1, 0, 1], [1, 0])
refused = False
except ValueError as error:
refused = True
print("mismatched lengths are refused:", error)
test("the four counts add up to every row", sum(m.values()), len(TRUTH))
test("true positives plus false negatives is the number of actual positives",
m["tp"] + m["fn"], sum(TRUTH))
test("true positives plus false positives is the number we flagged",
m["tp"] + m["fp"], sum(PREDICTED))
test("this filter caught six of eight and flagged three innocents",
[m["tp"], m["fn"], m["fp"]], [6, 2, 3])
test("a perfect predictor has nothing off the diagonal",
[perfect["fp"], perfect["fn"]], [0, 0])
test("labels and predictions of different lengths are refused", refused, True)
What it printed, when the build ran it
twenty emails, 1 means spam.
truth 11111111000000000000
predicted 11111100111000000000
predicted spam predicted not
actually spam 6 2
actually not 3 9
true positives 6 caught spam
false positives 3 a real email sent to the spam folder
false negatives 2 spam that landed in the inbox
true negatives 9 a real email left alone
the same matrix for a perfect predictor: {'tp': 8, 'fp': 0, 'fn': 0, 'tn': 12}
mismatched lengths are refused: 3 labels against 2 predictions
ok the four counts add up to every row
ok true positives plus false negatives is the number of actual positives
ok true positives plus false positives is the number we flagged
ok this filter caught six of eight and flagged three innocents
ok a perfect predictor has nothing off the diagonal
ok labels and predictions of different lengths are refused
Four metrics, and the divisions that have to be guarded
Precision and recall answer two different questions and are constantly confused, so here they are in words worth memorising. Precision: of the things we flagged, how many should have been? Recall: of the things that should have been flagged, how many were? F1 is the harmonic mean of the two, which exists so that ignoring one of them cannot buy you a good score.
# The four metrics, from the four counts, with the zero denominators handled.
TRUTH = [1] * 8 + [0] * 12
PREDICTED = [1, 1, 1, 1, 1, 1, 0, 0] + [1, 1, 1] + [0] * 9
def confusion(truth, predicted):
counts = {"tp": 0, "fp": 0, "fn": 0, "tn": 0}
for y, p in zip(truth, predicted):
if p == 1:
counts["tp" if y == 1 else "fp"] += 1
else:
counts["fn" if y == 1 else "tn"] += 1
return counts
def ratio(top, bottom):
"""Zero when there is nothing to divide by. The alternative is a crash mid-report."""
return top / bottom if bottom else 0.0
def accuracy(m):
return ratio(m["tp"] + m["tn"], m["tp"] + m["fp"] + m["fn"] + m["tn"])
def precision(m):
"""Of the things we flagged, how many should have been."""
return ratio(m["tp"], m["tp"] + m["fp"])
def recall(m):
"""Of the things that should have been flagged, how many were."""
return ratio(m["tp"], m["tp"] + m["fn"])
def f1(m):
"""The harmonic mean of precision and recall. It follows the smaller one."""
p, r = precision(m), recall(m)
return ratio(2.0 * p * r, p + r)
m = confusion(TRUTH, PREDICTED)
p, r = precision(m), recall(m)
print("accuracy {:.6f} (6 + 9) / 20".format(accuracy(m)))
print("precision {:.6f} 6 / (6 + 3)".format(p))
print("recall {:.6f} 6 / (6 + 2)".format(r))
print("F1 {:.6f}".format(f1(m)))
print()
print("arithmetic mean of precision and recall: {:.6f}".format((p + r) / 2.0))
print("F1, the harmonic mean : {:.6f} <- always the smaller".format(f1(m)))
# Why the harmonic mean and not the ordinary one. A model that flags everything has
# perfect recall for free, and the arithmetic mean lets it look half competent.
flags_everything = confusion(TRUTH, [1] * len(TRUTH))
print()
print("a model that flags every email:")
print(" precision {:.6f} recall {:.6f}".format(
precision(flags_everything), recall(flags_everything)))
print(" arithmetic mean {:.6f} <- looks respectable".format(
(precision(flags_everything) + recall(flags_everything)) / 2.0))
print(" F1 {:.6f} <- does not".format(f1(flags_everything)))
flags_nothing = confusion(TRUTH, [0] * len(TRUTH))
print()
print("a model that flags nothing has no precision to speak of:", precision(flags_nothing))
print("without the guard that is 0 / 0, and the report dies instead of the model.")
test("accuracy is fifteen right out of twenty", close(accuracy(m), 0.75), True)
test("precision is six of the nine we flagged", close(p, 6.0 / 9.0), True)
test("recall is six of the eight that were spam", close(r, 0.75), True)
test("F1 sits between the two and below their average",
[min(p, r) <= f1(m) <= max(p, r), f1(m) < (p + r) / 2.0], [True, True])
test("flagging everything buys perfect recall and terrible precision",
[close(recall(flags_everything), 1.0), close(precision(flags_everything), 0.4)], [True, True])
test("and F1 refuses to be impressed by it", f1(flags_everything) < 0.6, True)
test("flagging nothing returns zero precision rather than raising",
close(precision(flags_nothing), 0.0), True)
What it printed, when the build ran it
accuracy 0.750000 (6 + 9) / 20 precision 0.666667 6 / (6 + 3) recall 0.750000 6 / (6 + 2) F1 0.705882 arithmetic mean of precision and recall: 0.708333 F1, the harmonic mean : 0.705882 <- always the smaller a model that flags every email: precision 0.400000 recall 1.000000 arithmetic mean 0.700000 <- looks respectable F1 0.571429 <- does not a model that flags nothing has no precision to speak of: 0.0 without the guard that is 0 / 0, and the report dies instead of the model. ok accuracy is fifteen right out of twenty ok precision is six of the nine we flagged ok recall is six of the eight that were spam ok F1 sits between the two and below their average ok flagging everything buys perfect recall and terrible precision ok and F1 refuses to be impressed by it ok flagging nothing returns zero precision rather than raising
Which one you optimise is a product decision, not a technical one, and it comes down to what each mistake costs. Screening for a treatable cancer: recall, because a missed case is a death and a false alarm is a second test. Auto-deleting email: precision, because a lost invoice is worse than a spam message you can ignore. Recommending a film: precision, since nobody minds the ones you did not show them. Ask which error you are willing to make more of, and the metric follows.
The trap, with numbers
One thousand patients, ten of them ill. Two models. The first predicts healthy for everybody and contains no logic whatsoever. The second finds eight of the ten and raises forty false alarms that a second test would clear. Accuracy prefers the first one.
# The lesson. A model with no logic in it, scoring 99%.
def confusion(truth, predicted):
counts = {"tp": 0, "fp": 0, "fn": 0, "tn": 0}
for y, p in zip(truth, predicted):
if p == 1:
counts["tp" if y == 1 else "fp"] += 1
else:
counts["fn" if y == 1 else "tn"] += 1
return counts
def ratio(top, bottom):
return top / bottom if bottom else 0.0
def accuracy(m):
return ratio(m["tp"] + m["tn"], sum(m.values()))
def precision(m):
return ratio(m["tp"], m["tp"] + m["fp"])
def recall(m):
return ratio(m["tp"], m["tp"] + m["fn"])
def f1(m):
p, r = precision(m), recall(m)
return ratio(2.0 * p * r, p + r)
TRUTH = [1] * 10 + [0] * 990 # ten ill patients in a thousand
# Model A contains no logic at all. It says "healthy" and stops.
lazy = confusion(TRUTH, [0] * 1000)
# Model B is a real screening test: eight of the ten found, forty false alarms
# that a second and more expensive test would clear.
useful = confusion(TRUTH, ([1] * 8 + [0] * 2) + ([1] * 40 + [0] * 950))
print("1000 patients, 10 of them ill. The base rate is 1%.")
print()
print(" accuracy precision recall F1")
for name, mm in (("predicts healthy always", lazy), ("a real screening test ", useful)):
print(" {} {:9.4f} {:9.4f} {:6.4f} {:6.4f}".format(
name, accuracy(mm), precision(mm), recall(mm), f1(mm)))
print()
print("the useless model: ", lazy)
print("the useful model: ", useful)
print()
print("Accuracy prefers the model that has never found a single patient.")
print("It misses {} cases out of {}, and is right 99% of the time.".format(
lazy["fn"], sum(TRUTH)))
test("the do-nothing model scores ninety nine percent accuracy",
close(accuracy(lazy), 0.99), True)
test("it finds nobody, so its recall is zero", close(recall(lazy), 0.0), True)
test("and its F1 is zero as well", close(f1(lazy), 0.0), True)
test("the useful model has lower accuracy than the useless one",
accuracy(useful) < accuracy(lazy), True)
test("but it finds eight of the ten patients",
[useful["tp"], close(recall(useful), 0.8)], [8, True])
test("and its F1 is not zero, which is the only score that noticed",
[f1(useful) > 0.25, f1(lazy) == 0.0], [True, True])
What it printed, when the build ran it
1000 patients, 10 of them ill. The base rate is 1%.
accuracy precision recall F1
predicts healthy always 0.9900 0.0000 0.0000 0.0000
a real screening test 0.9580 0.1667 0.8000 0.2759
the useless model: {'tp': 0, 'fp': 0, 'fn': 10, 'tn': 990}
the useful model: {'tp': 8, 'fp': 40, 'fn': 2, 'tn': 950}
Accuracy prefers the model that has never found a single patient.
It misses 10 cases out of 10, and is right 99% of the time.
ok the do-nothing model scores ninety nine percent accuracy
ok it finds nobody, so its recall is zero
ok and its F1 is zero as well
ok the useful model has lower accuracy than the useless one
ok but it finds eight of the ten patients
ok and its F1 is not zero, which is the only score that noticed
The number to compute before any model result is the base rate: what does predicting the majority class score? At a 1% positive rate that floor is 99%, so an accuracy of 98.5% is worse than doing nothing. Any accuracy figure quoted without its base rate beside it is unreadable, and this is how a model gets shipped that has never made a positive prediction in its life. The training loss will not warn you either — a model can drive log loss down nicely by learning to say "no" with great confidence.
The threshold is a dial, and precision trades against recall
The model of lesson 9 outputs a probability, and turning that into a decision needs a
cutoff. The 0.5 everyone uses is a default, not a result. Sweeping it moves
precision and recall in opposite directions, so a single model gives you a whole curve of
behaviours to choose from without retraining anything.
# Fifty scored examples, and the same model at eleven different thresholds.
import random
def counts_at(scored, threshold):
m = {"tp": 0, "fp": 0, "fn": 0, "tn": 0}
for score, y in scored:
if score >= threshold:
m["tp" if y == 1 else "fp"] += 1
else:
m["fn" if y == 1 else "tn"] += 1
return m
def ratio(top, bottom):
return top / bottom if bottom else 0.0
def precision(m):
return ratio(m["tp"], m["tp"] + m["fp"])
def recall(m):
return ratio(m["tp"], m["tp"] + m["fn"])
def f1(m):
p, r = precision(m), recall(m)
return ratio(2.0 * p * r, p + r)
rng = random.Random(42)
SCORED = []
for _ in range(40):
SCORED.append((min(max(rng.gauss(0.35, 0.18), 0.0), 1.0), 0))
for _ in range(10):
SCORED.append((min(max(rng.gauss(0.70, 0.18), 0.0), 1.0), 1))
BASE_RATE = sum(y for _, y in SCORED) / len(SCORED)
THRESHOLDS = [i / 10.0 for i in range(11)]
print("fifty scored examples, ten of them positive. base rate {:.2f}".format(BASE_RATE))
print()
print(" threshold flagged tp fp fn precision recall F1")
table = {}
for t in THRESHOLDS:
m = counts_at(SCORED, t)
table[t] = m
print(" {:9.1f} {:7} {:2} {:2} {:2} {:9.4f} {:6.4f} {:6.4f}".format(
t, m["tp"] + m["fp"], m["tp"], m["fp"], m["fn"], precision(m), recall(m), f1(m)))
recalls = [recall(table[t]) for t in THRESHOLDS]
best = max(THRESHOLDS, key=lambda t: f1(table[t]))
print()
print("best F1 is {:.4f}, at a threshold of {:.1f}".format(f1(table[best]), best))
print("at 0.3 : precision {:.4f} recall {:.4f} flag nearly everything".format(
precision(table[0.3]), recall(table[0.3])))
print("at 0.7 : precision {:.4f} recall {:.4f} flag only the certain ones".format(
precision(table[0.7]), recall(table[0.7])))
print()
print("One model, eleven behaviours, no retraining. Neither number means")
print("anything without the other one printed beside it.")
test("at a threshold of zero everything is flagged, so recall is perfect",
close(recalls[0], 1.0), True)
test("and precision is then just the base rate", close(precision(table[0.0]), BASE_RATE), True)
test("recall never rises as the threshold rises",
all(recalls[i + 1] <= recalls[i] for i in range(len(recalls) - 1)), True)
test("moving from 0.3 to 0.7 buys precision and pays in recall",
[precision(table[0.7]) > precision(table[0.3]), recall(table[0.7]) < recall(table[0.3])],
[True, True])
test("the best F1 is at neither extreme of the sweep", best not in (0.0, 1.0), True)
test("above every score nothing is flagged, and both numbers collapse to zero",
[precision(table[1.0]), recall(table[1.0])], [0.0, 0.0])
What it printed, when the build ran it
fifty scored examples, ten of them positive. base rate 0.20
threshold flagged tp fp fn precision recall F1
0.0 50 10 40 0 0.2000 1.0000 0.3333
0.1 46 10 36 0 0.2174 1.0000 0.3571
0.2 41 10 31 0 0.2439 1.0000 0.3922
0.3 38 10 28 0 0.2632 1.0000 0.4167
0.4 24 10 14 0 0.4167 1.0000 0.5882
0.5 15 10 5 0 0.6667 1.0000 0.8000
0.6 8 8 0 2 1.0000 0.8000 0.8889
0.7 6 6 0 4 1.0000 0.6000 0.7500
0.8 3 3 0 7 1.0000 0.3000 0.4615
0.9 0 0 0 10 0.0000 0.0000 0.0000
1.0 0 0 0 10 0.0000 0.0000 0.0000
best F1 is 0.8889, at a threshold of 0.6
at 0.3 : precision 0.2632 recall 1.0000 flag nearly everything
at 0.7 : precision 1.0000 recall 0.6000 flag only the certain ones
One model, eleven behaviours, no retraining. Neither number means
anything without the other one printed beside it.
ok at a threshold of zero everything is flagged, so recall is perfect
ok and precision is then just the base rate
ok recall never rises as the threshold rises
ok moving from 0.3 to 0.7 buys precision and pays in recall
ok the best F1 is at neither extreme of the sweep
ok above every score nothing is flagged, and both numbers collapse to zero
That table is a precision-recall curve, in tabular form. The area under it is the metric usually called average precision, and it is the right summary for an imbalanced problem because it never looks at true negatives — the 990 healthy patients cannot inflate it. ROC and its AUC do count them, which is why a ROC curve can look encouraging on data where nothing useful is happening. Prefer precision-recall when positives are rare.
Same trap, more classes
With three classes the arithmetic changes but the failure does not. Compute a confusion matrix per class, then average — and how you average decides whether the rare classes are visible at all. Micro averaging pools all the counts first, which for single-label classification makes it identical to accuracy. Macro averaging gives every class an equal vote, however few examples it has.
# Macro against micro, on 900 common cases, 90 uncommon and 10 rare.
def ratio(top, bottom):
return top / bottom if bottom else 0.0
CLASSES = ("common", "uncommon", "rare")
# rows are the truth, columns are what was predicted
ALWAYS_COMMON = {
"common": {"common": 900, "uncommon": 0, "rare": 0},
"uncommon": {"common": 90, "uncommon": 0, "rare": 0},
"rare": {"common": 10, "uncommon": 0, "rare": 0},
}
BALANCED = {
"common": {"common": 800, "uncommon": 80, "rare": 20},
"uncommon": {"common": 20, "uncommon": 65, "rare": 5},
"rare": {"common": 3, "uncommon": 1, "rare": 6},
}
def one_class(matrix, label):
"""Collapse a multi-class matrix into this class against everything else."""
tp = matrix[label][label]
fp = sum(matrix[other][label] for other in CLASSES if other != label)
fn = sum(matrix[label][other] for other in CLASSES if other != label)
return {"tp": tp, "fp": fp, "fn": fn}
def f1_of(m):
p = ratio(m["tp"], m["tp"] + m["fp"])
r = ratio(m["tp"], m["tp"] + m["fn"])
return ratio(2.0 * p * r, p + r)
def accuracy(matrix):
right = sum(matrix[c][c] for c in CLASSES)
total = sum(matrix[a][b] for a in CLASSES for b in CLASSES)
return right / total
def macro_f1(matrix):
"""Average the per-class F1 scores. Every class counts the same."""
return sum(f1_of(one_class(matrix, c)) for c in CLASSES) / len(CLASSES)
def micro_f1(matrix):
"""Pool every count first, then compute one F1. Large classes dominate."""
pooled = {"tp": 0, "fp": 0, "fn": 0}
for c in CLASSES:
for key, value in one_class(matrix, c).items():
pooled[key] += value
return f1_of(pooled)
print("900 common cases, 90 uncommon, 10 rare.")
for name, matrix in (("always says common", ALWAYS_COMMON), ("spreads its bets ", BALANCED)):
print()
print(name)
print(" accuracy {:.6f} micro F1 {:.6f} macro F1 {:.6f}".format(
accuracy(matrix), micro_f1(matrix), macro_f1(matrix)))
for c in CLASSES:
print(" {:9} F1 {:.6f} {}".format(
c, f1_of(one_class(matrix, c)), one_class(matrix, c)))
print()
print("The second model is WORSE on accuracy and far better at the job.")
print("Micro F1 agrees with accuracy and hides that. Macro F1 does not.")
test("always guessing the common class scores ninety percent accuracy",
close(accuracy(ALWAYS_COMMON), 0.9), True)
test("micro F1 is exactly accuracy, so it adds nothing here",
close(micro_f1(ALWAYS_COMMON), accuracy(ALWAYS_COMMON)), True)
test("macro F1 lands near a third, because two classes score nothing",
close(macro_f1(ALWAYS_COMMON), 0.315789, 1e-5), True)
test("both minority classes score exactly zero F1",
[f1_of(one_class(ALWAYS_COMMON, "uncommon")), f1_of(one_class(ALWAYS_COMMON, "rare"))],
[0.0, 0.0])
test("the better model has lower accuracy", accuracy(BALANCED) < accuracy(ALWAYS_COMMON), True)
test("and nearly double the macro F1", macro_f1(BALANCED) > 1.8 * macro_f1(ALWAYS_COMMON), True)
What it printed, when the build ran it
900 common cases, 90 uncommon, 10 rare.
always says common
accuracy 0.900000 micro F1 0.900000 macro F1 0.315789
common F1 0.947368 {'tp': 900, 'fp': 100, 'fn': 0}
uncommon F1 0.000000 {'tp': 0, 'fp': 0, 'fn': 90}
rare F1 0.000000 {'tp': 0, 'fp': 0, 'fn': 10}
spreads its bets
accuracy 0.871000 micro F1 0.871000 macro F1 0.590714
common F1 0.928613 {'tp': 800, 'fp': 23, 'fn': 100}
uncommon F1 0.550847 {'tp': 65, 'fp': 81, 'fn': 25}
rare F1 0.292683 {'tp': 6, 'fp': 25, 'fn': 4}
The second model is WORSE on accuracy and far better at the job.
Micro F1 agrees with accuracy and hides that. Macro F1 does not.
ok always guessing the common class scores ninety percent accuracy
ok micro F1 is exactly accuracy, so it adds nothing here
ok macro F1 lands near a third, because two classes score nothing
ok both minority classes score exactly zero F1
ok the better model has lower accuracy
ok and nearly double the macro F1
What a real project would use instead
# Every metric above, from a library.
# NOT RUN HERE - scikit-learn is not installed.
from sklearn.metrics import (
confusion_matrix, classification_report, precision_recall_curve,
average_precision_score, f1_score, balanced_accuracy_score,
)
confusion_matrix(y_true, y_pred) # the four counts, as a 2x2 array
# classification_report prints per-class precision, recall and F1 plus BOTH averages,
# which is the single most useful line in the library for the trap in this lesson.
print(classification_report(y_true, y_pred, digits=4))
f1_score(y_true, y_pred, average="macro") # macro_f1(...)
f1_score(y_true, y_pred, average="micro") # micro_f1(...), equals accuracy
balanced_accuracy_score(y_true, y_pred) # mean recall per class, another honest default
# The threshold sweep, done properly: every distinct score becomes a threshold.
precision, recall, thresholds = precision_recall_curve(y_true, y_scores)
average_precision_score(y_true, y_scores) # the area under that curve
# Two details that bite. zero_division defaults to raising a warning and returning 0,
# which is the ratio() guard written above. And f1_score's default average is
# 'binary', so on multi-class data it raises rather than silently picking one.
scikit-learn is not installed in this environment, so this panel was never executed. Every metric named here was implemented and asserted in plain Python above, and the numbers on this page came from those implementations. Read this for the API names and for classification_report, which is the habit worth copying.
The habit that survives this lesson: never report one number. Print the confusion matrix, the base rate, precision and recall together, and say which threshold produced them. Every metric here is a summary of that matrix, and every summary throws something away — accuracy throws away which mistake you made, F1 throws away the true negatives, and a single threshold throws away the whole curve. When you get to evaluating language models in lesson 27, the metrics change completely but this discipline does not: know your baseline, know what your score cannot see.
11 Overfitting, splits and regularisation
Add a parameter to a model and its error on the data it was fitted to goes down. Always. That is arithmetic, not learning, and it is why a training score is worth nothing on its own. This lesson takes sixteen noisy points, fits polynomials of rising degree, and watches the held-out error climb while the training error falls to zero.
Training error only ever falls
Sixteen points from sin(3x) with noise added, and a least-squares polynomial
fitted to all of them at every degree from flat to nine. The machinery is the normal
equations of lesson 8 with more columns: one per power of x. Watch the error
column, and note that nothing in it can tell you which model to keep.
# Sixteen noisy points, and a polynomial of any degree fitted to them.
import math
import random
rng = random.Random(42) # seeded, so this page is the same every build
XS = [-1.0 + i * (2.0 / 15.0) for i in range(16)]
YS = [math.sin(3.0 * x) + rng.gauss(0.0, 0.10) for x in XS]
def solve(matrix, rhs):
"""Gaussian elimination with partial pivoting. Small, and enough for this."""
n = len(rhs)
a = [list(row) + [rhs[i]] for i, row in enumerate(matrix)]
for col in range(n):
pivot = max(range(col, n), key=lambda r: abs(a[r][col]))
if abs(a[pivot][col]) < 1e-14:
raise ValueError("the system is singular at column {}".format(col))
a[col], a[pivot] = a[pivot], a[col]
for r in range(col + 1, n):
factor = a[r][col] / a[col][col]
for c in range(col, n + 1):
a[r][c] -= factor * a[col][c]
out = [0.0] * n
for r in range(n - 1, -1, -1):
out[r] = (a[r][n] - sum(a[r][c] * out[c] for c in range(r + 1, n))) / a[r][r]
return out
def polyfit(xs, ys, degree):
"""Least squares for c0 + c1*x + c2*x^2 + ..., by the normal equations of lesson 8."""
width = degree + 1
if len(xs) < width:
raise ValueError("{} points cannot pin down {} coefficients".format(len(xs), width))
rows = [[x ** k for k in range(width)] for x in xs]
ata = [[sum(r[i] * r[j] for r in rows) for j in range(width)] for i in range(width)]
aty = [sum(r[i] * y for r, y in zip(rows, ys)) for i in range(width)]
return solve(ata, aty)
def poly(coeffs, x):
total = 0.0
for c in reversed(coeffs): # Horner's method, evaluated from the inside out
total = total * x + c
return total
def rmse(coeffs, xs, ys):
"""Root mean squared error, in the same units as y so it can be read."""
return math.sqrt(sum((poly(coeffs, x) - y) ** 2 for x, y in zip(xs, ys)) / len(xs))
print("sixteen points from sin(3x), with gaussian noise of 0.10 added")
print(" x y sin(3x)")
for i in (0, 1, 7, 8, 14, 15):
print(" {:5.2f} {:7.4f} {:8.4f}".format(XS[i], YS[i], math.sin(3.0 * XS[i])))
print()
print(" degree parameters error on the data it was fitted to")
errors = []
for degree in range(10):
errors.append(rmse(polyfit(XS, YS, degree), XS, YS))
print(" {:5} {:9} {:.6f}".format(degree, degree + 1, errors[-1]))
print()
print("Every extra parameter lowered it. Not one of those numbers is evidence.")
# A polynomial with as many coefficients as there are points has no work left to
# do: it passes through every one of them exactly, whatever they happen to be.
FOUR_X = [-1.0, 0.0, 1.0, 2.0]
FOUR_Y = [1.0, 2.0, 0.0, 5.0]
cubic = polyfit(FOUR_X, FOUR_Y, 3)
print()
print("four arbitrary points, one cubic:", [round(v, 6) for v in cubic])
print("what it predicts at each of them:", [round(poly(cubic, x), 9) for x in FOUR_X])
print("error: {:.3e} <- a flawless fit, and it means nothing".format(rmse(cubic, FOUR_X, FOUR_Y)))
try:
polyfit(FOUR_X, FOUR_Y, 7)
refused = False
except ValueError as error:
refused = True
print()
print("asking for more coefficients than points is refused:", error)
drops = [errors[i] - errors[i + 1] for i in range(len(errors) - 1)]
test("the noise moved every point off the true curve",
all(not close(y, math.sin(3.0 * x)) for x, y in zip(XS, YS)), True)
test("a flat line does badly, as it should", errors[0] > 0.6, True)
test("the training error falls at every single degree",
all(errors[i + 1] < errors[i] for i in range(len(errors) - 1)), True)
test("the largest single improvement is the step up to degree three",
drops.index(max(drops)), 2)
test("the last four parameters bought less than a fifth of the error",
(errors[5] - errors[9]) / errors[5] < 0.2, True)
test("a cubic through four points hits all four exactly",
close(rmse(cubic, FOUR_X, FOUR_Y), 0.0, 1e-12), True)
test("more coefficients than data points is refused", refused, True)
What it printed, when the build ran it
sixteen points from sin(3x), with gaussian noise of 0.10 added
x y sin(3x)
-1.00 -0.1555 -0.1411
-0.87 -0.5328 -0.5155
-0.07 -0.2254 -0.1987
0.07 0.1770 0.1987
0.87 0.4417 0.5155
1.00 0.0397 0.1411
degree parameters error on the data it was fitted to
0 1 0.723787
1 2 0.488223
2 3 0.487946
3 4 0.093185
4 5 0.089890
5 6 0.044409
6 7 0.041709
7 8 0.041566
8 9 0.039136
9 10 0.038182
Every extra parameter lowered it. Not one of those numbers is evidence.
four arbitrary points, one cubic: [2.0, -2.166667, -1.5, 1.666667]
what it predicts at each of them: [1.0, 2.0, 0.0, 5.0]
error: 2.483e-15 <- a flawless fit, and it means nothing
asking for more coefficients than points is refused: 4 points cannot pin down 8 coefficients
ok the noise moved every point off the true curve
ok a flat line does badly, as it should
ok the training error falls at every single degree
ok the largest single improvement is the step up to degree three
ok the last four parameters bought less than a fifth of the error
ok a cubic through four points hits all four exactly
ok more coefficients than data points is refused
The cubic through four arbitrary points is the whole trap in miniature. Its error is
2e-15, which is as close to a perfect model as floating point permits, and it
was told nothing about where those four points came from. Any model with as many parameters
as you have rows can reproduce your data exactly, including the parts of it that are
measurement error. A training score is a statement about memory, not about skill.
Hold data back, and the second column disagrees
So score the model on rows it never saw. Shuffle the sixteen with a named seed, keep ten to fit on, lock the other six away. The seed matters more than it looks: an unseeded split gives a different score on every run, so you can never tell whether a change helped or whether you got a friendlier split.
# The same sixteen points, split in two before anything is fitted.
import math
import random
def solve(matrix, rhs):
n = len(rhs)
a = [list(row) + [rhs[i]] for i, row in enumerate(matrix)]
for col in range(n):
pivot = max(range(col, n), key=lambda r: abs(a[r][col]))
if abs(a[pivot][col]) < 1e-14:
raise ValueError("singular")
a[col], a[pivot] = a[pivot], a[col]
for r in range(col + 1, n):
factor = a[r][col] / a[col][col]
for c in range(col, n + 1):
a[r][c] -= factor * a[col][c]
out = [0.0] * n
for r in range(n - 1, -1, -1):
out[r] = (a[r][n] - sum(a[r][c] * out[c] for c in range(r + 1, n))) / a[r][r]
return out
def polyfit(xs, ys, degree):
width = degree + 1
rows = [[x ** k for k in range(width)] for x in xs]
ata = [[sum(r[i] * r[j] for r in rows) for j in range(width)] for i in range(width)]
aty = [sum(r[i] * y for r, y in zip(rows, ys)) for i in range(width)]
return solve(ata, aty)
def poly(coeffs, x):
total = 0.0
for c in reversed(coeffs):
total = total * x + c
return total
def rmse(coeffs, xs, ys):
return math.sqrt(sum((poly(coeffs, x) - y) ** 2 for x, y in zip(xs, ys)) / len(xs))
def train_test_split(xs, ys, n_train, seed):
"""Shuffle the row numbers with a named seed, then cut. The seed is the point:
an unseeded split scores differently every run, so nothing is comparable."""
order = list(range(len(xs)))
random.Random(seed).shuffle(order)
train, held = sorted(order[:n_train]), sorted(order[n_train:])
return (([xs[i] for i in train], [ys[i] for i in train]),
([xs[i] for i in held], [ys[i] for i in held]))
rng = random.Random(42)
XS = [-1.0 + i * (2.0 / 15.0) for i in range(16)]
YS = [math.sin(3.0 * x) + rng.gauss(0.0, 0.10) for x in XS]
(xtr, ytr), (xte, yte) = train_test_split(XS, YS, 10, seed=42)
print("ten rows to fit on, six held back and not looked at until the end")
print("train x:", [round(v, 3) for v in xtr])
print("test x:", [round(v, 3) for v in xte])
print()
print(" degree train test verdict")
train_err = []
test_err = []
for degree in range(10):
coeffs = polyfit(xtr, ytr, degree)
train_err.append(rmse(coeffs, xtr, ytr))
test_err.append(rmse(coeffs, xte, yte))
if degree == 0:
verdict = ""
elif test_err[-1] < min(test_err[:-1]):
verdict = "better"
else:
verdict = "WORSE, and getting worse"
print(" {:5} {:12.8f} {:11.6f} {}".format(
degree, train_err[-1], test_err[-1], verdict))
best = min(range(10), key=lambda d: test_err[d])
print()
print("lowest test error at degree {}: {:.6f}".format(best, test_err[best]))
print("degree 9 fits the training rows to {:.1e} and is {:.0f}x worse on the held-out six".format(
train_err[9], test_err[9] / test_err[best]))
print()
print("The two columns move in opposite directions after degree {}. That gap is".format(best))
print("the whole of overfitting: the model is memorising ten points, noise included.")
test("training error falls all the way down, as it always will",
all(train_err[i + 1] < train_err[i] for i in range(9)), True)
test("with ten rows and ten coefficients the training error reaches zero",
close(train_err[9], 0.0, 1e-8), True)
test("test error is worse than training error at every degree",
all(test_err[d] > train_err[d] for d in range(10)), True)
test("the best degree on held-out data is neither the smallest nor the largest",
best not in (0, 9), True)
test("past that degree the test error rises every time",
all(test_err[d + 1] > test_err[d] for d in range(best, 9)), True)
test("the flawless fit is two hundred times worse than the best one",
test_err[9] / test_err[best] > 200.0, True)
What it printed, when the build ran it
ten rows to fit on, six held back and not looked at until the end
train x: [-0.867, -0.733, -0.333, -0.2, -0.067, 0.067, 0.2, 0.333, 0.6, 0.867]
test x: [-1.0, -0.6, -0.467, 0.467, 0.733, 1.0]
degree train test verdict
0 0.68250093 0.787863
1 0.38416891 0.661124 better
2 0.38416119 0.661557 WORSE, and getting worse
3 0.05224970 0.239638 better
4 0.05166971 0.233405 better
5 0.03995617 0.072441 better
6 0.03369870 0.298805 WORSE, and getting worse
7 0.02382779 1.091486 WORSE, and getting worse
8 0.00738631 6.332269 WORSE, and getting worse
9 0.00000000 15.920658 WORSE, and getting worse
lowest test error at degree 5: 0.072441
degree 9 fits the training rows to 3.8e-11 and is 220x worse on the held-out six
The two columns move in opposite directions after degree 5. That gap is
the whole of overfitting: the model is memorising ten points, noise included.
ok training error falls all the way down, as it always will
ok with ten rows and ten coefficients the training error reaches zero
ok test error is worse than training error at every degree
ok the best degree on held-out data is neither the smallest nor the largest
ok past that degree the test error rises every time
ok the flawless fit is two hundred times worse than the best one
That table is the shape you should expect from every model you ever fit. Test error falls while extra flexibility is buying real structure, bottoms out, then climbs while the extra flexibility is buying noise. The gap between the two columns is the diagnosis: a small gap with both numbers high means underfitting, and you need a bigger model or better features. A tiny training error with a large gap means overfitting, and you need less model, more data, or a penalty. Print both. A report with one number in it cannot tell you which of those two problems you have.
What the overfitted model is actually made of
Look at the coefficients before fixing anything, because the symptom is legible. A curve that threads ten scattered points and then leaves the page between them is built from enormous terms that nearly cancel. L2 regularisation — ridge regression — adds the sum of the squared coefficients to the loss, so the fit has to pay for that size. In the normal equations it is one number added to the diagonal, and nothing else changes.
# Ridge regression: the same solve, with one number added to the diagonal.
import math
import random
def solve(matrix, rhs):
n = len(rhs)
a = [list(row) + [rhs[i]] for i, row in enumerate(matrix)]
for col in range(n):
pivot = max(range(col, n), key=lambda r: abs(a[r][col]))
if abs(a[pivot][col]) < 1e-14:
raise ValueError("singular")
a[col], a[pivot] = a[pivot], a[col]
for r in range(col + 1, n):
factor = a[r][col] / a[col][col]
for c in range(col, n + 1):
a[r][c] -= factor * a[col][c]
out = [0.0] * n
for r in range(n - 1, -1, -1):
out[r] = (a[r][n] - sum(a[r][c] * out[c] for c in range(r + 1, n))) / a[r][r]
return out
def polyfit(xs, ys, degree, penalty=0.0):
"""Minimise squared error PLUS penalty times the sum of the squared coefficients.
Setting the derivative of that to zero adds the penalty to the diagonal of the
normal equations, and changes nothing else. Index 0 is left alone: penalising
the intercept would only drag every prediction towards zero."""
width = degree + 1
rows = [[x ** k for k in range(width)] for x in xs]
ata = [[sum(r[i] * r[j] for r in rows) for j in range(width)] for i in range(width)]
aty = [sum(r[i] * y for r, y in zip(rows, ys)) for i in range(width)]
for i in range(1, width):
ata[i][i] += penalty
return solve(ata, aty)
def poly(coeffs, x):
total = 0.0
for c in reversed(coeffs):
total = total * x + c
return total
def rmse(coeffs, xs, ys):
return math.sqrt(sum((poly(coeffs, x) - y) ** 2 for x, y in zip(xs, ys)) / len(xs))
def train_test_split(xs, ys, n_train, seed):
order = list(range(len(xs)))
random.Random(seed).shuffle(order)
train, held = sorted(order[:n_train]), sorted(order[n_train:])
return (([xs[i] for i in train], [ys[i] for i in train]),
([xs[i] for i in held], [ys[i] for i in held]))
rng = random.Random(42)
XS = [-1.0 + i * (2.0 / 15.0) for i in range(16)]
YS = [math.sin(3.0 * x) + rng.gauss(0.0, 0.10) for x in XS]
(xtr, ytr), (xte, yte) = train_test_split(XS, YS, 10, seed=42)
# First the symptom. The degree 9 fit is not slightly wrong, it is violent.
wild = polyfit(xtr, ytr, 9)
tame = polyfit(xtr, ytr, 5)
print("degree 9, no penalty:", [round(v, 1) for v in wild])
print("degree 5, no penalty:", [round(v, 3) for v in tame])
print()
print("largest coefficient: {:.1f} against {:.1f}".format(
max(abs(v) for v in wild), max(abs(v) for v in tame)))
print("at x = -1 the truth is {:.4f} and degree 9 predicts {:.2f}".format(
math.sin(-3.0), poly(wild, -1.0)))
print("Huge terms nearly cancelling. That is what a curve which threads ten points")
print("and then leaves the page between them is made of.")
print()
print(" penalty train test largest coefficient")
PENALTIES = (0.0, 1e-6, 1e-4, 1e-3, 3e-3, 1e-2, 1e-1, 1.0, 10.0)
result = {}
for penalty in PENALTIES:
coeffs = polyfit(xtr, ytr, 9, penalty)
result[penalty] = (rmse(coeffs, xtr, ytr), rmse(coeffs, xte, yte),
max(abs(v) for v in coeffs))
print(" {:9.0e} {:9.5f} {:9.5f} {:12.4f}".format(penalty, *result[penalty]))
best = min(PENALTIES, key=lambda p: result[p][1])
print()
print("best penalty {:.0e}: test error {:.5f}, down from {:.5f}".format(
best, result[best][1], result[0.0][1]))
print("plain degree 5, no penalty at all: {:.5f}".format(rmse(tame, xte, yte)))
print()
print("The penalty rescued an oversized model and still did not beat the right-sized")
print("one. Regularisation buys tolerance for a bad guess at how big to go.")
test("the unpenalised degree nine coefficients run into the hundreds",
max(abs(v) for v in wild) > 300.0, True)
test("and it predicts minus thirty one where the answer is near zero",
poly(wild, -1.0) < -30.0, True)
test("a small penalty cuts the largest coefficient a hundredfold",
result[best][2] < result[0.0][2] / 100.0, True)
test("and cuts the test error a hundredfold with it",
result[best][1] < result[0.0][1] / 100.0, True)
test("the training error only ever rises as the penalty rises",
all(result[PENALTIES[i + 1]][0] > result[PENALTIES[i]][0]
for i in range(len(PENALTIES) - 1)), True)
test("so the best penalty is in the middle, because too much of it underfits",
[best not in (PENALTIES[0], PENALTIES[-1]), result[10.0][1] > result[best][1]],
[True, True])
test("even penalised, degree nine does not beat plain degree five",
result[best][1] > rmse(tame, xte, yte), True)
What it printed, when the build ran it
degree 9, no penalty: [-0.0, 3.1, 2.7, -11.4, -35.5, 111.2, 83.2, -309.6, -54.4, 234.0]
degree 5, no penalty: [-0.023, 3.17, 0.114, -4.545, -0.18, 1.42]
largest coefficient: 309.6 against 4.5
at x = -1 the truth is -0.1411 and degree 9 predicts -31.32
Huge terms nearly cancelling. That is what a curve which threads ten points
and then leaves the page between them is made of.
penalty train test largest coefficient
0e+00 0.00000 15.92066 309.5691
1e-06 0.01941 1.79600 21.4250
1e-04 0.02922 0.46044 3.0344
1e-03 0.03479 0.21319 3.1667
3e-03 0.04578 0.15307 2.8997
1e-02 0.07429 0.32293 2.6517
1e-01 0.17796 0.34037 1.9012
1e+00 0.40255 0.53508 0.8157
1e+01 0.57601 0.69381 0.2154
best penalty 3e-03: test error 0.15307, down from 15.92066
plain degree 5, no penalty at all: 0.07244
The penalty rescued an oversized model and still did not beat the right-sized
one. Regularisation buys tolerance for a bad guess at how big to go.
ok the unpenalised degree nine coefficients run into the hundreds
ok and it predicts minus thirty one where the answer is near zero
ok a small penalty cuts the largest coefficient a hundredfold
ok and cuts the test error a hundredfold with it
ok the training error only ever rises as the penalty rises
ok so the best penalty is in the middle, because too much of it underfits
ok even penalised, degree nine does not beat plain degree five
Three things in that table are worth carrying. Training error rises monotonically with
the penalty, by construction — you are deliberately no longer minimising training error, so
a rising training error is regularisation working, not breaking. The best penalty sits in
the middle, because too much of it flattens the model into a straight line. And the diagonal
term does a second job for free: it makes the system better conditioned, which is why the
unpenalised degree-9 solve is the one whose numbers you should least trust. L1 —
lasso — penalises the absolute values instead and drives coefficients to
exactly zero, which is how you get feature selection out of a fit. It has no closed form,
so it needs the loop from lesson 7.
k-fold, because choosing on the test set spends it
There is a hole in the second panel. The degree was chosen by reading the test column, which means the test set helped fit the model, and the number it reported is now optimistic. k-fold cross-validation closes the hole: deal the training rows into k piles, fit on k-1 and score on the one left out, rotate, average. Every row gets scored exactly once, the test set is never opened, and it costs k fits instead of one.
# k-fold cross validation: choose the degree without ever touching the test set.
import math
import random
def solve(matrix, rhs):
n = len(rhs)
a = [list(row) + [rhs[i]] for i, row in enumerate(matrix)]
for col in range(n):
pivot = max(range(col, n), key=lambda r: abs(a[r][col]))
if abs(a[pivot][col]) < 1e-14:
raise ValueError("singular")
a[col], a[pivot] = a[pivot], a[col]
for r in range(col + 1, n):
factor = a[r][col] / a[col][col]
for c in range(col, n + 1):
a[r][c] -= factor * a[col][c]
out = [0.0] * n
for r in range(n - 1, -1, -1):
out[r] = (a[r][n] - sum(a[r][c] * out[c] for c in range(r + 1, n))) / a[r][r]
return out
def polyfit(xs, ys, degree):
width = degree + 1
if len(xs) < width:
raise ValueError("{} points cannot pin down {} coefficients".format(len(xs), width))
rows = [[x ** k for k in range(width)] for x in xs]
ata = [[sum(r[i] * r[j] for r in rows) for j in range(width)] for i in range(width)]
aty = [sum(r[i] * y for r, y in zip(rows, ys)) for i in range(width)]
return solve(ata, aty)
def poly(coeffs, x):
total = 0.0
for c in reversed(coeffs):
total = total * x + c
return total
def rmse(coeffs, xs, ys):
return math.sqrt(sum((poly(coeffs, x) - y) ** 2 for x, y in zip(xs, ys)) / len(xs))
def train_test_split(xs, ys, n_train, seed):
order = list(range(len(xs)))
random.Random(seed).shuffle(order)
train, held = sorted(order[:n_train]), sorted(order[n_train:])
return (([xs[i] for i in train], [ys[i] for i in train]),
([xs[i] for i in held], [ys[i] for i in held]))
def folds_of(n, k, seed):
"""Shuffle the rows, then deal them round like cards into k piles."""
order = list(range(n))
random.Random(seed).shuffle(order)
return [sorted(order[i::k]) for i in range(k)]
def cross_validate(xs, ys, degree, k, seed):
"""Fit on k-1 folds, score the one left out, rotate, pool the squared errors."""
total = 0.0
for held in folds_of(len(xs), k, seed):
keep = [i for i in range(len(xs)) if i not in held]
coeffs = polyfit([xs[i] for i in keep], [ys[i] for i in keep], degree)
total += sum((poly(coeffs, xs[i]) - ys[i]) ** 2 for i in held)
return math.sqrt(total / len(xs))
rng = random.Random(42)
XS = [-1.0 + i * (2.0 / 15.0) for i in range(16)]
YS = [math.sin(3.0 * x) + rng.gauss(0.0, 0.10) for x in XS]
(xtr, ytr), (xte, yte) = train_test_split(XS, YS, 10, seed=42)
FOLDS = folds_of(len(xtr), 5, seed=42)
print("ten training rows dealt into five folds:", FOLDS)
print("each fold is the scoring set once and training data the other four times")
print()
print(" degree 5-fold cv held-out test")
cv = {}
for degree in range(8):
cv[degree] = cross_validate(xtr, ytr, degree, 5, seed=42)
print(" {:5} {:9.5f} {:13.5f}".format(
degree, cv[degree], rmse(polyfit(xtr, ytr, degree), xte, yte)))
chosen = min(cv, key=lambda d: cv[d])
honest = min(range(8), key=lambda d: rmse(polyfit(xtr, ytr, d), xte, yte))
print()
print("cross validation chooses degree {}, from training rows only".format(chosen))
print("the held-out set would have chosen degree {}".format(honest))
print()
print("Degrees 8 and 9 are missing from the table on purpose. A fold leaves eight")
print("rows to fit on, and eight rows cannot pin down nine or ten coefficients:")
try:
cross_validate(xtr, ytr, 9, 5, seed=42)
refused = False
except ValueError as error:
refused = True
print(" ", error)
test("the folds cover every training row exactly once",
sorted(i for fold in FOLDS for i in fold), list(range(len(xtr))))
test("five folds of ten rows means two rows scored per fit",
[len(f) for f in FOLDS], [2, 2, 2, 2, 2])
test("cross validation rejects the flexible models too", cv[7] > 10.0 * cv[chosen], True)
test("it picks a small degree having never seen the test set", chosen <= 5, True)
test("it does not pick exactly what the test set would have picked", chosen == honest, False)
test("but both of them reject everything above degree five",
[cv[6] > cv[chosen], cv[7] > cv[6]], [True, True])
test("a degree too large to fit inside a fold is refused, not fudged", refused, True)
What it printed, when the build ran it
ten training rows dealt into five folds: [[6, 7], [3, 9], [2, 4], [0, 8], [1, 5]]
each fold is the scoring set once and training data the other four times
degree 5-fold cv held-out test
0 0.76478 0.78786
1 0.52406 0.66112
2 0.77012 0.66156
3 0.11620 0.23964
4 0.27002 0.23340
5 0.51500 0.07244
6 1.96450 0.29880
7 6.46259 1.09149
cross validation chooses degree 3, from training rows only
the held-out set would have chosen degree 5
Degrees 8 and 9 are missing from the table on purpose. A fold leaves eight
rows to fit on, and eight rows cannot pin down nine or ten coefficients:
8 points cannot pin down 10 coefficients
ok the folds cover every training row exactly once
ok five folds of ten rows means two rows scored per fit
ok cross validation rejects the flexible models too
ok it picks a small degree having never seen the test set
ok it does not pick exactly what the test set would have picked
ok but both of them reject everything above degree five
ok a degree too large to fit inside a fold is refused, not fudged
Cross-validation picked degree 3 and the held-out set picked 5, and that disagreement is the honest state of affairs with ten rows: each fold fits on eight points and scores two, so the estimate is noisy. Both agree the answer is small, which is the decision that mattered. The failure to avoid is treating either number as final. Every time you look at a score and change something, you spend a little of that set's independence — try forty degrees and forty penalties against one held-out set of six rows and you will find a combination that looks excellent by luck alone. The standard defence is three sets: train to fit, validation to choose, test opened once at the end and never again.
What a real project would use instead
Nothing above is exotic, and all of it is one import away. The library versions matter for
two reasons beyond convenience: numpy.polyfit solves by SVD rather than the
normal equations, so the ill-conditioned high-degree fits are trustworthy in a way the panels
here are not, and StratifiedKFold keeps the class balance of lesson 10 intact
across folds, which a plain shuffle does not.
# The same experiment, from libraries.
# NOT RUN HERE - numpy and scikit-learn are not installed.
import numpy as np
from sklearn.model_selection import train_test_split, KFold, cross_val_score, GridSearchCV
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import Ridge, LassoCV, RidgeCV
from sklearn.pipeline import make_pipeline
x = np.linspace(-1.0, 1.0, 16).reshape(-1, 1)
y = np.sin(3.0 * x).ravel() + np.random.default_rng(42).normal(0.0, 0.10, 16)
x_tr, x_te, y_tr, y_te = train_test_split(x, y, test_size=6, random_state=42)
# polyfit + poly + rmse, in one line each. lstsq uses an SVD, so degree 9 is safe.
coeffs = np.polyfit(x_tr.ravel(), y_tr, deg=9)
np.sqrt(np.mean((np.polyval(coeffs, x_te.ravel()) - y_te) ** 2))
# The ridge pipeline. StandardScaler first, because a penalty on raw coefficients
# means something different for a feature measured in metres and one in millimetres.
model = make_pipeline(PolynomialFeatures(degree=9), StandardScaler(), Ridge(alpha=1e-3))
# cross_validate(...), and note the sign: scikit-learn maximises, so its error
# scorers are negated. A "best score" of -0.116 is an RMSE of 0.116.
folds = KFold(n_splits=5, shuffle=True, random_state=42)
cross_val_score(model, x_tr, y_tr, cv=folds, scoring="neg_root_mean_squared_error")
# Searching degree and penalty together, with the test set still untouched.
search = GridSearchCV(model, {
"polynomialfeatures__degree": range(1, 10),
"ridge__alpha": [1e-6, 1e-4, 1e-3, 1e-2, 1e-1, 1.0],
}, cv=folds, scoring="neg_root_mean_squared_error").fit(x_tr, y_tr)
RidgeCV(alphas=np.logspace(-6, 2, 40)).fit(x_tr, y_tr) # cross-validated alpha, one call
LassoCV(cv=folds).fit(x_tr, y_tr) # L1: some coefficients hit exactly 0
numpy and scikit-learn are not installed in this environment, so this panel was never executed. Every routine named here - the split, the polynomial fit, the ridge penalty, the k-fold loop - was written out and asserted in plain Python above, and the numbers on this page came from those implementations. The note about negated scorers and the SVD is documented library behaviour, not something verified here.
The habits, in the order you will need them. Split before you look at anything, with a seed you write down. Report training and held-out error together, because the gap between them names the problem. Expect the held-out curve to be U-shaped in every knob you turn, so neither extreme is the answer. Choose hyperparameters by cross-validation on training data, and open the test set once. None of this is specific to polynomials — the same table, the same U, and the same L2 penalty appear again for a neural network in lesson 15, where the knob is the number of hidden units and the penalty is called weight decay.
12 k-nearest neighbours and k-means
Both of these algorithms are a distance function and a loop. k-nearest neighbours classifies a point by asking what its neighbours are; k-means finds groups when nobody has labelled anything. Both are four lines of arithmetic, and both fail quietly in ways that a training score cannot see.
k-nearest neighbours, and the k that changes the answer
There is no training. Keep the labelled data, and to classify something new, sort by
distance, take the closest k, and count votes. That means every choice you make
is in k and in the distance function. Below is one query point where three
different values of k give two different answers, and each is defensible.
# k-nearest neighbours. Sort by distance, count votes, done.
import math
# Four class 0 points around the origin, eight class 1 points far away, and one
# stray class 1 sitting almost on top of the query. The stray is a mislabelled row.
LABELLED = [
((0.10, 0.05), 1), # the stray: nearest of all, and wrong
((0.40, 0.30), 0),
((-0.35, 0.45), 0),
((0.50, -0.40), 0),
((-0.50, -0.50), 0),
((2.30, 2.60), 1),
((2.70, 2.40), 1),
((2.50, 2.90), 1),
((2.90, 2.50), 1),
((2.10, 2.30), 1),
((2.60, 2.10), 1),
((3.00, 2.80), 1),
((2.40, 3.10), 1),
]
QUERY = (0.0, 0.0)
def distance(a, b):
"""Straight-line distance, from lesson 5. Everything here rests on it."""
return math.sqrt(sum((p - q) ** 2 for p, q in zip(a, b)))
def neighbours(rows, query, k):
"""The k closest rows, nearest first. The sort is the whole algorithm."""
if k < 1 or k > len(rows):
raise ValueError("k must be between 1 and {}, got {}".format(len(rows), k))
return sorted(rows, key=lambda row: distance(row[0], query))[:k]
def classify(rows, query, k):
"""Majority vote. A tie goes to whichever tied class has the nearer member."""
near = neighbours(rows, query, k)
votes = {}
for point, label in near:
votes[label] = votes.get(label, 0) + 1
most = max(votes.values())
tied = [label for label, n in votes.items() if n == most]
if len(tied) == 1:
return tied[0]
for point, label in near: # nearest first, so the first tied one wins
if label in tied:
return label
print("thirteen labelled points, sorted by distance from a query at the origin")
print(" distance label point")
for point, label in sorted(LABELLED, key=lambda row: distance(row[0], QUERY)):
print(" {:8.4f} {:5} {}".format(distance(point, QUERY), label, point))
print()
print(" k labels of the k nearest votes 0/1 answer")
answer = {}
for k in (1, 2, 3, 5, 7, 9, 13):
near = [label for _, label in neighbours(LABELLED, QUERY, k)]
answer[k] = classify(LABELLED, QUERY, k)
print(" {:3} {:24} {:3} /{:3} {}".format(
k, "".join(str(v) for v in near), near.count(0), near.count(1), answer[k]))
print()
print("k = 1 believes the one mislabelled row sitting next to the query.")
print("k = 3 outvotes it, and gives the answer the neighbourhood supports.")
print("k = 9 reaches into the far cluster, and the vote flips back.")
print("k = 13 is every row in the dataset, so it just reports the majority class.")
try:
classify(LABELLED, QUERY, 20)
refused = False
except ValueError as error:
print()
refused = True
print("asking for more neighbours than there are rows is refused:", error)
test("the single nearest neighbour is the mislabelled stray", answer[1], 1)
test("three neighbours outvote it", answer[3], 0)
test("five and seven agree with three", [answer[5], answer[7]], [0, 0])
test("nine reaches too far and flips the answer back", answer[9], 1)
test("so one point, one dataset, and two different answers", answer[1] == answer[3], False)
test("an even k can tie, and then the tie-break decides it, not the data", answer[2], 1)
test("k outside one to the number of rows is refused", refused, True)
What it printed, when the build ran it
thirteen labelled points, sorted by distance from a query at the origin
distance label point
0.1118 1 (0.1, 0.05)
0.5000 0 (0.4, 0.3)
0.5701 0 (-0.35, 0.45)
0.6403 0 (0.5, -0.4)
0.7071 0 (-0.5, -0.5)
3.1145 1 (2.1, 2.3)
3.3422 1 (2.6, 2.1)
3.4713 1 (2.3, 2.6)
3.6125 1 (2.7, 2.4)
3.8288 1 (2.5, 2.9)
3.8288 1 (2.9, 2.5)
3.9205 1 (2.4, 3.1)
4.1037 1 (3.0, 2.8)
k labels of the k nearest votes 0/1 answer
1 1 0 / 1 1
2 10 1 / 1 1
3 100 2 / 1 0
5 10000 4 / 1 0
7 1000011 4 / 3 0
9 100001111 4 / 5 1
13 1000011111111 4 / 9 1
k = 1 believes the one mislabelled row sitting next to the query.
k = 3 outvotes it, and gives the answer the neighbourhood supports.
k = 9 reaches into the far cluster, and the vote flips back.
k = 13 is every row in the dataset, so it just reports the majority class.
asking for more neighbours than there are rows is refused: k must be between 1 and 13, got 20
ok the single nearest neighbour is the mislabelled stray
ok three neighbours outvote it
ok five and seven agree with three
ok nine reaches too far and flips the answer back
ok so one point, one dataset, and two different answers
ok an even k can tie, and then the tie-break decides it, not the data
ok k outside one to the number of rows is refused
Read the two failures at the ends of that table, because they are the same two failures
as lesson 11. A small k is a high-variance model: it trusts one row, so one
mislabelled row changes the answer. A large k is a high-bias model: it averages
over so wide an area that local structure is smoothed away, and at k equal to
the whole dataset it degenerates into predicting the majority class — which is exactly the
useless 99% model of lesson 10. Odd values of k are conventional for two
classes purely to avoid ties, and a tie-break rule is a coin toss dressed up as a decision.
k = 1 gets every training row right and has learned nothing
Here is why you cannot pick k by training accuracy. Every training row is its
own nearest neighbour, at distance zero, so k = 1 scores a perfect 100% on the
data it was given. Same trap as the cubic through four points, with even less work involved.
# k = 1 has zero training error and has learned nothing. Lesson 11, again.
import math
import random
def distance(a, b):
return math.sqrt(sum((p - q) ** 2 for p, q in zip(a, b)))
def classify(rows, query, k):
near = sorted(rows, key=lambda row: distance(row[0], query))[:k]
votes = {}
for point, label in near:
votes[label] = votes.get(label, 0) + 1
most = max(votes.values())
tied = [label for label, n in votes.items() if n == most]
if len(tied) == 1:
return tied[0]
for point, label in near:
if label in tied:
return label
rng = random.Random(42)
ROWS = []
for _ in range(50):
ROWS.append(((rng.gauss(0.0, 1.3), rng.gauss(0.0, 1.3)), 0))
for _ in range(50):
ROWS.append(((rng.gauss(2.2, 1.3), rng.gauss(2.2, 1.3)), 1))
order = list(range(len(ROWS)))
random.Random(42).shuffle(order)
TRAIN = [ROWS[i] for i in order[:60]]
HELD = [ROWS[i] for i in order[60:]]
def right(data, k):
"""A count, not a ratio, so an assertion can compare whole numbers exactly."""
return sum(1 for point, label in data if classify(TRAIN, point, k) == label)
majority = max(sum(1 for _, lab in TRAIN if lab == 0), sum(1 for _, lab in TRAIN if lab == 1))
print("two overlapping clusters. 60 rows to look things up in, 40 held back.")
print("the majority class holds {} of the {} training rows".format(majority, len(TRAIN)))
print()
print(" k train test gap")
KS = (1, 3, 5, 7, 11, 17, 25, 39, 60)
train_acc = {}
test_acc = {}
for k in KS:
train_acc[k] = right(TRAIN, k) / len(TRAIN)
test_acc[k] = right(HELD, k) / len(HELD)
print(" {:3} {:.4f} {:.4f} {:+.4f}".format(
k, train_acc[k], test_acc[k], train_acc[k] - test_acc[k]))
best = max(KS, key=lambda k: test_acc[k])
usable = [k for k in KS if k != 60]
print()
print("k = 1 is right about all sixty training rows, because every training row IS")
print("its own nearest neighbour. There is no fit, no parameter and no learning.")
print("best on held-out rows: k = {} at {:.4f}".format(best, test_acc[best]))
print()
print("k = 60 lets every row vote, so every query gets the same answer.")
print(" distinct predictions across the held-out set:",
len(set(classify(TRAIN, point, 60) for point, _ in HELD)))
print(" which scores exactly the majority rate, {}/{}".format(majority, len(TRAIN)))
test("at k = 1 every single training row is classified correctly", right(TRAIN, 1), len(TRAIN))
test("yet it is the worst usable setting on rows it has not seen",
max(usable, key=lambda k: -test_acc[k]), 1)
test("k = 1 also has the widest gap between the two columns",
max(usable, key=lambda k: train_acc[k] - test_acc[k]), 1)
test("the best k is neither the smallest nor the largest", best not in (1, 60), True)
test("with every row voting there is only one answer left",
len(set(classify(TRAIN, point, 60) for point, _ in HELD)), 1)
test("and that scores exactly the majority class rate", right(TRAIN, 60), majority)
What it printed, when the build ran it
two overlapping clusters. 60 rows to look things up in, 40 held back.
the majority class holds 31 of the 60 training rows
k train test gap
1 1.0000 0.8500 +0.1500
3 0.9333 0.9250 +0.0083
5 0.9500 0.9500 +0.0000
7 0.9500 0.9250 +0.0250
11 0.9333 0.9000 +0.0333
17 0.9500 0.9250 +0.0250
25 0.9500 0.8750 +0.0750
39 0.9500 0.9250 +0.0250
60 0.5167 0.4750 +0.0417
k = 1 is right about all sixty training rows, because every training row IS
its own nearest neighbour. There is no fit, no parameter and no learning.
best on held-out rows: k = 5 at 0.9500
k = 60 lets every row vote, so every query gets the same answer.
distinct predictions across the held-out set: 1
which scores exactly the majority rate, 31/60
ok at k = 1 every single training row is classified correctly
ok yet it is the worst usable setting on rows it has not seen
ok k = 1 also has the widest gap between the two columns
ok the best k is neither the smallest nor the largest
ok with every row voting there is only one answer left
ok and that scores exactly the majority class rate
k-NN has a second cost that the table hides. There is no training, so all the work is at
prediction time: every query compares itself against every stored row, which is
O(n) distance calculations per prediction and the whole dataset kept in memory
forever. For a thousand rows that is nothing. For ten million embeddings it is the reason
approximate nearest neighbour indexes exist — HNSW, IVF, the machinery inside every vector
database — and lesson 24 is where that reappears. k-NN is also the honest baseline for any
retrieval system: if your clever model cannot beat "find the most similar thing we have
already seen", it is not earning its complexity.
The trap: distance has units
Every number above came out of one sqrt of summed squares, which quietly
assumes the columns are comparable. They usually are not. Here age in years carries all the
signal and income in pounds is pure noise, measured in a unit about fifteen hundred times
larger. Watch what the distance function does with that.
# The trap. Distance has units, and the largest unit wins.
import math
import random
def distance(a, b):
return math.sqrt(sum((p - q) ** 2 for p, q in zip(a, b)))
def classify(rows, query, k):
near = sorted(rows, key=lambda row: distance(row[0], query))[:k]
votes = {}
for point, label in near:
votes[label] = votes.get(label, 0) + 1
most = max(votes.values())
tied = [label for label, n in votes.items() if n == most]
if len(tied) == 1:
return tied[0]
for point, label in near:
if label in tied:
return label
# Age separates the two classes. Income does not: both classes are drawn from the
# same distribution. But income is measured in pounds.
rng = random.Random(42)
ROWS = []
for _ in range(50):
ROWS.append(((rng.gauss(30.0, 5.0), rng.gauss(50000.0, 15000.0)), 0))
for _ in range(50):
ROWS.append(((rng.gauss(45.0, 5.0), rng.gauss(50000.0, 15000.0)), 1))
order = list(range(len(ROWS)))
random.Random(42).shuffle(order)
TRAIN = [ROWS[i] for i in order[:60]]
HELD = [ROWS[i] for i in order[60:]]
def standardiser(rows):
"""Mean and standard deviation per column, from the TRAINING rows only."""
n = len(rows)
width = len(rows[0][0])
means = [sum(p[j] for p, _ in rows) / n for j in range(width)]
sds = []
for j in range(width):
variance = sum((p[j] - means[j]) ** 2 for p, _ in rows) / n
sds.append(math.sqrt(variance) if variance > 1e-12 else 1.0)
return means, sds
def rescale(rows, means, sds):
"""Subtract the mean, divide by the deviation. Every column now spans about the same."""
return [(tuple((p[j] - means[j]) / sds[j] for j in range(len(p))), lab) for p, lab in rows]
MEANS, SDS = standardiser(TRAIN)
S_TRAIN = rescale(TRAIN, MEANS, SDS)
S_HELD = rescale(HELD, MEANS, SDS)
a, b = TRAIN[0][0], TRAIN[1][0]
age_part = (a[0] - b[0]) ** 2
income_part = (a[1] - b[1]) ** 2
print("two rows: age {:.1f}, income {:.0f} against age {:.1f}, income {:.0f}".format(
a[0], a[1], b[0], b[1]))
print(" the age gap contributes {:14.2f} to the squared distance".format(age_part))
print(" the income gap contributes {:14.2f}".format(income_part))
print(" so age is {:.6f}% of the distance between these two people".format(
100.0 * age_part / (age_part + income_part)))
print()
print("training means {}, standard deviations {}".format(
[round(v, 2) for v in MEANS], [round(v, 2) for v in SDS]))
print()
print(" k raw features standardised")
raw = {}
scaled = {}
for k in (1, 3, 5, 7, 11):
raw[k] = sum(1 for p, lab in HELD if classify(TRAIN, p, k) == lab) / len(HELD)
scaled[k] = sum(1 for p, lab in S_HELD if classify(S_TRAIN, p, k) == lab) / len(S_HELD)
print(" {:3} {:12.4f} {:12.4f}".format(k, raw[k], scaled[k]))
print()
print("Same rows, same labels, same algorithm, two lines of arithmetic between")
print("chance and useful. Nothing raised to tell you which one you had.")
mean_age = sum(p[0] for p, _ in S_TRAIN) / len(S_TRAIN)
sd_age = math.sqrt(sum((p[0] - mean_age) ** 2 for p, _ in S_TRAIN) / len(S_TRAIN))
held_mean_age = sum(p[0] for p, _ in S_HELD) / len(S_HELD)
test("income is measured in a unit a thousand times larger than age",
SDS[1] / SDS[0] > 1000.0, True)
test("so age contributes under a millionth of the raw squared distance",
age_part / (age_part + income_part) < 1e-6, True)
test("on raw features every k is no better than a coin toss",
all(raw[k] < 0.6 for k in raw), True)
test("standardising the same rows puts every k above ninety percent",
all(scaled[k] >= 0.9 for k in scaled), True)
test("the standardised training column has mean zero and deviation one",
[close(mean_age, 0.0, 1e-12), close(sd_age, 1.0, 1e-12)], [True, True])
test("the held-out rows were shifted by the training statistics, not their own",
close(held_mean_age, 0.0, 1e-6), False)
What it printed, when the build ran it
two rows: age 27.2, income 56236 against age 20.8, income 33763
the age gap contributes 40.55 to the squared distance
the income gap contributes 505062432.37
so age is 0.000008% of the distance between these two people
training means [38.24, 49947.61], standard deviations [8.86, 13650.03]
k raw features standardised
1 0.5750 0.9250
3 0.5000 0.9250
5 0.4750 0.9250
7 0.4500 0.9000
11 0.4250 0.9250
Same rows, same labels, same algorithm, two lines of arithmetic between
chance and useful. Nothing raised to tell you which one you had.
ok income is measured in a unit a thousand times larger than age
ok so age contributes under a millionth of the raw squared distance
ok on raw features every k is no better than a coin toss
ok standardising the same rows puts every k above ninety percent
ok the standardised training column has mean zero and deviation one
ok the held-out rows were shifted by the training statistics, not their own
The last assertion is the detail people get wrong. The mean and deviation must be
computed on the training rows and then applied to everything else. Standardise the
combined dataset and information about the held-out rows leaks into the numbers used to
transform the training rows, so your test score is quietly optimistic. It is a small leak
here and a large one when the statistic is something like a per-category average of the
target. Fit the transform on train, apply it everywhere — which is precisely why
scikit-learn splits every preprocessor into fit and transform.
Any algorithm that measures distance inherits this: k-NN, k-means, SVMs with an RBF kernel,
anything penalised by coefficient size.
k-means: assign, update, repeat
Now the unsupervised half. Nobody labels anything: you say how many groups you want, and the algorithm alternates two steps until nothing moves. Assign every point to its nearest centre. Move every centre to the mean of its members. Both steps lower the same score, which is why it always stops.
# k-means: assign, update, repeat. Both halves lower the score, so it must stop.
import math
import random
rng = random.Random(42)
POINTS = []
TRUE = []
for c, (cx, cy) in enumerate(((0.0, 0.0), (6.0, 0.5), (12.0, 0.0))):
for _ in range(12):
POINTS.append((rng.gauss(cx, 0.8), rng.gauss(cy, 0.8)))
TRUE.append(c) # kept only to grade the answer at the end
def distance(a, b):
return math.sqrt(sum((p - q) ** 2 for p, q in zip(a, b)))
def assign(points, centres):
"""Every point joins its nearest centre. No label is consulted anywhere."""
return [min(range(len(centres)), key=lambda c: distance(p, centres[c])) for p in points]
def update(points, labels, k):
"""Every centre moves to the mean of its own members."""
fresh = []
for c in range(k):
members = [p for p, lab in zip(points, labels) if lab == c]
if not members:
raise ValueError("cluster {} lost every member".format(c))
fresh.append(tuple(sum(p[j] for p in members) / len(members)
for j in range(len(points[0]))))
return fresh
def inertia(points, labels, centres):
"""The only score k-means has: total squared distance from each point to its centre."""
return sum(distance(p, centres[lab]) ** 2 for p, lab in zip(points, labels))
def kmeans(points, k, seed, max_steps=50):
centres = random.Random(seed).sample(points, k) # k of the points, chosen at random
labels = assign(points, centres)
trail = [("start ", inertia(points, labels, centres))]
for step in range(max_steps):
centres = update(points, labels, k)
trail.append(("update", inertia(points, labels, centres)))
fresh = assign(points, centres)
trail.append(("assign", inertia(points, fresh, centres)))
settled = fresh == labels
labels = fresh
if settled:
return centres, labels, trail, step + 1
raise ValueError("did not settle in {} passes".format(max_steps))
centres, labels, trail, passes = kmeans(POINTS, 3, seed=1)
print("36 unlabelled points. k-means is told k = 3 and nothing else.")
print()
print(" phase inertia")
for phase, value in trail:
print(" {} {:9.4f}".format(phase, value))
print()
print("settled after {} full passes, inertia {:.4f}".format(passes, trail[-1][1]))
print("centres found :", [tuple(round(v, 3) for v in c) for c in centres])
print("centres used to make the data: (0, 0), (6, 0.5), (12, 0)")
print()
print(" found cluster size which real clusters its members came from")
for c in range(3):
members = [TRUE[i] for i in range(len(POINTS)) if labels[i] == c]
print(" {:13} {:4} {}".format(c, len(members), sorted(set(members))))
values = [v for _, v in trail]
try:
update(POINTS, [0] * len(POINTS), 3)
refused = False
except ValueError as error:
refused = True
print()
print("if a centre ends up with no members at all:", error)
test("the score never rises, at either half of any step",
all(values[i + 1] <= values[i] for i in range(len(values) - 1)), True)
test("it falls strictly until the final pass, which changes nothing",
[all(values[i + 1] < values[i] for i in range(len(values) - 2)),
close(values[-1], values[-2])], [True, True])
test("the last assignment moved no point, which is why it stopped", assign(POINTS, centres), labels)
test("it found three clusters of twelve, having never seen a label",
sorted(labels.count(c) for c in range(3)), [12, 12, 12])
test("and each one is exactly one of the real clusters",
[len(set(TRUE[i] for i in range(len(POINTS)) if labels[i] == c)) for c in range(3)],
[1, 1, 1])
test("an empty cluster is refused rather than dividing by zero", refused, True)
What it printed, when the build ran it
36 unlabelled points. k-means is told k = 3 and nothing else.
phase inertia
start 885.8303
update 240.7501
assign 228.2672
update 223.5904
assign 223.1046
update 222.5889
assign 220.3537
update 210.8169
assign 195.5938
update 161.7129
assign 89.6280
update 34.6485
assign 34.6485
settled after 6 full passes, inertia 34.6485
centres found : [(6.126, 0.285), (0.026, -0.041), (12.024, 0.3)]
centres used to make the data: (0, 0), (6, 0.5), (12, 0)
found cluster size which real clusters its members came from
0 12 [1]
1 12 [0]
2 12 [2]
if a centre ends up with no members at all: cluster 1 lost every member
ok the score never rises, at either half of any step
ok it falls strictly until the final pass, which changes nothing
ok the last assignment moved no point, which is why it stopped
ok it found three clusters of twelve, having never seen a label
ok and each one is exactly one of the real clusters
ok an empty cluster is refused rather than dividing by zero
The interesting part of that trail is the middle. Inertia drops from 885 to
228 on the first pass, then crawls to only 220 over the next two,
and then falls off a cliff to 34.6 in the last three. Nothing special happened at
the cliff: a centre finally drifted far enough that a whole block of points changed allegiance
at once. A k-means run that looks stuck is not necessarily finished, which is why the stopping
rule is "no point changed cluster" and not "the score barely moved".
The real lesson: a stable, confident, wrong answer
The run above used starting centres picked with seed 1. Nothing else about the code or the data changes below — only which three points were picked to start from. The seed this whole course uses everywhere else happens to be an unlucky one here.
# The real lesson: same data, same code, worse answer, no complaint.
import math
import random
rng = random.Random(42)
POINTS = []
TRUE = []
for c, (cx, cy) in enumerate(((0.0, 0.0), (6.0, 0.5), (12.0, 0.0))):
for _ in range(12):
POINTS.append((rng.gauss(cx, 0.8), rng.gauss(cy, 0.8)))
TRUE.append(c)
def distance(a, b):
return math.sqrt(sum((p - q) ** 2 for p, q in zip(a, b)))
def assign(points, centres):
return [min(range(len(centres)), key=lambda c: distance(p, centres[c])) for p in points]
def update(points, labels, k):
fresh = []
for c in range(k):
members = [p for p, lab in zip(points, labels) if lab == c]
if not members:
raise ValueError("cluster {} lost every member".format(c))
fresh.append(tuple(sum(p[j] for p in members) / len(members)
for j in range(len(points[0]))))
return fresh
def inertia(points, labels, centres):
return sum(distance(p, centres[lab]) ** 2 for p, lab in zip(points, labels))
def kmeans(points, k, seed, max_steps=50):
centres = random.Random(seed).sample(points, k)
labels = assign(points, centres)
for step in range(max_steps):
centres = update(points, labels, k)
fresh = assign(points, centres)
settled = fresh == labels
labels = fresh
if settled:
return centres, labels, inertia(points, labels, centres), step + 1
raise ValueError("did not settle")
runs = dict((seed, kmeans(POINTS, 3, seed)) for seed in list(range(1, 21)) + [42])
print("the same 36 points, k = 3, and only the starting centres different")
print()
print(" seed passes inertia cluster sizes")
for seed in (1, 2, 11, 12, 17, 42):
_, labels, score, passes = runs[seed]
print(" {:4} {:6} {:9.4f} {}".format(
seed, passes, score, sorted(labels.count(c) for c in range(3))))
good = runs[1]
unlucky = runs[42]
print()
print("seed 42 settled in {} passes and stopped, {:.2f} times worse than seed 1.".format(
unlucky[3], unlucky[2] / good[2]))
print("its centres:", [tuple(round(v, 2) for v in c) for c in unlucky[0]])
print()
print(" found cluster size real clusters mixed into it")
for c in range(3):
members = [TRUE[i] for i in range(len(POINTS)) if unlucky[1][i] == c]
print(" {:13} {:4} {}".format(c, len(members), sorted(set(members))))
print()
print("Two of its centres landed inside the left-hand cluster and carved it up")
print("between them. The third was left covering both of the others. Every point")
print("is already in its nearest cluster, so no point wants to move, so the")
print("algorithm is finished. It is wrong, it is stable, and nothing raised.")
# The fix is not cleverness, it is repetition: run it from several starts and keep
# the run with the lowest inertia.
scores = dict((seed, runs[seed][2]) for seed in range(1, 21))
best_seed = min(scores, key=lambda s: scores[s])
bad = [s for s in scores if scores[s] > scores[best_seed] + 1e-9]
print()
print("twenty runs, each from a different start:")
print(" distinct answers reached:", sorted(set(round(v, 4) for v in scores.values())))
print(" {} of the 20 landed above the best one".format(len(bad)))
print(" keeping the lowest inertia gives {:.4f}".format(scores[best_seed]))
still = assign(POINTS, update(POINTS, unlucky[1], 3))
test("the unlucky start reached a genuine stopping point, not a half-finished one",
still, unlucky[1])
test("its score is six times worse than the good run", unlucky[2] / good[2] > 6.0, True)
test("it merged two real clusters together and split a third",
sorted(unlucky[1].count(c) for c in range(3)) == [12, 12, 12], False)
test("it also stopped sooner, so a pass count says nothing about quality",
unlucky[3] < good[3], True)
test("inertia is the only signal that anything went wrong", unlucky[2] > good[2], True)
test("keeping the best of twenty restarts finds the good answer",
close(scores[best_seed], good[2]), True)
test("but four of the twenty were bad, so a single run is a gamble", len(bad) >= 4, True)
What it printed, when the build ran it
the same 36 points, k = 3, and only the starting centres different
seed passes inertia cluster sizes
1 6 34.6485 [12, 12, 12]
2 3 224.9202 [5, 8, 23]
11 3 251.2943 [3, 9, 24]
12 1 254.4851 [2, 10, 24]
17 1 251.2943 [3, 9, 24]
42 2 224.5397 [3, 10, 23]
seed 42 settled in 2 passes and stopped, 6.48 times worse than seed 1.
its centres: [(-0.09, -1.06), (0.45, 0.27), (9.3, 0.3)]
found cluster size real clusters mixed into it
0 3 [0]
1 10 [0, 1]
2 23 [1, 2]
Two of its centres landed inside the left-hand cluster and carved it up
between them. The third was left covering both of the others. Every point
is already in its nearest cluster, so no point wants to move, so the
algorithm is finished. It is wrong, it is stable, and nothing raised.
twenty runs, each from a different start:
distinct answers reached: [34.6485, 224.9202, 251.2943, 254.4851]
4 of the 20 landed above the best one
keeping the lowest inertia gives 34.6485
ok the unlucky start reached a genuine stopping point, not a half-finished one
ok its score is six times worse than the good run
ok it merged two real clusters together and split a third
ok it also stopped sooner, so a pass count says nothing about quality
ok inertia is the only signal that anything went wrong
ok keeping the best of twenty restarts finds the good answer
ok but four of the twenty were bad, so a single run is a gamble
This is the failure to remember from the whole lesson. There were no
labels, so there was no accuracy to check, no held-out set to be honest with you, and no
exception. The run converged in two passes, faster than the correct one, and returned three
clusters. The only number that noticed was inertia — 224.5 against 34.6 — and you can only
read that as bad by having something to compare it with. Which is the whole argument for
restarts: k-means is cheap, so run it ten or twenty times from different starts and keep the
best. That is what scikit-learn's n_init does, and why the default init is
k-means++, which spreads the initial centres out instead of picking uniformly
and makes the bad start much rarer without eliminating it.
Two more things k-means will not tell you, both of which fall on you. It cannot choose
k: inertia falls monotonically as k rises and reaches exactly zero
when every point is its own cluster, so minimising it is the lesson-11 trap in a new
costume. The usual tools are the elbow in the inertia curve, which is a judgement call, and
the silhouette score, which is a real measurement. And it can only find round, similarly
sized, similarly dense blobs, because "nearest centre" carves space into straight-edged
regions. Two interleaved crescents have obvious structure and no k-means answer at all,
which is where DBSCAN and spectral clustering come in.
What a real project would use instead
# Both algorithms, from a library.
# NOT RUN HERE - numpy and scikit-learn are not installed.
import numpy as np
from sklearn.neighbors import KNeighborsClassifier
from sklearn.cluster import KMeans, DBSCAN
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.model_selection import GridSearchCV
from sklearn.metrics import silhouette_score
# k-NN. StandardScaler is inside the pipeline for the reason the third panel showed,
# and being inside it means the k-fold loop refits the scaler on each fold - so the
# leak the panel warned about cannot happen by accident.
knn = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5))
search = GridSearchCV(knn, {"kneighborsclassifier__n_neighbors": [1, 3, 5, 7, 11, 17]}, cv=5)
# weights="distance" votes by 1/distance instead of one-row-one-vote, which softens
# the large-k smoothing. algorithm="kd_tree" replaces the O(n) scan per query.
KNeighborsClassifier(n_neighbors=5, weights="distance", algorithm="kd_tree")
# k-means. n_init=20 is the restart loop from the last panel, and init="k-means++"
# is the smarter start. Both are defences against exactly the seed-42 outcome.
model = KMeans(n_clusters=3, n_init=20, init="k-means++", random_state=42).fit(x)
model.inertia_ # inertia(...)
model.labels_, model.cluster_centers_ # labels, centres
# Choosing k, honestly. Inertia always falls, so it cannot be minimised; silhouette
# can be, because it measures separation between clusters as well as tightness within.
for k in range(2, 8):
fit = KMeans(n_clusters=k, n_init=20, random_state=42).fit(x)
print(k, fit.inertia_, silhouette_score(x, fit.labels_))
DBSCAN(eps=0.5, min_samples=5).fit(x) # no k, finds odd shapes, marks outliers -1
MiniBatchKMeans # the version that scales past memory
numpy and scikit-learn are not installed in this environment, so this panel was never executed. Every routine named here - the distance, the vote, the assign and update steps, the inertia, the standardiser and the restart loop - was written out and asserted in plain Python above, and every number on this page came from those implementations. The claims about kd_tree, k-means++ and DBSCAN are documented library behaviour, not something verified here.
That closes classical machine learning. Look back at what the five lessons share: a
model is a function with parameters, a loss scores it on a sample, and every number that
comes out describes the sample rather than the world. The traps were all the same shape too
— a score that flatters you (training error, accuracy, inertia), a default nobody chose on
purpose (the 0.5 threshold, k, the initial centres), and a silent unit or scale
assumption. None of that changes in the neural network lessons that follow. What changes is
that the function in the middle stops being linear, which buys the ability to learn
exclusive-or, curved boundaries and crescents — and costs you the closed-form answer, the
convex loss, and any hope of reading the parameters.