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.

✅ What you need before starting
Libraries: 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.
🧠 How 2D convolution works
In discrete mathematics, the convolution of an input image $I$ with a $k \times k$ filter (kernel) $K$ at coordinate $(i, j)$ is defined as: $$S(i, j) = (I * K)(i, j) = \sum_{m} \sum_{n} I(i-m, j-n) K(m, n)$$ More intuitively: we lay the filter $K$ (a small weight matrix, say $3 \times 3$) over a corner of the input image $I$, multiply each overlapping pair of values, and sum them into a single number written into the result matrix — called the feature map.

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$$
🔎 The detail most material skips: PyTorch does not do "convolution"
The formula $\sum_m \sum_n I(i-m, j-n) K(m,n)$ above is convolution in the strict mathematical sense, and notice the minus signs: they flip the kernel 180° before multiplying.

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.
📐 The output shape formula
Given an input of height $H_{\text{in}}$ (or width $W_{\text{in}}$), kernel size $K$, padding $P$ and stride $S$, the output height (or width) $H_{\text{out}}$ of a convolutional layer is: $$H_{\text{out}} = \left\lfloor \frac{H_{\text{in}} - K + 2P}{S} \right\rfloor + 1$$ where $\lfloor \dots \rfloor$ is the floor function.

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.
⚠️ Pitfall: choosing too large a kernel
A common design mistake is picking a very large filter ($11 \times 11$ or $7 \times 7$) in the deeper layers of a network. It inflates the parameter count needlessly and blurs the fine detail of the image. Modern design philosophy (VGG, ResNet) encourages stacking several small $3 \times 3$ convolutions in a row instead of one large filter, giving a deeper network with better non-linearity and fewer parameters.

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).
🧠 How the receptive field grows hierarchically
A $2 \times 2$ max-pooling layer with stride $S=2$ halves the height and width of the feature map, cutting the computation needed by later layers.

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:

  1. Feature extractor: stacked blocks of Conv2d $\to$ ReLU $\to$ MaxPool2d. This part learns the spatial features of the image.
  2. Linear classifier: after extraction we flatten the 3D feature matrix into a 1D vector and pass it through fully connected Linear layers with activations, to predict the class label.
⚠️ Pitfall: the shape mismatch when handing over to the Linear layer
This is the single most frustrating error for engineers new to AI. As you write 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:

cnn_architecture.py
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:

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

⚠️ The data here is synthetic, and here is exactly why you need to know
Real MNIST needs a ~10 MB download plus 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
# 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):

Terminal
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

  1. Install: pip install torch numpy. No data download, no network needed.
  2. Download train_mnist_cnn.py at the end of the lesson, or retype the code above.
  3. Run it: python3 train_mnist_cnn.py. It takes a few tens of seconds on a CPU.
  4. 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).
  5. Then try three ways of breaking it, each teaching something different:
    • Change nn.Linear(32 * 7 * 7, 64) to nn.Linear(32 * 8 * 8, 64) — you get exactly the mat1 and mat2 shapes cannot be multiplied error 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, ...) to 0.5. The shapes start drowning in noise and the accuracy drops.
    • Recreate the impossible task: change make_shape_dataset to 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.

Lesson summary & bridge to what's next

🔑 What you now have:
  • 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 multiplied rather than guessing at it.
  • Achieved: knowing nn.Conv2d actually 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):

Download train_mnist_cnn.py

📖 Further reading

Related lessons in this series

Lesson 6: Training a network & Backpropagation Lesson 8: Text processing & word embeddings Back to the Practical AI Engineer roadmap

Comments