In Lesson 6 we trained a fully connected MLP successfully on flat data. But images are not flat data: a picture carries spatial structure, and the pixel next to another pixel means something. This lesson introduces the architecture built for that — the convolutional neural network.
pip install torch numpy. The project at the end
downloads nothing and does not need torchvision — it draws its own
dataset, so it runs even with no internet connection. Knowledge you need: Lesson 6 — the four-line training loop and
optimizer.zero_grad(). This lesson reuses that exact loop and only changes the architecture
inside it. Lesson 3 helps too: an image is just a multi-dimensional NumPy array.
7.1 What convolution actually is
To process an image with a fully connected network (an MLP), the first thing we have to do is flatten it into a one-dimensional vector. That operation destroys the spatial relationship between neighbouring pixels entirely — a pixel in the top-left corner is severed from the pixel immediately below it.
Worse, imagine a colour image at the modest resolution of $256 \times 256 \times 3$ (height, width, and 3 RGB channels). Feeding it into an MLP hidden layer of $1000$ neurons means that layer alone has to learn: $$256 \times 256 \times 3 \times 1000 \approx 196.6 \text{ million weights!}$$ An enormous number, which makes the model extremely prone to overfitting and chokes GPU memory.
CNNs solve this with two design ideas borrowed from the biological retina:
- Local receptive fields: instead of connecting a neuron to every pixel of the previous layer, a CNN neuron connects only to a small neighbouring window (its receptive field).
- Shared weights: one filter (kernel) slides across the whole image, left to right and top to bottom. This means the entire image shares one small weight matrix, letting the network recognise a feature (a slanted corner, a vertical edge) wherever on the image it happens to appear — translation invariance.
Here is a worked example with a $3 \times 3$ filter: $$\text{input } I = \begin{bmatrix} 1 & 1 & 1 \\ 0 & 1 & 1 \\ 0 & 0 & 1 \end{bmatrix} \quad * \quad \text{kernel } K = \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 1 \end{bmatrix}$$ The single number in the top-left cell of the result is: $$(1 \times 1) + (1 \times 0) + (1 \times 1) + (0 \times 0) + (1 \times 1) + (1 \times 0) + (0 \times 1) + (0 \times 0) + (1 \times 1) = 1 + 0 + 1 + 0 + 1 + 0 + 0 + 0 + 1 = 4$$
But PyTorch's
nn.Conv2d does not flip. It performs
cross-correlation — laying the kernel down as-is and multiplying, exactly as the worked
example above did. The whole deep learning field calls it "convolution" out of historical momentum.
Why nobody fixes the name: the kernel's weights are learned by the network, so if the operation needed a flipped kernel the network would simply learn a pre-flipped one. The end result is identical, and flipping would just cost work.
The example above does not reveal the difference because the chosen kernel $\begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 1 \end{bmatrix}$ is symmetric under a 180° rotation — flipped or not, it gives 4. With an asymmetric kernel the two operations give different answers. Know this so a research paper does not trip you up; it does not change how you write code.
In practice, a convolution has three parameters to configure:
- Kernel size: usually an odd number such as $3 \times 3$ or $5 \times 5$, so there is a symmetric centre neuron.
- Stride ($S$): how far the filter moves after each multiplication step. At $S=1$ it shifts one pixel at a time. At $S=2$ it skips two pixels, shrinking the output feature map.
- Padding ($P$): sliding the filter means pixels at the outer edge get convolved fewer times than those in the middle, so the image shrinks with every layer and information at the border is lost. To fix that, we add borders of zeros (zero padding) around the image before sliding the filter.
Example: a $28 \times 28$ input with $K=3$, $P=1$, $S=1$: $$H_{\text{out}} = \left\lfloor \frac{28 - 3 + 2(1)}{1} \right\rfloor + 1 = 28$$ So padding of $P=1$ preserves the $28 \times 28$ spatial size through the convolution.
7.2 Pooling layers & hierarchical feature extraction
After a convolutional layer we usually insert a pooling layer. Pooling slides a window over the image and condenses the information:
- Max pooling: keeps only the largest value in the window (typically $2 \times 2$ with stride $S=2$). This is the default in CNN architectures because it retains the most strongly activated feature — the highest contrast, for instance.
- Average pooling: takes the mean of every pixel in the window. Less used in hidden layers, but common in the final layer before the classifier (global average pooling).
At the same time, shrinking the image enlarges the receptive field of neurons in later layers. A neuron deep in the network, having passed through several pooling layers, "sees" a large region of the original input image. That is how a CNN extracts features hierarchically: the first layers learn tiny raw details (lines, corners); the middle layers learn part-shapes (circles, textures); the deepest layers combine everything to recognise a whole object (a face, a car).
7.3 Anatomy of a complete CNN
A full image-classification CNN splits into two distinct parts:
-
Feature extractor: stacked blocks of
Conv2d$\to$ReLU$\to$MaxPool2d. This part learns the spatial features of the image. -
Linear classifier: after extraction we flatten the 3D feature matrix into a 1D vector
and pass it through fully connected
Linearlayers with activations, to predict the class label.
forward(),
the data flowing through successive convolution and pooling layers keeps shrinking. By the time it reaches the first
nn.Linear, you must have worked out the tensor's flattened
size by hand. If the real flattened size is $C \times H \times W$ (channels times height times width),
the nn.Linear input parameter must equal exactly $C \cdot H \cdot W$. Get it wrong and
PyTorch raises RuntimeError: mat1 and mat2 shapes cannot be multiplied at run time.
Here is the forward-pass structure of a complete CNN:
import torch
import torch.nn as nn
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# --- spatial feature extractor ---
self.feature_extractor = nn.Sequential(
# Conv 1: 1 input channel (greyscale) -> 16 feature channels.
# padding=1 keeps the size, so the output is 16 x 28 x 28.
nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2), # halves it -> 16 x 14 x 14
# Conv 2: 16 channels -> 32 channels, output 32 x 14 x 14
nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2) # halves it again -> 32 x 7 x 7
)
# --- linear classifier ---
# After extraction the tensor is (batch, 32, 7, 7); flattening gives
# (batch, 32*7*7 = 1568). That 1568 is where the shape error comes from.
self.classifier = nn.Sequential(
nn.Linear(32 * 7 * 7, 128), # must match the flattened size exactly
nn.ReLU(),
nn.Linear(128, num_classes) # raw logits, one per class
)
def forward(self, x):
features = self.feature_extractor(x)
# start_dim=1 keeps the batch dimension and flattens everything after it.
flat_features = torch.flatten(features, start_dim=1)
return self.classifier(flat_features)
Now let's return to the number warned about in section 7.1 (an MLP needing ~196 million weights) and compare it CONCRETELY against the two convolutional layers just defined, on the same $28 \times 28$ MNIST-sized input:
# Parameters in the two Conv2d layers defined above.
# Conv1: 16 filters, each (1 input channel * 3 * 3) weights + 1 bias
conv1_params = 16 * (1 * 3 * 3 + 1) # = 160
# Conv2: 32 filters, each (16 input channels * 3 * 3) weights + 1 bias
conv2_params = 32 * (16 * 3 * 3 + 1) # = 4,640
feature_extractor_total = conv1_params + conv2_params
print(f"two Conv2d layers (the feature extractor): {feature_extractor_total:,}") # 4,800
# For comparison: the SIMPLEST possible linear layer taking the same image
# already flattened (28*28 = 784) to 128 hidden neurons — matching the width of
# the classifier behind it, and extracting no spatial structure whatsoever.
mlp_equivalent_params = 784 * 128 + 128
print(f"one equivalent linear layer (784 -> 128): {mlp_equivalent_params:,}") # 100,480
print(f"the CNN uses {mlp_equivalent_params / feature_extractor_total:.1f}x fewer") # ~21x
The real figures: with just 4,800 parameters, two convolutional layers extract spatial features from all 784 pixels — roughly 21 times fewer than the simplest possible linear layer of the same output width, and that linear layer has not even "seen" the 2D spatial structure of the image (it only handles a flat vector). This is the concrete number behind the theoretical explanation of "shared weights + local connectivity" from section 7.1.
7.4 Hands-on project: training a CNN to recognise shapes
This lesson's project trains the exact CNN defined above, and runs entirely offline — no
downloads, no torchvision.
torchvision, so a flaky connection stops the whole
lesson. The script therefore draws its own dataset: ten distinct shapes (ring, vertical
bar, horizontal bar, plus, two diagonals, cross, hollow box, solid box, double bar) standing in for ten
digit classes, with noise and a random offset. The crucial part: these ten shapes are GENUINELY LEARNABLE. An earlier version of this script fed the network
np.random.randn images with np.random.randint labels —
pure noise, with no relationship at all between image and label. That task is
unlearnable by construction: accuracy sat permanently at chance level (15% across 10
classes) while the training loss still fell steadily — because the network was memorising 200 random
labels. Remember that signature; it will save you repeatedly: a falling loss while validation accuracy never moves off chance level means the network is memorising, not learning. A falling loss on its own proves nothing.
To train on real MNIST: install
torchvision and swap make_shape_dataset() for
torchvision.datasets.MNIST. Everything else stays — architecture, loop and loss function
need not change a line.
# train_mnist_cnn.py
# Lesson 7: Computer vision basics — convolutional networks
# Practical AI Engineer series
#
# Run it with: python train_mnist_cnn.py
# Requires: pip install torch numpy (no download, no torchvision)
#
# WHY THE DATA IS SYNTHETIC, AND WHAT THAT COSTS
# Real MNIST needs a ~10 MB download and torchvision, which makes the lesson fail
# on a bad connection. So this script draws its own dataset: ten distinct shapes
# standing in for ten digit classes, plus noise and a random offset.
#
# The important part: these shapes are GENUINELY LEARNABLE. An earlier version of
# this script fed the network `np.random.randn` images with `np.random.randint`
# labels — pure noise with no relationship between image and label. That task is
# unlearnable by construction, so accuracy sat at chance level while the training
# loss still fell, because the network was memorising 200 random labels. A falling
# loss with chance-level accuracy is the signature of exactly that mistake.
#
# To train on real MNIST instead, install torchvision and replace
# make_shape_dataset() with torchvision.datasets.MNIST. Everything else is unchanged.
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
CLASS_NAMES = [
'ring',
'vertical bar',
'horizontal bar',
'plus',
'diagonal \\',
'diagonal /',
'cross X',
'hollow box',
'solid box',
'double bar',
]
def draw_shape(cls, rng):
"""One 28x28 shape for the given class, jittered slightly off centre."""
img = np.zeros((28, 28), dtype=np.float32)
cy, cx = rng.integers(11, 17), rng.integers(11, 17)
t = 2 # stroke thickness
clip = lambda v: int(np.clip(v, 0, 27))
if cls == 0: # ring
yy, xx = np.ogrid[:28, :28]
r = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
img[(r > 6) & (r < 6 + t + 1)] = 1
elif cls == 1: # vertical bar
img[cy - 9 : cy + 9, cx - 1 : cx + t] = 1
elif cls == 2: # horizontal bar
img[cy - 1 : cy + t, cx - 9 : cx + 9] = 1
elif cls == 3: # plus
img[cy - 9 : cy + 9, cx - 1 : cx + t] = 1
img[cy - 1 : cy + t, cx - 9 : cx + 9] = 1
elif cls == 4: # diagonal \
for k in range(-9, 9):
img[clip(cy + k), clip(cx + k)] = 1
elif cls == 5: # diagonal /
for k in range(-9, 9):
img[clip(cy + k), clip(cx - k)] = 1
elif cls == 6: # cross X
for k in range(-9, 9):
img[clip(cy + k), clip(cx + k)] = 1
img[clip(cy + k), clip(cx - k)] = 1
elif cls == 7: # hollow box
img[cy - 8 : cy + 8, cx - 8 : cx - 8 + t] = 1
img[cy - 8 : cy + 8, cx + 8 - t : cx + 8] = 1
img[cy - 8 : cy - 8 + t, cx - 8 : cx + 8] = 1
img[cy + 8 - t : cy + 8, cx - 8 : cx + 8] = 1
elif cls == 8: # solid box
img[cy - 6 : cy + 6, cx - 6 : cx + 6] = 1
else: # double bar
img[cy - 5 : cy - 5 + t, cx - 8 : cx + 8] = 1
img[cy + 5 : cy + 5 + t, cx - 8 : cx + 8] = 1
return img
def make_shape_dataset(n_samples=2000, seed=42):
"""Balanced dataset of the ten shapes, with noise, shuffled."""
rng = np.random.default_rng(seed)
X = np.empty((n_samples, 1, 28, 28), dtype=np.float32)
y = np.empty(n_samples, dtype=np.int64)
for i in range(n_samples):
cls = i % 10
X[i, 0] = np.clip(draw_shape(cls, rng) + rng.normal(0, 0.12, (28, 28)), 0, 1)
y[i] = cls
order = rng.permutation(n_samples)
return torch.tensor(X[order]), torch.tensor(y[order])
class MNIST_CNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2), # 28x28 -> 14x14
nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2), # 14x14 -> 7x7
)
# 32 * 7 * 7 is not a magic number: it is the shape the block above emits.
# Get it wrong and you get "mat1 and mat2 shapes cannot be multiplied".
self.classifier = nn.Sequential(
nn.Linear(32 * 7 * 7, 64),
nn.ReLU(),
nn.Linear(64, num_classes), # raw logits — CrossEntropyLoss wants logits
)
def forward(self, x):
x = self.features(x)
x = torch.flatten(x, 1) # keep the batch dimension, flatten the rest
return self.classifier(x)
def print_sample(img, label):
"""Show one training image as text, so you can see what the network sees."""
print(f'\nsample input — class {label} ({CLASS_NAMES[label]}):')
for row in range(4, 26):
print(' ' + ''.join('#' if v > 0.5 else ('.' if v > 0.25 else ' ') for v in img[row]))
def train():
torch.manual_seed(42)
X, y = make_shape_dataset(2000)
split = 1600
X_train, y_train = X[:split], y[:split]
X_val, y_val = X[split:], y[split:]
print(f'train {tuple(X_train.shape)} | validation {tuple(X_val.shape)}')
print_sample(X_train[0, 0].numpy(), int(y_train[0]))
model = MNIST_CNN()
n_params = sum(p.numel() for p in model.parameters())
print(f'\nmodel has {n_params:,} parameters')
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
epochs, batch_size = 30, 64
print('\n=== training ===')
for epoch in range(1, epochs + 1):
model.train()
running = 0.0
# Mini-batches, not one giant batch: more update steps per pass over the
# data, which is how real training is always done.
for start in range(0, len(X_train), batch_size):
xb = X_train[start : start + batch_size]
yb = y_train[start : start + batch_size]
optimizer.zero_grad()
loss = criterion(model(xb), yb)
loss.backward()
optimizer.step()
running += loss.item() * len(xb)
if epoch % 5 == 0 or epoch == 1:
model.eval()
with torch.no_grad():
val_acc = (model(X_val).argmax(1) == y_val).float().mean().item() * 100
print(f'epoch {epoch:2d}/{epochs} | train loss {running / len(X_train):.4f} | val accuracy {val_acc:.2f}%')
model.eval()
with torch.no_grad():
preds = model(X_val).argmax(1)
acc = (preds == y_val).float().mean().item() * 100
print(f'\n=== final validation accuracy: {acc:.2f}% (chance level is 10%) ===')
# Per-class accuracy: an overall number can hide one class the model never gets.
print('\nper-class accuracy:')
for cls in range(10):
mask = y_val == cls
if mask.sum():
hit = (preds[mask] == cls).float().mean().item() * 100
print(f' {cls} {CLASS_NAMES[cls]:16} {hit:6.1f}% ({int(mask.sum())} samples)')
if __name__ == '__main__':
train()
Running it produces this (middle trimmed):
train (1600, 1, 28, 28) | validation (400, 1, 28, 28)
sample input — class 9 (double bar):
...
# #
################
################
...
model has 105,866 parameters
=== training ===
epoch 1/30 | train loss 1.9055 | val accuracy 71.25%
epoch 5/30 | train loss 0.0070 | val accuracy 100.00%
epoch 10/30 | train loss 0.0011 | val accuracy 100.00%
...
epoch 30/30 | train loss 0.0001 | val accuracy 100.00%
=== final validation accuracy: 100.00% (chance level is 10%) ===
per-class accuracy:
0 ring 100.0% (47 samples)
1 vertical bar 100.0% (31 samples)
2 horizontal bar 100.0% (35 samples)
3 plus 100.0% (38 samples)
4 diagonal \ 100.0% (43 samples)
5 diagonal / 100.0% (41 samples)
6 cross X 100.0% (43 samples)
7 hollow box 100.0% (37 samples)
8 solid box 100.0% (47 samples)
9 double bar 100.0% (38 samples)
Four things worth reading out. One: the script prints one input sample as text before
training. Look at it — that is the only way to know what the network actually sees, and if you
ever change the data generation, this is where a mistake shows up immediately. Two: epoch
1 already reaches 71%, and epoch 5 is at 100% — considerably faster than Lesson 6, despite this network
being larger. The reason is mini-batching: each pass over the data now performs 25 weight updates instead
of 1. Three: the model has 105,866 parameters, and nearly all of them sit in the
Linear(1568, 64) layer — 100,416 of them. The two convolutional layers account for only
4,800. That is the same comparison as section 7.3, now visible on the running model.
Four: the per-class accuracy table is the part to trust. A single overall figure of 100%
can hide the network abandoning one class entirely — getting 9 classes right and the tenth completely
wrong still reads as 90%. Split by class and nothing hides.
How to run this project on your own machine
- Install:
pip install torch numpy. No data download, no network needed. - Download
train_mnist_cnn.pyat the end of the lesson, or retype the code above. - Run it:
python3 train_mnist_cnn.py. It takes a few tens of seconds on a CPU. -
You will get exactly the numbers above, because the script fixes both random generators (
default_rng(42)for the data,torch.manual_seed(42)for the weights). -
Then try three ways of breaking it, each teaching something different:
-
Change
nn.Linear(32 * 7 * 7, 64)tonn.Linear(32 * 8 * 8, 64)— you get exactly themat1 and mat2 shapes cannot be multipliederror warned about in section 7.3. Meeting it once on purpose means recognising it instantly next time. -
Raise the noise: change
rng.normal(0, 0.12, ...)to0.5. The shapes start drowning in noise and the accuracy drops. -
Recreate the impossible task: change
make_shape_datasetto return random labels (y[i] = rng.integers(0, 10)). You will watch the loss fall while accuracy stays at 10% — the exact trap described at the start of this section, rebuilt with your own hands.
-
Change
Lesson summary & bridge to what's next
- Achieved: the two ideas that make a CNN — local connectivity and shared weights — together with the concrete number proving them: 4,800 parameters instead of 100,480.
-
Achieved: computing each layer's output shape, so you avoid
mat1 and mat2 shapes cannot be multipliedrather than guessing at it. -
Achieved: knowing
nn.Conv2dactually performs cross-correlation rather than convolution, and why that does not change how you write code. - Achieved: pooling, receptive fields, and how a CNN learns features hierarchically — from edges, to parts, to objects.
- Achieved: a diagnostic you will use for the rest of your career: a falling loss with accuracy stuck at chance level means the network is memorising, not learning.
- Achieved: reading per-class accuracy, because a single overall figure can hide the network abandoning a class entirely.
Bridge to the next lesson: with images handled, the next step moves into the natural world of language: how to turn words into feature vectors in semantic space — word embeddings, in Lesson 8.
Download the hands-on code for this lesson
The Python file train_mnist_cnn.py — the complete CNN training script with its own
generated dataset, per-class accuracy breakdown and no downloads at all (run
python train_mnist_cnn.py, needs torch and numpy):
Comments