Theory

One filter, step by step

There is no code here. Here is what a convolution does — on a picture you can check by eye.

Take a tiny 6 × 6 "picture": a bright 3 × 3 square on a dark background.

0 0 0 0 0 0
0 9 9 9 0 0
0 9 9 9 0 0
0 9 9 9 0 0
0 0 0 0 0 0
0 0 0 0 0 0

And one 3 × 3 filter (also called a kernel). It responds where brightness grows from left to right, i.e. to vertical edges:

-1  0  1
-2  0  2
-1  0  1

One number

Lay the filter on the top left corner, position (0, 0). Under it lies a piece of the picture — a patch:

patch          filter           products
0 0 0          -1  0  1          0  0  0
0 9 9    ×     -2  0  2    =     0  0 18
0 9 9          -1  0  1          0  0  9

sum = 0 + 0 + 0 + 0 + 0 + 18 + 0 + 0 + 9 = 27

You multiply cell by cell (not a matrix product) and add everything into one number. That number is cell (0, 0) of the feature map.

Now slide the filter one column right and repeat. Then one row down. From a 6 × 6 picture and a 3 × 3 filter you get a 4 × 4 map (6 − 3 + 1 = 4):

 27    0  -27  -27
 36    0  -36  -36
 27    0  -27  -27
  9    0   -9   -9

The square's left edge (dark → bright) gave positive numbers, its right edge (bright → dark) gave negative ones. Inside the square and in the empty background the filter sees nothing — zeros.

ReLU and pooling

After a convolution the network applies ReLU (negatives → 0) and then max pooling: take 2 × 2 blocks and keep the largest number from each.

after ReLU               after 2 × 2 max pooling
 27  0  0  0                36  0
 36  0  0  0        →       27  0
 27  0  0  0
  9  0  0  0

4 × 4 became 2 × 2. We found that something is on the left edge — and the exact position became a little vaguer. Pooling does that on purpose.

The same on a real picture

The same filter on the first FashionMNIST picture (an ankle boot). The position of the strongest response is shown:

feature map shape: (1, 1, 26, 26)   (28 - 3 + 1 = 26)
at (14, 25): by hand -3.8471  conv2d -3.8471
after 2x2 max pool: (1, 1, 13, 13)

The number computed "by hand" (patch × filter, sum) and the number from conv2d agree to four decimals. conv2d does nothing magic — it does what you just saw, only fast.

What changes in a real network

The real CNN has not one filter but 16, then another 32. Each filter produces its own map — a channel. This is how the tensor's shape changes as two pictures go through the network:

Conv2d     -> (2, 16, 28, 28)
ReLU       -> (2, 16, 28, 28)
MaxPool2d  -> (2, 16, 14, 14)
Conv2d     -> (2, 32, 14, 14)
ReLU       -> (2, 32, 14, 14)
MaxPool2d  -> (2, 32, 7, 7)
Flatten    -> (2, 1568)
Linear     -> (2, 10)

(The first number is how many pictures are in the batch, here 2.)

Here padding=1 keeps 28 × 28 as 28 — without it you would get 26, as above. Each pooling halves width and height: 28 → 14 → 7.

Three observations

1. The same 9 numbers are used at every position. That is why nn.Conv2d(1, 16, 3) has 16 × (9 + 1) = 160 parameters — 16 filters of 9 weights and one bias each. A dense layer giving the same 16 × 26 × 26 output has 8,490,560. The whole CNN has 20,490; the MLP from lesson 6 has 101,770.

2. Pooling throws information away. 28 × 28 → 7 × 7 in two steps. That saves parameters and computation, but it is also a deliberate choice: "where exactly" matters less than "was it there at all".

3. Channels grow, area shrinks. 1 × 28 × 28 → 16 × 14 × 14 → 32 × 7 × 7. More and more different patterns, on an ever coarser grid of positions. The final dense layer sees 1,568 numbers and decides from them which class it is.

What next

In the next step you write the same convolution as a Python function — with the loops given and two holes. You already know that 6 × 6 picture and that 27: the test checks exactly those.