Index Ai Academy
Phase 4 · Deep learning · Architectures

Convolutional networks

Pattern detectors for images.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

To a computer, a photo is a grid of numbers, one per pixel. A plain network (an MLP) treats that grid as a long, shapeless list. It misses two facts. Pixels next to each other belong together. And a cat is still a cat wherever it sits in the frame. Convolutional networks (CNNs) build both facts in. That's why they drove the big leap in computer vision in 2012. They still run inside many image systems. Their core ideas, a small sliding window and shared weights, also show up in audio models and in parts of multimodal LLMs, the models that read images as well as text.

What you'll be able to do

  • Explain convolution: a small filter of shared weights sliding over an image
  • Compute output sizes from kernel size, stride and padding
  • Build a convolution from patches and a matrix multiply, and train a small CNN
  • Compare a CNN's parameter efficiency with a fully connected network, and know where CNNs are used today

What it covers

  1. A small window sliding over a picture
  2. Worked example: finding an edge by hand
  3. Hand-made filters on a real digit
  4. Why this beats a fully connected layer for images
  5. How big is the output?
  6. Channels and pooling
  7. A small CNN, built from patches
  8. The real thing in PyTorch

A small window sliding over a picture

A grayscale picture is a grid of numbers. Each number is one pixel's brightness. In this lesson's digit pictures, 0 is black and 16 is the brightest white.

Now picture reading a page through a small square window, like a magnifying glass. You slide it across, one step at a time. At every spot, you ask the same question: "Is there an edge here?"

A convolution does exactly that with numbers. The window is a small grid of weights, often 3 × 3. It's called a filter, or a kernel. At each spot, the filter does two things:

  1. It multiplies each of its 9 weights by the pixel underneath.
  2. It adds the 9 results into one number.

That number is a score. A big score means "my pattern is strongly here". A score near 0 means "nothing here". Slide the filter over every spot and you get a new, smaller grid of scores. That grid is called a feature map. It shows where in the picture the pattern appears.

In plain words: a convolution slides a small grid of weights over a picture. At each spot it multiplies and adds, giving one score. The scores form a map of where the pattern is.

Quick check
A filter is laid over a patch of pixels. What does this print?
import numpy as np
patch = np.array([[1, 2], [3, 4]])
kernel = np.array([[1, 0], [0, 1]])
print(np.sum(patch * kernel))
  1. 1
  2. 4
  3. 5
  4. 10
Show the answer

5

Multiply each pixel by the weight on top of it: 1 × 1, 2 × 0, 3 × 0 and 4 × 1. Then add: 1 + 0 + 0 + 4 = 5.

Worked example

Worked example: finding an edge by hand

Here's a tiny 4 × 4 picture. The first three columns are black (0). The last column is bright (10). So there's an edge between columns 3 and 4.

The filter has 1s in its left column, 0s in the middle and −1s on the right. It compares the left side of the window with the right side. Slide it over every spot and work out the scores.

import numpy as np

image = np.array([[0, 0, 0, 10],
                  [0, 0, 0, 10],
                  [0, 0, 0, 10],
                  [0, 0, 0, 10]])
kernel = np.array([[1, 0, -1],
                   [1, 0, -1],
                   [1, 0, -1]])

out = np.zeros((2, 2), dtype=int)
for r in range(2):
    for c in range(2):
        patch = image[r:r + 3, c:c + 3]       # the 3 × 3 pixels under the filter
        out[r, c] = np.sum(patch * kernel)    # multiply, then add
print(out)
It runs right here in your browser, with nothing to install.

A 3 × 3 window fits on a 4 × 4 picture in only 2 × 2 spots. So the output is 2 × 2.

Put the window at the top left. It covers columns 1 to 3, which are all 0. Every product is 0, so the score is 0. There's no edge there.

Move it one step right. Now it covers columns 2 to 4. In each row, the pixels are 0, 0 and 10. Times the weights 1, 0 and −1, that's 0 + 0 − 10 = −10. Three rows give −30. A strong score: the edge is here.

The bottom row of spots works out the same way, so the output holds 0 and −30. The minus sign only tells you which side was brighter. Flip the picture left to right and you'd get +30.

Hand-made filters on a real digit

Now try two filters on a real 8 × 8 picture of a handwritten 0. One looks for vertical edges. The other looks for horizontal edges. The convolve function is the loop from the worked example, written for any picture and any square filter.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits

image = load_digits().images[0]                 # an 8 × 8 picture of a 0

def convolve(img, kernel):
    k = kernel.shape[0]
    out = np.zeros((img.shape[0] - k + 1, img.shape[1] - k + 1))
    for r in range(out.shape[0]):
        for c in range(out.shape[1]):
            out[r, c] = np.sum(img[r:r + k, c:c + k] * kernel)
    return out

filters = {"vertical edges": np.array([[1, 0, -1], [1, 0, -1], [1, 0, -1]]),
           "horizontal edges": np.array([[1, 1, 1], [0, 0, 0], [-1, -1, -1]])}
fig, axes = plt.subplots(1, 3, figsize=(9, 3))
axes[0].imshow(image, cmap="gray"); axes[0].set_title("input 8 × 8")
for ax, (name, k) in zip(axes[1:], filters.items()):
    fm = convolve(image, k)
    ax.imshow(fm, cmap="coolwarm"); ax.set_title(f"{name} {fm.shape[0]} × {fm.shape[1]}")
    print(f"{name}: output shape {fm.shape}, strongest response {np.abs(fm).max():.0f}")
for ax in axes:
    ax.axis("off")

Each feature map is 6 × 6, because a 3 × 3 filter fits in 6 × 6 spots on an 8 × 8 picture. The vertical-edge map lights up along the left and right sides of the 0. The horizontal-edge map lights up along the top and bottom. Scores can be positive or negative. The sign tells you which side of the edge was brighter. In the plots, red marks high scores and blue marks low ones.

Here's the big step. A CNN doesn't use hand-made filters. It starts with random weights and learns its filters with backpropagation, the same way an MLP learns its weights. Each layer learns many filters, each one hunting for a different pattern.

Go deeper The convolution as a formula

Call the filter KK. Its size is kk: it has kk rows and kk columns. Let ii count rows inside the filter, from 0. Let jj count its columns, also from 0. An output spot sits in row rr. It sits in column cc. Its score is:

out[r,c]=∑i=0k−1∑j=0k−1img[r+i, c+j]×K[i,j]\text{out}[r, c] = \sum_{i=0}^{k-1} \sum_{j=0}^{k-1} \text{img}[r + i,\ c + j] \times K[i, j]

Read it as: for every cell of the filter, take the pixel under it, multiply the two, and add up all the products. The two ∑\sum signs (sigma, "sum") are two nested for loops.

The worked example above is this formula with k=3k = 3. In Python it's one line of convolve: np.sum(img[r:r + k, c:c + k] * kernel).

Strictly, mathematicians call this cross-correlation. A true convolution flips the filter first. Deep-learning libraries skip the flip. Since the filter is learned anyway, it makes no difference.

Quick check
Where do a CNN's filters come from?
  1. It learns them from data, starting from random weights
  2. They're copied from the photos
  3. Engineers draw them by hand
  4. They're fixed by the camera
Show the answer

It learns them from data, starting from random weights

Earlier in this lesson you set filters by hand to see what they do. Real networks learn theirs by gradient descent.

Why this beats a fully connected layer for images

Think of a "Where's Waldo?" puzzle. You don't learn a separate Waldo for each corner of the page. You learn one picture of Waldo and scan for it everywhere. CNNs work the same way, for three reasons.

  • Shared weights. The same 9 weights are used at every spot. So a filter that finds an edge finds it anywhere in the picture. A 3 × 3 filter has 9 weights, whatever the picture size.
  • Local connections. Each score looks only at a small neighborhood. That matches how pictures are built: a stroke or an edge is made of pixels that touch.
  • Layers build up. Stack convolution layers and later filters see larger areas. The first layer finds edges. Later layers find textures and parts. The last layers find whole objects.

Compare a fully connected layer. Every pixel gets its own weight for every unit. A 224 × 224 photo has 50,176 pixels. Connect it to just 100 units and you need 50,176 × 100, about 5 million weights. And a pattern learned in one corner teaches the layer nothing about other corners.

Quick check
A 3 × 3 filter slides over a 100 × 100 picture. Then the same kind of filter slides over a 1,000 × 1,000 picture. How many weights does the filter have each time?
  1. 9, then 90
  2. It depends on the stride
  3. 10,000, then 1,000,000
  4. 9 both times
Show the answer

9 both times

The filter is shared: the same 9 weights are reused at every spot, so the picture size doesn't change it.

How big is the output?

Four settings decide the size of a feature map. Here they are, one at a time:

  • nn is the input size. An 8 × 8 picture has n=8n = 8.
  • kk is the kernel size. A 3 × 3 filter has k=3k = 3.
  • ss is the stride: how many pixels the filter moves each step. Stride 1 visits every spot. Stride 2 skips every other one.
  • pp is the padding: how many rings of zeros you add around the border first. Padding lets the filter sit on edge pixels, so the output shrinks less, or not at all.

The output size is:

out=⌊n+2p−ks⌋+1\text{out} = \left\lfloor \frac{n + 2p - k}{s} \right\rfloor + 1

The ⌊ ⌋\lfloor\ \rfloor brackets mean "round down to a whole number".

In plain words: add the padding to both sides of the picture. Take away the filter size. That leaves the room the filter can travel. Divide by the step size, round down, and add 1 for the starting spot.

Try it with the digit above: n=8n = 8, k=3k = 3, s=1s = 1, p=0p = 0. That's 8 + 0 − 3 = 5. Then 5 ÷ 1 = 5, and 5 + 1 = 6. So the output is 6 × 6, as you saw.

Now add one ring of padding (p=1p = 1): 8 + 2 − 3 = 7, and 7 + 1 = 8. The output stays 8 × 8.

The same calculation in Python, where // divides and rounds down:

def out_size(n, k, s=1, p=0):
    return (n + 2 * p - k) // s + 1

print(out_size(8, 3))          # the digit example
print(out_size(8, 3, p=1))     # one ring of padding
print(out_size(8, 2, s=2))     # 2 × 2 filter, stride 2

It prints 6, 8 and 4. The last line shows that stride 2 halves the size: (8 − 2) ÷ 2 + 1 = 4.

Try it now

The formula from this section, live. Compare the last two lines to see why sharing a small filter is so cheap.

Quick check
A 32 × 32 image goes through a 5 × 5 convolution with stride 1 and no padding. What's the output size?
  1. 28 × 28
  2. 27 × 27
  3. 32 × 32
  4. 6 × 6
Show the answer

28 × 28

(32 − 5) / 1 + 1 = 28.

Channels and pooling

A layer usually has many filters, not one. Each filter makes its own feature map. The maps stack on top of each other like pages in a book. Each page is called a channel.

Pictures have channels too. A color photo starts with 3: one grid each for red, green and blue. A filter always reaches through every input channel. So on a color photo, one 3 × 3 filter has 3 × 3 × 3 = 27 weights, one 3 × 3 grid per color. It still gives one score per spot.

Pooling shrinks a feature map. The usual kind, max pooling, splits the map into 2 × 2 blocks and keeps only the biggest number in each block. It's like keeping each region's headline: "an edge showed up somewhere here". For example, the block below keeps 3.

1  3
2  0     →  3

Pooling halves the width and the height. That makes later layers cheaper. It also lets them see a wider area of the picture.

Quick check
A 6 × 6 feature map goes through 2 × 2 max pooling. What size comes out, and what does each number hold?
  1. 3 × 3, each the average of one 2 × 2 block
  2. 12 × 12, each block copied twice
  3. 3 × 3, each the largest value in one 2 × 2 block
  4. 5 × 5, each the largest value under a sliding 2 × 2 window
Show the answer

3 × 3, each the largest value in one 2 × 2 block

Max pooling splits the map into 2 × 2 blocks. Six divided into blocks of 2 gives 3 per side, and each block keeps its biggest number.

A small CNN, built from patches

Here's a useful fact. A convolution is a matrix multiply in disguise.

Cut out every 3 × 3 patch of the picture. Flatten each one into a row of 9 numbers. One filter is also 9 numbers. Its score on one patch is "multiply and add", which is a dot product. So put all the patch rows in one table and all the filters in another. One matrix multiply then applies every filter to every patch at once.

This trick is called im2col, short for "image to columns". (Some libraries lay the patches out as columns rather than rows. It's the same idea.) Many libraries run convolutions this way. It also lets this course's in-browser PyTorch, which has no built-in convolution layer (Conv2d), train a real CNN.

The code below trains two models on the 8 × 8 digits and compares them:

  • TinyCNN. It has 8 learned 3 × 3 filters, then ReLU, then 2 × 2 max pooling. A final layer turns the pooled maps into 10 scores, one per digit.
  • An MLP. It flattens the 64 pixels and uses one hidden layer of 64 units.
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

digits = load_digits()
X = torch.tensor(digits.images / 16.0, dtype=torch.float32)   # (1797, 8, 8)
y = torch.tensor(digits.target)
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=0)

# Row and column of every pixel in every 3 × 3 patch: 36 positions × 9 pixels
pos = [(r, c) for r in range(6) for c in range(6)]
ROWS = np.array([[r + i for i in range(3) for j in range(3)] for r, c in pos])
COLS = np.array([[c + j for i in range(3) for j in range(3)] for r, c in pos])

class TinyCNN(nn.Module):
    def __init__(self, filters=8):
        super().__init__()
        self.filters = filters
        self.kernel = nn.Linear(9, filters)               # one row of 9 weights per filter, plus a bias
        self.head = nn.Linear(filters * 3 * 3, 10)

    def forward(self, images):                            # images: (batch, 8, 8)
        patches = images[:, ROWS, COLS]                    # (batch, 36, 9)
        maps = torch.relu(self.kernel(patches))           # (batch, 36, filters): every filter at every position
        maps = maps.view(-1, 6, 6, self.filters)
        pooled = maps.view(-1, 3, 2, 3, 2, self.filters).max(dim=4).values.max(dim=2).values   # 2 × 2 max pooling
        return self.head(pooled.flatten(1))               # (batch, 10)

def train(model, epochs=12):
    opt = torch.optim.Adam(model.parameters(), lr=0.01)
    for epoch in range(epochs):
        for xb, yb in DataLoader(TensorDataset(X_train, y_train), batch_size=64, shuffle=True):
            opt.zero_grad()
            nn.functional.cross_entropy(model(xb), yb).backward()
            opt.step()
    with torch.no_grad():
        return (model(X_val).argmax(1) == y_val).float().mean().item()

torch.manual_seed(0)
cnn = TinyCNN()
mlp = nn.Sequential(nn.Flatten(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 10))
for name, model in [("CNN", cnn), ("MLP", mlp)]:
    params = sum(p.numel() for p in model.parameters())
    print(f"{name}: {params:,} parameters, validation accuracy {train(model):.3f}")

The CNN has 810 parameters. The MLP has 4,810, about six times as many. Yet the CNN gets close to the MLP's accuracy on pictures it never trained on.

On tiny 8 × 8 digits, a small MLP still holds its own (it's slightly better here). Real photos are hundreds of pixels per side. There, fully connected layers grow far too large, and CNNs win clearly. That's why computer vision used them almost exclusively for a decade.

Go deeper How TinyCNN works, line by line
  • ROWS and COLS list, for each of the 36 filter spots, the row and column of its 9 pixels. They're worked out once, before training.
  • images[:, ROWS, COLS] uses them to cut out every patch at once. The result holds 36 patches of 9 numbers for each picture.
  • nn.Linear(9, filters) holds the filters: 9 weights and a bias for each one. Applied to every patch, it gives every filter's score at every spot. ReLU then sets negative scores to 0.
  • The scores are reshaped into a 6 × 6 grid per filter. The long max line is 2 × 2 max pooling, which leaves 3 × 3 per filter.
  • 8 filters × 3 × 3 = 72 numbers go into self.head, which outputs 10 digit scores.

Where 810 comes from: the filters have 8 × 9 weights plus 8 biases, which is 80. The head has 72 × 10 weights plus 10 biases, which is 730. Together that's 810.

Quick check
8 filters of 9 weights each, plus one bias per filter. How many numbers is that?
print(8 * 9 + 8)
  1. 17
  2. 648
  3. 80
  4. 72
Show the answer

80

8 × 9 is 72 weights, and 8 biases make it 80. Those few numbers scan every position in the image, which is why CNNs need so few weights.

The real thing in PyTorch

Full PyTorch has a ready-made convolution layer, nn.Conv2d, and a pooling layer, nn.MaxPool2d. Here's a typical small CNN for 28 × 28 grayscale pictures:

import torch.nn as nn

model = nn.Sequential(
    nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, padding=1),   # (B, 1, 28, 28) → (B, 16, 28, 28)
    nn.ReLU(),
    nn.MaxPool2d(2),                                                        # → (B, 16, 14, 14)
    nn.Conv2d(16, 32, kernel_size=3, padding=1),                           # → (B, 32, 14, 14)
    nn.ReLU(),
    nn.MaxPool2d(2),                                                        # → (B, 32, 7, 7)
    nn.Flatten(),
    nn.Linear(32 * 7 * 7, 10),
)

The comments show the shape after each layer. B is the batch size, the number of pictures handled together. Read the shapes as batch, channels, height, width.

  • in_channels=1: grayscale input has one channel.
  • out_channels=16: the layer learns 16 filters, so it makes 16 feature maps.
  • padding=1 keeps the size at 28. Check it with the formula: 28 + 2 − 3 = 27, and 27 + 1 = 28.
  • Each MaxPool2d(2) halves the size: 28 to 14, then 14 to 7.
  • The final layer reads all 32 × 7 × 7 = 1,568 numbers and gives 10 class scores.

Where CNNs stand now

Newer models called vision transformers cut a picture into patches and use attention, which you'll meet in Phase 5. At large scale they now match or beat CNNs. Most multimodal LLMs use them to read images.

CNNs are still common where speed and efficiency matter: phones, cameras and real-time video. They also appear in hybrid models that mix both designs. And the idea of a small, shared, sliding filter lives on in audio models and some sequence models.

Quick check
A team needs to spot faces in live video on a cheap phone. Which statement fits what you've learned?
  1. CNNs are still a common choice there, because they are small and fast
  2. A fully connected network is best, because it gives every pixel its own weight
  3. Convolutions only work on 8 × 8 pictures
  4. CNNs are obsolete, so only a vision transformer will work
Show the answer

CNNs are still a common choice there, because they are small and fast

Vision transformers lead at large scale, but CNNs remain common where efficiency matters, such as phones, cameras and real-time video.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What does weight sharing in a convolutional layer mean?

  1. The same filter weights are used at every position in the image
  2. All filters have the same weights
  3. Weights are shared between the training and test sets
  4. Every layer uses the same filters

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson