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.
Inspect Architecture: Convolutional neural networks1. 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.
2. The math
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)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)8 filters × (25 + 1) = 208 knobs
a dense layer with the same 24 × 24 × 8 outputs:
784 × 4,608 = 3,612,672 weightsconv(shift(x)) = shift(conv(x)) away from the edges
the code checks it: shift by 2 columns, compare → Truefilters: dK = patchesᵀ · dZ (Chapter 6's matrix rule)
input: each pixel sits in up to 25 patches: add up their gradients2 × 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 three28 × 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,738Adam η = 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)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%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• 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
4. The code (python)
# 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.
6. Go further
- 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.
- 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.
- 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
Finished this chapter?