Real Data: Reading Handwritten Digits
Seventy thousand handwritten digits, straight from the original files. Load them, look at them, split them, then train a template matcher and a one-hidden-layer network with everything from Chapters 5 to 12, and see which digits fool it.
Inspect Architecture: Multi-layer perceptron1. The idea
Every dataset so far has been small and made up for the purpose: four rides, a season of picnic days, a year of commutes generated from a formula. This chapter uses real data that nobody designed for us: MNIST, 70,000 digits handwritten by American high-school students and census employees, scanned and scaled to 28 × 28 pixels by Yann LeCun, Corinna Cortes and Christopher Burges. It has been the first real dataset of almost every machine learning course since 1998, because it's large enough to be honest and small enough to train on a laptop in a minute.
Real data needs a few steps before any training. The code downloads the four original files and checks each one against a known fingerprint (a SHA-256 hash), so a damaged or substituted file stops the program instead of quietly training on garbage. It then reads the IDX file format by hand: a short header giving the array's shape, followed by one byte per pixel. Then it does the most underrated step of all: it prints an image and its label, to check that the data is what we think it is. Finally it flattens each image to 784 numbers between 0 and 1 and splits the 60,000 training images into 50,000 for training and 10,000 for validation, keeping the official 10,000 test images for the very end (Chapter 11).
The first model is Chapter 9's softmax regression, with 784 inputs instead of 3. Its ten weight vectors can be drawn as images, and they turn out to be templates: an image's score for a digit is how well its ink overlaps that digit's template. After five epochs it reads 91.96% of the test digits correctly. Adding one hidden layer of 128 ReLU units with He initialization (Chapter 12), training with mini-batches and Adam (Chapters 7 and 10) and keeping the epoch with the best validation loss (Chapter 11) gives 97.62%, or 238 mistakes in 10,000.
The mistakes are worth looking at. The network most often reads 8 as 3 or 5, 5 as 3, and confuses 4 with 9, and several of those digits would make a person hesitate too. What neither model knows is that a digit shifted two pixels to the right is still the same digit. Each pixel is a separate input with its own weights, so a template only matches ink in the exact places it was learned. Building that knowledge into the network is the idea behind convolutional networks, in Chapter 15.
2. The math
70,000 grayscale images, 28 × 28 pixels, values 0 (white) to 255 (black ink)
official split: 60,000 training, 10,000 test
training images per digit: from 5,421 (fives) to 6,742 (ones)bytes 0–1: zero byte 2: type (8 = unsigned byte)
byte 3: number of dimensions d
then d sizes, 4 bytes each, big-endian then the values, one byte each
images: 4 + 4·3 = 16 header bytes, then 60,000 × 28 × 28 pixel bytesx = flatten(image) / 255 ∈ [0, 1]⁷⁸⁴ mean pixel 0.131
split: 50,000 training · 10,000 validation · 10,000 testalways guess the commonest digit: 10.9% of validation images
uniform guess, p = 1/10 for every digit: loss ln 10 = 2.3026zₖ = wₖ · x + bₖ for k = 0 … 9 wₖ has 784 numbers: a 28 × 28 image
knobs: 10 × 784 + 10 = 7,850Adam η = 0.001, batches of 128, 5 epochs, from all-zero weights
validation 92.43% test 91.96% (loss 0.2849)784 → 128 ReLU → 10 knobs: 784·128 + 128 + 128·10 + 10 = 101,770
He initialization: σ₁ = √(2/784) = 0.0505, σ₂ = √(2/128) = 0.125
50,000 / 128 = 391 updates per epochepoch 1: train 94.24% validation 94.54%
epoch 9: validation loss 0.0826, the lowest: these weights are kept
epoch 10: train 99.34% validation 97.56%
test: 97.62% (loss 0.0776), 238 mistakes in 10,000most common mistakes (true → predicted):
8 → 3: 13 8 → 5: 11 5 → 3: 11 9 → 4: 10 4 → 9: 10 9 → 7: 9
accuracy per digit: 0 and 1: 99.2% · 9: 96.3% · 8: 95.0%forward, per image: 784·128 + 128·10 = 101,632 multiply-adds
backward ≈ 2 × forward ⇒ one training step ≈ 3 × forward
one epoch ≈ 3 × 101,632 × 50,000 ≈ 1.5 × 10¹⁰ multiply-adds• Digit k's score is zₖ = Σᵢ wₖᵢ·xᵢ + bₖ, a sum over the 784 pixels. Ink (xᵢ near 1) on pixels where wₖᵢ > 0 raises the score; ink where wₖᵢ < 0 lowers it; blank pixels (xᵢ = 0) contribute nothing whatever the weight.
• Softmax picks the digit with the largest score, so the model predicts the digit whose template the image's ink overlaps best, after the biases. That's all a linear model can do: one template per class.
• The gradient for one example is (p − y)ₖ·x for row k (Chapter 9), so training adds a little of each image to its own digit's template and subtracts a little from the templates that wrongly scored it highly. Over 50,000 images the templates become blurred averages of each digit, sharpened where digits differ.
• Now shift an image two pixels to the right. Its ink lands on different pixels, xᵢ becomes xᵢ₋₂, and nothing in the model connects pixel i to pixel i − 2: they have unrelated weights. The overlap with the right template drops, and with a thin stroke it can drop to almost nothing. ∎
• The hidden layer helps, because its 128 features can each respond to a stroke in a range of places, but it has to learn that separately for every place. Chapter 15 builds shift tolerance in directly, by reusing the same small set of weights at every position.
• A dense layer computes Z = X Wᵀ for a batch X of N rows with d inputs and k outputs. That's N·d·k multiply-adds.
• The backward pass (Chapter 6) computes two products: dW = dZᵀ X, which is k·N·d multiply-adds, and dX = dZ W, which is N·k·d. Each costs the same as the forward product.
• So each layer's backward pass costs about twice its forward pass, and a full training step (forward plus backward) about three times. The first layer's dX can be skipped, since nothing needs the gradient with respect to the pixels, which saves a little.
• For the 784-128-10 network, the forward pass is 784·128 + 128·10 = 101,632 multiply-adds per image, a training step about 3 × 101,632 ≈ 305,000, and an epoch of 50,000 images about 1.5 × 10¹⁰. ∎
• Activation functions, softmax and the Adam update add a little on top, but in a network made of matrix products those products dominate. This 'about three times the forward pass' rule holds for most networks, including the transformer in Chapter 16.
3. How it works
4. The code (python)
# Chapter 13: real data. Handwritten digits (MNIST), from raw files to a network that reads them.
# Uses tensor.py (Chapter 6) and optim.py (Chapter 10's Adam, as in Chapter 11).
# MNIST is by Yann LeCun, Corinna Cortes and Christopher Burges, licensed CC BY-SA 3.0.
import gzip
import hashlib
import os
import struct
import urllib.request
import numpy as np
from tensor import Tensor
from optim import Adam
# === 1. Get the files, and check they are the right ones ==========================
MIRROR = "https://storage.googleapis.com/cvdf-datasets/mnist/"
FILES = {"train-images-idx3-ubyte.gz": "440fcabf73cc546fa21475e81ea370265605f56be210a4024d2ca8f203523609",
"train-labels-idx1-ubyte.gz": "3552534a0a558bbed6aed32b30c495cca23d567ec52cac8be1a0730e8010255c",
"t10k-images-idx3-ubyte.gz": "8d422c7b0a1c1c79245a5bcf07fe86e33eeafee792b84584aec276f5a2dbc4e6",
"t10k-labels-idx1-ubyte.gz": "f7ae60f92e00ec6debd23a6088c31dbd2371eca3ffa0defaefb259924204aec6"}
os.makedirs("mnist", exist_ok=True)
for name, sha in FILES.items():
path = os.path.join("mnist", name)
if not os.path.exists(path):
urllib.request.urlretrieve(MIRROR + name, path)
with open(path, "rb") as f:
assert hashlib.sha256(f.read()).hexdigest() == sha, f"{name} is damaged or not the file we expect"
def read_idx(name):
"""IDX format: two zero bytes, a type code, the number of dimensions, each size (big-endian), then raw bytes."""
with gzip.open(os.path.join("mnist", name), "rb") as f:
data = f.read()
_zero, _type, ndim = struct.unpack(">HBB", data[:4])
shape = struct.unpack(">" + "I" * ndim, data[4:4 + 4 * ndim])
return np.frombuffer(data, dtype=np.uint8, offset=4 + 4 * ndim).reshape(shape)
train_images, train_labels = read_idx("train-images-idx3-ubyte.gz"), read_idx("train-labels-idx1-ubyte.gz")
test_images, test_labels = read_idx("t10k-images-idx3-ubyte.gz"), read_idx("t10k-labels-idx1-ubyte.gz")
print("train:", train_images.shape, " test:", test_images.shape, " pixel values", train_images.min(), "to", train_images.max())
print("training examples per digit:", np.bincount(train_labels).tolist())
# === 2. Look at one before doing anything else =====================================
print(f"\nthe first training image, labelled {train_labels[0]}:")
for row in train_images[0][4:24]:
print(" " + "".join(" .:#"[v // 64] for v in row[3:25]))
# === 3. Flatten, scale, split =======================================================
X_all = train_images.reshape(-1, 784) / 255.0 # each image becomes 784 numbers between 0 and 1
X_test = test_images.reshape(-1, 784) / 255.0
X_train, y_train = X_all[:50000], train_labels[:50000]
X_val, y_val = X_all[50000:], train_labels[50000:] # the last 10,000 training images, for choosing (Chapter 11)
print(f"\nmean pixel {X_train.mean():.4f}; 50,000 training, 10,000 validation, 10,000 test")
print(f"always guessing the commonest digit: {np.bincount(y_val).max() / len(y_val):.1%} of validation images;"
f" loss of a uniform guess: ln 10 = {np.log(10):.4f}")
def softmax_cross_entropy(Z, labels):
E = (Z + (-Z.data.max(axis=1, keepdims=True))).exp()
P = E / (E @ Tensor(np.ones((10, 1))))
return -(Tensor(np.eye(10)[labels]) * P.log()).sum() / Z.shape[0], P
def evaluate(model, X, labels):
loss, P = softmax_cross_entropy(model(X), labels)
return float(loss.data), float(np.mean(P.data.argmax(axis=1) == labels)), P.data.argmax(axis=1)
def train(params, model, epochs, lr=0.001, batch=128, seed=0):
"""Mini-batch Adam (Chapters 7 and 10). Keeps the weights from the epoch with the lowest validation loss."""
opt = Adam(params, lr)
order = np.random.default_rng(seed)
best = (np.inf, None, 0)
for epoch in range(1, epochs + 1):
shuffled = order.permutation(len(X_train))
for start in range(0, len(X_train), batch):
rows = shuffled[start:start + batch]
loss, _ = softmax_cross_entropy(model(X_train[rows]), y_train[rows])
for p in params:
p.grad = np.zeros_like(p.data)
loss.backward()
opt.step()
tl, ta, _ = evaluate(model, X_train, y_train)
vl, va, _ = evaluate(model, X_val, y_val)
print(f" epoch {epoch:2}: train {tl:.4f} ({ta:.2%}) validation {vl:.4f} ({va:.2%})")
if vl < best[0]:
best = (vl, [p.data.copy() for p in params], epoch)
for p, saved in zip(params, best[1]):
p.data = saved
return best[2]
# === 4. Softmax regression: one score per digit, straight from the pixels ===========
W = Tensor(np.zeros((10, 784)))
b = Tensor(np.zeros(10))
linear = lambda X: Tensor(X) @ W.T + b
print("\nsoftmax regression (7,850 knobs):")
kept = train([W, b], linear, epochs=5)
loss, acc, _ = evaluate(linear, X_test, test_labels)
print(f"kept epoch {kept}; test: loss {loss:.4f}, accuracy {acc:.2%}")
# === 5. One hidden layer of 128 ReLU units, He initialization (Chapter 12) ===========
rng = np.random.default_rng(0)
W1, b1 = Tensor(rng.normal(0, np.sqrt(2 / 784), (128, 784))), Tensor(np.zeros(128))
W2, b2 = Tensor(rng.normal(0, np.sqrt(2 / 128), (10, 128))), Tensor(np.zeros(10))
mlp = lambda X: (Tensor(X) @ W1.T + b1).relu() @ W2.T + b2
print("\n784-128-10 network (101,770 knobs):")
kept = train([W1, b1, W2, b2], mlp, epochs=10)
loss, acc, predicted = evaluate(mlp, X_test, test_labels)
print(f"kept epoch {kept}; test: loss {loss:.4f}, accuracy {acc:.2%}, {np.sum(predicted != test_labels)} mistakes in 10,000")
# === 6. Which digits does it mix up? ===================================================
confusion = np.zeros((10, 10), dtype=int)
np.add.at(confusion, (test_labels, predicted), 1)
np.fill_diagonal(confusion, 0)
pairs = sorted(((confusion[t, p], t, p) for t in range(10) for p in range(10) if t != p), reverse=True)[:6]
print("most common mistakes (true → predicted, count):", ", ".join(f"{t}→{p}: {n}" for n, t, p in pairs))
print("accuracy per digit:", [f"{d}: {1 - confusion[d].sum() / np.sum(test_labels == d):.1%}" for d in range(10)])
5. Practice
Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.
6. Go further
- Shift every test image two pixels to the right (np.roll along the column axis, then set the two wrapped-around columns to 0) and measure both trained models' accuracy on the shifted images. How much does each lose? Which digits suffer most? Keep the numbers: Chapter 15 runs the same test on a convolutional network.
- Train the network on raw pixel values, 0 to 255 instead of 0 to 1, first with Adam at η = 0.001 and then with plain gradient descent at η = 0.1. What happens in each case, and which chapter explains it?
- Try to beat 98% on the validation set: more hidden units, a second hidden layer, layer norm, an L2 penalty, more epochs with early stopping. Keep a record of every setting and its validation accuracy, choose your best, and only then measure it once on the test set.
7. Check yourself
In the catalog
Finished this chapter?