Learn machine learning from scratch

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

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

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.

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

1. 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.

Top: ten small 28 by 28 images, one per digit 0 to 9, showing the weights softmax regression learned. Each is a blotchy pattern in two colours: one marks pixels where ink raises that digit's score, the other pixels where ink lowers it. The 0 shows a ring with a hole in the middle, and the 2 and 3 show their curves. Bottom: six handwritten test digits the network got wrong, labelled 8 → 3, 8 → 5, 5 → 3, 9 → 4, 4 → 9 and 9 → 7. Several are written unusually, such as an 8 with an open top loop and a 9 with a short, open loop.Top: ten small 28 by 28 images, one per digit 0 to 9, showing the weights softmax regression learned. Each is a blotchy pattern in two colours: one marks pixels where ink raises that digit's score, the other pixels where ink lowers it. The 0 shows a ring with a hole in the middle, and the 2 and 3 show their curves. Bottom: six handwritten test digits the network got wrong, labelled 8 → 3, 8 → 5, 5 → 3, 9 → 4, 4 → 9 and 9 → 7. Several are written unusually, such as an 8 with an open top loop and a 9 with a short, open loop.
What a linear model learns is a template per digit, and an image scores well when its ink overlaps that template. Below, mistakes from the network; several would make a person hesitate too. MNIST images (LeCun, Cortes and Burges) are licensed CC BY-SA 3.0.

2. The math

The data
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)
The classes are nearly balanced, so accuracy is a fair summary. On a dataset where one class is 99% of the examples, 99% accuracy would mean nothing.
The IDX file format
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 bytes
Big-endian means the most significant byte comes first. struct.unpack('>I', …) reads one such 4-byte size, and np.frombuffer turns the rest into an array without copying.
From picture to input vector
x = flatten(image) / 255 ∈ [0, 1]⁷⁸⁴ mean pixel 0.131 split: 50,000 training · 10,000 validation · 10,000 test
Most pixels are background, which is why the mean is low. Dividing by 255 keeps inputs on a scale like Chapter 7's standardized ones; the last 10,000 training images serve as the validation set.
Baselines
always guess the commonest digit: 10.9% of validation images uniform guess, p = 1/10 for every digit: loss ln 10 = 2.3026
Any model has to beat these to have learned something. Chapter 9's practice problem predicted this ln 10 baseline.
Softmax regression: ten templates
zₖ = wₖ · x + bₖ for k = 0 … 9 wₖ has 784 numbers: a 28 × 28 image knobs: 10 × 784 + 10 = 7,850
A dot product adds up weight × pixel, so ink on a pixel with positive weight raises digit k's score and ink on a negative weight lowers it. The figure draws each wₖ as an image.
Softmax regression: results
Adam η = 0.001, batches of 128, 5 epochs, from all-zero weights validation 92.43% test 91.96% (loss 0.2849)
Training and validation accuracy stay close (91.9% and 92.4%): with 7,850 knobs and 50,000 images, this model is too simple to memorize. Its limit is bias, not variance.
One hidden layer
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 epoch
The hidden layer gives the network 128 learned features to combine, instead of matching raw pixels against templates directly.
One hidden layer: results
epoch 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,000
By epoch 10 the train–validation gap has opened to almost 2 points, and validation loss has turned up: the start of Chapter 11's overfitting. Keeping the best epoch costs nothing.
Where the network goes wrong
most 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%
A confusion table counts each (true, predicted) pair. The pairs are digits that share strokes: a 4 with a closed top looks like a 9, and an 8 with a gap looks like a 3.
What a training step costs
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
Derived in the second derivation. That's a few seconds per epoch here, because NumPy hands each matrix product to optimized code. Chapter 5's one-number-at-a-time engine would take hours.

• 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

1
Download and verify
Fetch the files once, cache them, and check each against a known SHA-256 hash. A failed check stops the program before anything is trained on a bad file.
2
Parse the format
Read the header to learn the shape, then turn the remaining bytes into an array. Print the shapes and the value range, and compare with what the dataset's description promises.
3
Look at examples
Print or draw a few images with their labels. Mislabelled or rotated data, swapped files and off-by-one shapes are all easy to see and hard to debug later.
4
Scale, flatten and split
Divide pixels by 255, flatten each image to 784 numbers, and split off a validation set before training. Leave the test set alone until the end.
5
Baseline, then build up
Compute the trivial baselines, train the simplest model, then a bigger one, keeping the best epoch by validation loss. Look at the mistakes, not just the accuracy.

4. The code (python)

core_ch13.py
# 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.

P1 How many knobs does a 784-128-10 network have, counting biases?
784·128 + 128 + 128·10 + 10.
Solution. 100,352 + 128 + 1,280 + 10 = 101,770, about thirteen times as many as softmax regression's 7,850.
P2 A pixel has the value 191 in the file. What input does the network see? (3 decimal places.)
Divide by 255.
Solution. 191 / 255 ≈ 0.749.
P3 With 50,000 training images and batches of 128, how many updates make one epoch? (The last batch is smaller.)
Divide and round up.
Solution. 50,000 / 128 = 390.6, so 390 full batches and one batch of 80: 391 updates.
P4 An IDX file holds a 3-dimensional array. How many bytes does its header take?
4 bytes of magic number, then 4 bytes per dimension.
Solution. 4 + 3 × 4 = 16. The label files are 1-dimensional, so their header is 4 + 4 = 8 bytes.
P5 The network makes 238 mistakes on the 10,000 test images. What is its error rate, in percent?
Mistakes divided by total.
Solution. 238 / 10,000 = 2.38%, the other side of 97.62% accuracy.
P6 How many multiply-adds does one forward pass of a single image through the 784-128-10 network's weights take?
One per weight: 784 × 128 + 128 × 10.
Solution. 100,352 + 1,280 = 101,632. Biases and ReLU add a few hundred more operations.
P7 What spread does He initialization give the first layer, which has 784 inputs? (4 decimal places.)
σ = √(2 / n_in).
Solution. √(2 / 784) ≈ 0.0505.
P8 A template has weight +0.5 on a pixel of brightness 0.8, weight −0.3 on a pixel of brightness 1.0, and every other inked pixel has weight 0. What do those pixels add to the score?
Add weight × pixel over the pixels.
Solution. 0.5·0.8 + (−0.3)·1.0 = 0.4 − 0.3 = 0.1. Ink on the negative weight cancels most of the evidence from the positive one.

6. Go further

  1. 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.
  2. 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?
  3. 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

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 Why does the code check each downloaded file against a SHA-256 hash?
Q2/5 What do softmax regression's weights look like when each row is drawn as a 28 × 28 image?
Q3/5 The network's training accuracy reaches 99.34% while validation stays at 97.56%. What does that gap tell you?
Q4/5 Why can't softmax regression recognize a digit that's shifted two pixels sideways as well as an unshifted one?
Q5/5 About how many forward passes' worth of work is one training step of a network made of dense layers?

Finished this chapter?