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 15

Same Weights Everywhere: From Dense Layers to Convolutions

A dense network gives every pixel its own weights, so a digit moved two pixels looks like a stranger. A convolutional layer slides one small set of weights over the whole image instead: far fewer knobs, better accuracy, and a network that copes much better with shifts.

47 min Prerequisites: Chapters 6, 13 and 14 (tensor.py, optim.py, mnist/ and digits_model.npz)
Inspect Architecture: Convolutional neural networks

1. The idea

Chapter 13 ended with a weakness. In a dense network every pixel has its own weights, so nothing tells the network that a stroke at column 10 and the same stroke at column 12 are related. Shift the test digits two pixels to the right and Chapter 14's saved network drops from 96.9% to 75.1%; shift them four and it gets fewer than a third right. Yet a person wouldn't even notice the shift.

A convolutional layer builds that knowledge in. Instead of connecting every pixel to every unit, it takes a small filter, here 5 × 5 pixels, and slides it across the image, computing the same weighted sum at every position. One filter has 26 numbers (25 weights and a bias) however large the image is, and it produces a feature map showing where in the image its pattern appears. If the digit moves, the feature map moves with it. Eight filters give 208 knobs; a dense layer producing the same 24 × 24 × 8 outputs would need 3.6 million weights. Then max-pooling keeps only the largest value in each 2 × 2 block, which halves the size and lets a feature wander by a pixel without changing the result.

The engine needs no new calculus. A convolution is a matrix product in disguise: lay every 5 × 5 patch out as a row, and multiplying by the filters gives every output at once. So the chapter adds three small operations, each with a short backward rule: unfold (patches to rows), reshape, and max-pool. A gradient check confirms them to nine digits. There's one practical lesson too. The engine's graphs contain reference cycles, which Python frees only from time to time, and with patch arrays this large the backlog can fill the computer's memory in minutes. So the training loop frees each step's graph explicitly.

The result is a network with 11,738 knobs, about a ninth of the dense network's 101,770, that reads 97.99% of the test digits after five epochs. Shift the digits two pixels and it still gets 88.5%; at three pixels 72.7% against the dense network's 50.2%. It isn't immune to shifts, because one round of pooling only absorbs small ones and its last layer is still dense, but the idea scales: stacks of convolutions and pooling are what made computer vision work, and the same principle of sharing weights across positions returns in the capstone's language model.

A handwritten 7 from the test set, 28 by 28 pixels, then eight small 5 by 5 grids showing the filters the network learned, coloured where weights are positive or negative. Under each filter is its 24 by 24 feature map for the 7: bright where the filter's pattern was found. Some filters respond mostly to the horizontal top stroke of the 7, others to its diagonal stem, and some to edges on one side of the strokes.A handwritten 7 from the test set, 28 by 28 pixels, then eight small 5 by 5 grids showing the filters the network learned, coloured where weights are positive or negative. Under each filter is its 24 by 24 feature map for the 7: bright where the filter's pattern was found. Some filters respond mostly to the horizontal top stroke of the 7, others to its diagonal stem, and some to edges on one side of the strokes.
Each filter is one set of 26 numbers applied at every position, so its feature map shows where in the image its pattern occurs. The digit is an MNIST test image (CC BY-SA 3.0).

2. The math

Convolution: one filter, every position
z[i, j, f] = Σ_(a, b, c) K[f, a, b, c] · x[i + a, j + b, c] + bias_f 28 × 28 input, 5 × 5 filter, no padding: output 24 × 24 (28 − 5 + 1)
i, j are output positions, a, b run over the filter, c over input channels (1 here: grey levels), f over filters. Each output is a dot product between the filter and the patch under it.
A convolution is a matrix product
unfold: every 5 × 5 patch becomes a row of 25 numbers 128 images: (128·576 × 25) patches @ (25 × 8) filters = (73,728 × 8) then reshape to (128, 24, 24, 8)
Often called im2col. It turns the sliding window into Chapter 6's matrix product, so the engine's existing rule handles the filters' gradients. Real libraries use cleverer methods, but this one is exact.
Sharing weights
8 filters × (25 + 1) = 208 knobs a dense layer with the same 24 × 24 × 8 outputs: 784 × 4,608 = 3,612,672 weights
The convolution assumes a useful pattern is useful anywhere in the image. That assumption replaces millions of free weights, and it's what lets the network generalize across positions.
Shift the input, and the feature map shifts with it
conv(shift(x)) = shift(conv(x)) away from the edges the code checks it: shift by 2 columns, compare → True
This property is called equivariance. A dense layer has no such property: each output looks at fixed pixel positions. Derived in the first derivation.
The backward pass
filters: dK = patchesᵀ · dZ (Chapter 6's matrix rule) input: each pixel sits in up to 25 patches: add up their gradients
A filter is one set of weights used at 576 positions, so by Chapter 5's rule its gradient is the sum of all 576 contributions, which is exactly what the matrix product computes. The second derivation spells it out.
Max-pooling
2 × 2 blocks → keep the largest: (24 × 24 × 8) → (12 × 12 × 8) backward: the whole gradient goes to each block's winner, 0 to the other three
Pooling shrinks the maps four times over and keeps the strongest response in each neighbourhood, so a feature that moves within its 2 × 2 block gives the same output.
The network, shape by shape
28 × 28 × 1 → conv 8 × 5 × 5 → 24 × 24 × 8 → ReLU → max-pool → 12 × 12 × 8 → flatten 1,152 → dense → 10 scores knobs: 208 + 1,152 × 10 + 10 = 11,738
He initialization (Chapter 12): spread √(2/25) for the filters, each of which sees 25 inputs, and √(2/1152) for the dense layer.
Results
Adam η = 0.001, batches of 128, 5 epochs (about 35 s each here) validation: 95.01%, 96.72%, 97.57%, 97.72%, 98.01% test: 97.99% (Chapter 13's dense network: 97.62% with 101,770 knobs)
Fewer knobs and better accuracy, because the knobs it has are spent on patterns rather than positions. A second convolutional layer would do better still (second exercise).
Chapter 13's shift test
shift right dense (Chapter 14) CNN 0 pixels 96.86% 97.99% 1 pixel 93.97% 96.86% 2 pixels 75.11% 88.47% 3 pixels 50.22% 72.67% 4 pixels 30.41% 51.48%
The CNN degrades far more gracefully, but it still degrades: one round of 2 × 2 pooling absorbs about one pixel, and its final dense layer still ties each feature to a position. Training on shifted copies helps (first exercise).
Memory: why the loop calls gc.collect()
patches for one batch: 73,728 × 25 × 8 bytes ≈ 14.7 MB plus a gradient array just as large each backward closure refers to its own output → reference cycles without gc.collect(): memory grows about 20 MB per step
Python frees cycles only when its collector runs, which is triggered by the number of objects, not their size. With big arrays the backlog grew to gigabytes while writing this chapter. Freeing each step's graph keeps memory flat; so does evaluating in batches.

• Write a shift by s columns as (Sx)[i, j] = x[i, j − s]. Apply the convolution to the shifted image: conv(Sx)[i, j] = Σ_(a, b) K[a, b] · (Sx)[i + a, j + b] = Σ_(a, b) K[a, b] · x[i + a, j + b − s].

• Now shift the convolution of the original image: (S conv(x))[i, j] = conv(x)[i, j − s] = Σ_(a, b) K[a, b] · x[i + a, j − s + b].

• The two sums are term-for-term identical, because the same K[a, b] multiplies the same pixel in both. So conv(Sx) = S conv(x). ∎

• The only exceptions are near the edges, where the shift pushes pixels out of the image or brings in blank columns. That's why the code compares the maps away from the left edge.

• The argument needs one thing: the same K at every position. A dense layer has a different weight for every (pixel, output) pair, so the terms no longer match, and that's exactly what the shift test exposes.

• A filter's weight K[a, b] is used at all 576 output positions. By Chapter 5's rule for shared values, its gradient is the sum of the contributions from every use: ∂L/∂K[a, b] = Σ_(i, j) ∂L/∂z[i, j] · x[i + a, j + b].

• Each term is 'how much the loss wants output (i, j) to change' times 'the pixel this weight saw there'. Positions where the pattern matters push the weight; blank background (x = 0) contributes nothing.

• In the unfolded form, the patches matrix P has one row per position with the 25 pixels each weight sees, so the sum is (Pᵀ · dZ)[a·5 + b], Chapter 6's dW = (input)ᵀ · dZ. ∎

• The input side works the same way in reverse. Pixel (p, q) appears in every patch that covers it, up to 25 of them, so its gradient adds up the matching entries of each of those patches' gradients. That's the double loop in unfold's _backward.

• So a filter learns from every location at once. A single training image gives it 576 examples of its pattern, which is one more reason convolutions need fewer images than dense layers to learn the same thing.

3. How it works

1
Keep the image's shape
Store images as (N, height, width, channels) instead of flattening them. Convolutions need to know which pixels are neighbours.
2
Convolve with a few small filters
Unfold every k × k patch into a row, multiply by the filters, reshape back into feature maps, and apply ReLU.
3
Pool to shrink
Keep the largest value in each 2 × 2 block. The maps get four times smaller and small shifts stop mattering.
4
Flatten into a dense layer
After the convolutions, flatten the remaining maps and finish with a dense layer and softmax, as in Chapter 9.
5
Mind the memory
Patches multiply the data size by k² (25 here). Free each step's graph, evaluate in batches, and estimate array sizes before running on a whole dataset.

4. The code (python)

core_ch15.py
# Chapter 15: from dense layers to convolutions. Same weights at every position, so a shifted digit is still a digit.
# Uses tensor.py (Chapter 6), optim.py (Chapter 10's Adam), the mnist/ folder (Chapter 13)
# and digits_model.npz, the dense network saved in Chapter 14.
import gc
import gzip
import os
import struct
import time
import numpy as np
from tensor import Tensor
from optim import Adam

# === 1. Three new operations, each with its own backward rule =====================
def reshape(x, shape):
    out = Tensor(x.data.reshape(shape), (x,), "reshape")

    def _backward():                                   # same numbers, so the gradient just changes shape back
        x.grad += out.grad.reshape(x.shape)
    out._backward = _backward
    return out


def unfold(x, k):
    """(N, H, W, C) → (N, H−k+1, W−k+1, k·k·C): every k × k patch laid out as one row (often called im2col).
    Given a plain array, such as the input images, it skips the backward pass: data needs no gradient."""
    data = x.data if isinstance(x, Tensor) else x
    N, H, W, C = data.shape
    Ho, Wo = H - k + 1, W - k + 1
    windows = np.lib.stride_tricks.sliding_window_view(data, (k, k), axis=(1, 2))   # (N, Ho, Wo, C, k, k)
    cols = windows.transpose(0, 1, 2, 4, 5, 3).reshape(N, Ho, Wo, k * k * C)
    if not isinstance(x, Tensor):
        return Tensor(cols)
    out = Tensor(cols, (x,), "unfold")

    def _backward():                                   # each pixel appears in up to k·k patches: add them all up
        g = out.grad.reshape(N, Ho, Wo, k, k, C)
        for i in range(k):
            for j in range(k):
                x.grad[:, i:i + Ho, j:j + Wo, :] += g[:, :, :, i, j, :]
    out._backward = _backward
    return out


def maxpool2(x):
    """(N, H, W, C) → (N, H/2, W/2, C): keep the largest value in each 2 × 2 block."""
    N, H, W, C = x.shape
    blocks = x.data.reshape(N, H // 2, 2, W // 2, 2, C)
    biggest = blocks.max(axis=(2, 4))
    out = Tensor(biggest, (x,), "maxpool")

    def _backward():                                   # the gradient goes only to the winner of each block
        winner = blocks == biggest[:, :, None, :, None, :]
        winner = winner / winner.sum(axis=(2, 4), keepdims=True)
        x.grad += (winner * out.grad[:, :, None, :, None, :]).reshape(x.shape)
    out._backward = _backward
    return out


def conv(x, K, kb, k):
    """Convolution as a matrix product: every patch times every filter. K is (filters × k·k·channels)."""
    patches = unfold(x, k)
    N, Ho, Wo, P = patches.shape
    return reshape(reshape(patches, (N * Ho * Wo, P)) @ K.T + kb, (N, Ho, Wo, K.shape[0]))

# Gradient check on a tiny example (Chapter 4's habit, applied to the new operations)
r = np.random.default_rng(0)
x = Tensor(r.normal(size=(2, 8, 8, 3)))
Kc, kc = Tensor(r.normal(size=(4, 27))), Tensor(r.normal(size=4))
probe = r.normal(size=(2, 3, 3, 4))
f = lambda: (maxpool2(conv(x, Kc, kc, 3).relu()) * Tensor(probe)).sum()
f().backward()
worst = 0.0
for t, idx in [(x, (0, 2, 3, 1)), (x, (1, 5, 5, 0)), (Kc, (2, 10)), (Kc, (3, 26)), (kc, (1,))]:
    old = t.data[idx]
    t.data[idx] = old + 1e-6
    up = float(f().data)
    t.data[idx] = old - 1e-6
    down = float(f().data)
    t.data[idx] = old
    numeric = (up - down) / 2e-6
    worst = max(worst, abs(numeric - t.grad[idx]) / (abs(numeric) + abs(t.grad[idx])))
print(f"gradient check of unfold, conv, ReLU and max-pool: worst relative error {worst:.1e}")

# === 2. Why sharing weights matters ================================================
print(f"\none 5×5 filter: 25 weights + 1 bias, reused at all 24×24 = 576 positions")
print(f"8 filters: {8 * 26} knobs;  a dense layer making the same 24×24×8 outputs: {784 * 24 * 24 * 8:,} weights")

# Shift the input, and the feature map shifts with it (away from the edges)
img = r.random((1, 28, 28, 1))
moved = np.roll(img, 2, axis=2)
moved[:, :, :2] = 0
K1 = Tensor(r.normal(size=(1, 25)))
a = conv(img, K1, Tensor(np.zeros(1)), 5).data
b = conv(moved, K1, Tensor(np.zeros(1)), 5).data
print("conv(shifted image) equals shifted conv(image), away from the left edge:",
      np.allclose(b[:, :, 2:], a[:, :, :-2]))

# === 3. A small convolutional network for the digits =================================
def read_idx(name):
    with gzip.open(os.path.join("mnist", name), "rb") as fh:
        data = fh.read()
    ndim = data[3]
    shape = struct.unpack(">" + "I" * ndim, data[4:4 + 4 * ndim])
    return np.frombuffer(data, dtype=np.uint8, offset=4 + 4 * ndim).reshape(shape)

images, 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")
X_train, y_train = images[:50000, :, :, None] / 255.0, labels[:50000]     # keep the 28×28 shape, 1 channel
X_val, y_val = images[50000:, :, :, None] / 255.0, labels[50000:]

rng = np.random.default_rng(0)
K = Tensor(rng.normal(0, np.sqrt(2 / 25), (8, 25)))          # 8 filters of 5×5 (He initialization, Chapter 12)
kb = Tensor(np.zeros(8))
W = Tensor(rng.normal(0, np.sqrt(2 / 1152), (10, 1152)))      # 12×12×8 = 1,152 pooled features → 10 scores
b = Tensor(np.zeros(10))
params = [K, kb, W, b]
print(f"\nCNN: conv 8×5×5 → ReLU → 2×2 max-pool → dense 10;  {sum(p.data.size for p in params):,} knobs")


def scores(X):
    h = maxpool2(conv(X, K, kb, 5).relu())                    # (N, 12, 12, 8)
    return reshape(h, (len(X), 1152)) @ W.T + b


def softmax_cross_entropy(Z, y):
    E = (Z + (-Z.data.max(axis=1, keepdims=True))).exp()
    P = E / (E @ Tensor(np.ones((10, 1))))
    return -(Tensor(np.eye(10)[y]) * P.log()).sum() / Z.shape[0]


def accuracy(predict, X, y, batch=500):
    """In batches, collecting each batch's graph: the patches alone for 10,000 images would be about 1 GB."""
    right = 0
    for s in range(0, len(X), batch):
        right += int(np.sum(predict(X[s:s + batch]).argmax(axis=1) == y[s:s + batch]))
        gc.collect()
    return right / len(X)

opt = Adam(params, 0.001)
shuffle = np.random.default_rng(0)
for epoch in range(1, 6):
    t0 = time.time()
    order = shuffle.permutation(len(X_train))
    for start in range(0, len(X_train), 128):
        rows = order[start:start + 128]
        loss = softmax_cross_entropy(scores(X_train[rows]), y_train[rows])
        for p in params:
            p.grad = np.zeros_like(p.data)
        loss.backward()
        opt.step()
        del loss
        gc.collect()          # each step's graph holds reference cycles; free its big arrays now, not eventually
    print(f"  epoch {epoch}: validation {accuracy(lambda X: scores(X).data, X_val, y_val):.2%}  ({time.time() - t0:.0f} s)")
cnn = lambda X: scores(X).data
print(f"test accuracy: {accuracy(cnn, test_images[:, :, :, None] / 255.0, test_labels):.2%}")

# === 4. Chapter 13's shift test: the dense network from Chapter 14 against the CNN ====
with np.load("digits_model.npz") as saved:
    dense = {k: saved[k] for k in saved.files}
mlp = lambda X: np.maximum(0, X.reshape(len(X), 784) @ dense["W1"].T + dense["b1"]) @ dense["W2"].T + dense["b2"]


def shift_right(raw, pixels):
    out = np.roll(raw, pixels, axis=2)
    out[:, :, :pixels] = 0                                    # blank the columns that wrapped around
    return out

print("\nshift right by   dense network (101,770 knobs)   CNN (11,738 knobs)")
for pixels in [0, 1, 2, 3, 4]:
    X_shift = shift_right(test_images, pixels) / 255.0
    print(f"  {pixels} pixels        {accuracy(mlp, X_shift, test_labels):10.2%}"
          f"                   {accuracy(cnn, X_shift[:, :, :, None], test_labels):8.2%}")

5. Practice

Work these out on paper (or in Python) and type the number. Answers are checked with a small tolerance for rounding.

P1 A 28 × 28 image goes through a 5 × 5 convolution with no padding. How wide is each feature map?
Width − filter size + 1.
Solution. 28 − 5 + 1 = 24: the filter's top-left corner can sit at 24 positions along each row.
P2 A layer has 16 filters of 3 × 3 over an input with 8 channels. How many knobs does it have, with one bias per filter?
Each filter has 3 × 3 × 8 weights.
Solution. 16 × (72 + 1) = 1,168, whatever the size of the image.
P3 24 × 24 × 8 feature maps go through 2 × 2 max-pooling and are flattened. How many numbers come out?
Pooling halves the width and the height; the 8 maps stay 8.
Solution. 12 × 12 × 8 = 1,152, the input size of the CNN's dense layer.
P4 For a batch of 128 images, how many rows does the unfolded patches matrix have?
One row per output position per image.
Solution. 128 × 24 × 24 = 73,728 rows of 25 numbers each.
P5 At 8 bytes per number, how many megabytes do those patches take? (1 MB = 1,000,000 bytes; 1 decimal place.)
73,728 × 25 × 8 bytes.
Solution. 14,745,600 bytes ≈ 14.7 MB, and the engine keeps a gradient array of the same size. Hundreds of uncollected steps of that is gigabytes.
P6 A 2 × 2 block holds [0.2, 0.9; 0.5, 0.1], and a gradient of 3 arrives at its pooled output. What gradient goes to the 0.5?
Only the largest value affected the output.
Solution. 0. The whole 3 goes to the 0.9; nudging the other three a little wouldn't change the maximum.
P7 Shifting the test digits two pixels drops the dense network from 96.86% to 75.11%. How many percentage points is that?
Subtract.
Solution. 96.86 − 75.11 = 21.75 points. The CNN loses 97.99 − 88.47 = 9.52.
P8 How many weights would a dense layer need to turn 784 pixels into 24 × 24 × 8 outputs?
784 × 4,608.
Solution. 3,612,672, against 208 for the convolution that produces the same shape.

6. Go further

  1. Train with shifted copies (data augmentation): for each batch, shift every image left or right by a random 0 to 2 pixels before the forward pass. Rerun the shift test. How much does the CNN gain at 2 and 3 pixels, and does the unshifted accuracy change? Try the same trick on the dense network.
  2. Add a second convolutional layer: after the first pooling, 16 filters of 5 × 5 over the 8 channels (12 × 12 × 8 → 8 × 8 × 16), ReLU, pool again to 4 × 4 × 16 = 256, then the dense layer. Count the knobs, estimate the patch memory per batch before running, and compare accuracy and shift robustness with the one-layer CNN.
  3. Replace 'flatten, then dense' with global average pooling: average each of the 8 maps over all positions to get 8 numbers, then a dense layer from 8 to 10. Now no weight is tied to a position. How does the shift test change, and what does the network lose?

7. Check yourself

Answer all 5 questions correctly to complete the chapter · 0 / 5 done
Q1/5 What does weight sharing in a convolutional layer mean?
Q2/5 How does the chapter compute a convolution with the existing engine?
Q3/5 During max-pooling's backward pass, where does a block's gradient go?
Q4/5 After a 3-pixel shift, the CNN gets 72.7% where the dense network gets 50.2%. Why isn't the CNN completely unaffected?
Q5/5 Why does the CNN's training loop call gc.collect() after every step?

Finished this chapter?