All Modules Convolution Output Size Architecture Exercise

Convolutional Networks

The architecture that taught machines to see — convolutions, pooling, and how a handful of small filters replace millions of weights.

Module 8 · Lecture notes by Dr. Abdulkarim Albanna

Core Concept Computer Vision ~50 min

What You'll Learn

  • Why dense layers don't scale to images, and the three ideas CNNs exploit: local receptive fields, parameter sharing, and translation invariance
  • How the convolution operation slides a kernel over an input to build a feature map — computed by hand
  • How stride and padding control the output size, via the output-size formula
  • How filters span channels, and how to count a conv layer's parameters
  • What pooling does and why it needs no learnable weights
  • How CONV → ReLU → POOL blocks stack into a full architecture — with a shape trace and PyTorch code

Why Not Just Use Dense Layers?

Everything so far has used fully connected (dense) layers, where every input connects to every neuron. That works for small vectors. It falls apart for images.

Take a modest colour photo of \(224 \times 224\) pixels with 3 colour channels. Flattened, that is one input vector of length \(224 \times 224 \times 3 = 150{,}528\). Wire it into a single hidden layer of just 1000 neurons and you already need:

The parameter explosion

\[ 150{,}528 \times 1000 + 1000 \approx 1.5 \times 10^{8} \text{ weights} \]

Over 150 million parameters in one layer — before you have learned anything useful. Such a network overfits easily, is slow to train, and needs enormous data.

Worse, flattening throws away spatial structure: pixel \((0,0)\) and its right-hand neighbour end up as unrelated entries in a long vector, and a cat shifted three pixels to the right looks like a completely different input. CNNs fix all of this with three ideas:

Local receptive fields

A neuron looks at only a small patch of the image (say \(3 \times 3\)), not the whole thing — because meaningful visual features (edges, corners, textures) are local.

Parameter sharing

The same small set of weights (a filter) is reused at every location. One \(3 \times 3\) edge detector is useful everywhere in the image, so we learn it once and slide it across.

Translation invariance

Because the same filter is applied everywhere, a feature is detected no matter where it appears. A cat in the corner fires the same detectors as a cat in the centre.

The Convolution Operation

A convolution slides a small grid of weights — the kernel or filter — across the input. At each position it multiplies the overlapping numbers element-by-element and sums them into a single output value. Sweep over every position and the outputs form a feature map.

For a 2D input \(I\) and a \(k \times k\) kernel \(K\), the value at output position \((i,j)\) is:

\[ S(i,j) = \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} I(i+m,\; j+n)\, K(m,n) \]

(Deep learning libraries implement this cross-correlation form — no kernel flip — and call it convolution. We follow that convention.)

Worked example: a vertical-edge filter

Take a \(5 \times 5\) input that is bright on the left and dark on the right, and a \(3 \times 3\) vertical-edge kernel. We use valid convolution (no padding, stride 1), so the output is \(3 \times 3\).

Input \(I\) (5×5)

10101000
10101000
10101000
10101000
10101000

Kernel \(K\) (3×3)

10−1
10−1
10−1

Output cell \((0,0)\) — overlay the kernel on the top-left \(3\times3\) patch (all values 10):

\[ (10\cdot1 + 10\cdot0 + 10\cdot(-1)) \times 3 \text{ rows} = 0 \]

The patch is uniform, so the \(+1\) and \(-1\) columns cancel: no edge here.

Output cell \((0,1)\) — shift one column right; the patch now straddles the bright/dark boundary (columns \(10,10,0\)):

\[ (10\cdot1 + 10\cdot0 + 0\cdot(-1)) \times 3 \text{ rows} = 10 \times 3 = 30 \]

A large response — the filter has found the vertical edge. Sweeping over all nine positions (every row is identical) gives the feature map:

Feature map (3×3)

03030
03030
03030

What just happened

The output is near zero over flat regions and spikes where brightness changes horizontally. A single \(3\times3\) filter — nine numbers — became a reusable edge detector. Different kernels detect different things: box/averaging kernels blur, and Laplacian-style kernels sharpen. In a CNN we don't hand-pick these numbers — they are learned by backprop.

Stride, Padding & Output Size

Two knobs control how the kernel sweeps the input:

Stride \(S\)

How many pixels the kernel jumps each step. \(S=1\) visits every position; \(S=2\) skips every other one, roughly halving the output size (a cheap way to downsample).

Padding \(P\)

A border of zeros added around the input. Without it, every convolution shrinks the image and the edge pixels are under-sampled. “Valid” means \(P=0\) (output shrinks); “same” padding is chosen so the output matches the input size.

For an input of width \(W\), kernel \(K\), padding \(P\), and stride \(S\), the output width is:

\[ O = \left\lfloor \frac{W - K + 2P}{S} \right\rfloor + 1 \]

(The same formula applies to height. For square inputs and kernels, height and width come out equal.)

Worked example: reading the formula

Input \(W\)Kernel \(K\)Padding \(P\)Stride \(S\)Output \(O\)Note
32501(32−5+0)/1 + 1 = 28valid — shrinks
32521(32−5+4)/1 + 1 = 32“same” — size preserved
32502⌊27/2⌋ + 1 = 14stride 2 — downsampled
28311(28−3+2)/1 + 1 = 283×3 “same” (\(P=1\))

A rule worth memorising

A \(3 \times 3\) kernel with \(P=1\) and \(S=1\) always preserves the spatial size. That is why modern architectures (VGG, ResNet) stack many \(3\times3\), \(P=1\) convolutions and downsample only at pooling or strided layers.

Channels, Depth & Multiple Filters

Real inputs have depth: a colour image is \(3\) channels (R, G, B). A convolutional filter always spans the full depth of its input. So a “\(3\times3\) filter” on an RGB image is really a \(3 \times 3 \times 3\) block of weights — it slides in 2D (across height and width) but reaches through all channels at each stop, producing one 2D feature map.

To detect many features, a layer uses many filters. \(F\) filters produce \(F\) feature maps, stacked into an output of depth \(F\) — which becomes the input depth of the next layer.

Counting parameters

A conv layer with \(C_{\text{in}}\) input channels, \(F\) filters of size \(k \times k\), has:

\[ \text{params} = (C_{\text{in}} \cdot k \cdot k + 1) \cdot F \]

The \(+1\) is one bias per filter. For our first layer — 3 input channels, 32 filters, \(3\times3\):

\[ (3 \cdot 3 \cdot 3 + 1) \cdot 32 = 28 \cdot 32 = 896 \text{ parameters} \]

896 vs 150 million

The dense layer at the top of this page needed ~150,000,000 weights to touch a \(224\times224\times3\) image once. A conv layer with 32 filters sees the same image with 896 — and, thanks to parameter sharing, applies them at every location. That five-orders-of-magnitude saving is the whole point of a CNN.

A deeper layer with 32 input channels and 64 filters of \(3\times3\) has \((3\cdot3\cdot32 + 1)\cdot64 = 289 \cdot 64 = 18{,}496\) parameters — still tiny by dense-layer standards.

Pooling Layers

After a convolution we often downsample with a pooling layer: slide a small window (usually \(2\times2\), stride 2) over each feature map and replace it with a single summary value. This shrinks the maps, cuts computation, and adds a little robustness to small shifts.

Max pooling keeps the largest value in each window (the strongest activation); average pooling takes the mean. Max pooling is the common default.

Worked example: 2×2 max pooling

A \(4\times4\) feature map, pooled with a \(2\times2\) window at stride 2, yields a \(2\times2\) output — four non-overlapping windows:

Input (4×4)

1324
5612
7230
1248

Max-pooled (2×2)

64
78

Top-left window \(\{1,3,5,6\}\to 6\); top-right \(\{2,4,1,2\}\to 4\); bottom-left \(\{7,2,1,2\}\to 7\); bottom-right \(\{3,0,4,8\}\to 8\). (Average pooling the top-left window would instead give \((1+3+5+6)/4 = 3.75\).)

No weights to learn

Pooling has zero learnable parameters — it is a fixed operation. Many modern networks drop it in favour of strided convolutions (a conv with \(S=2\) downsamples and learns how), but max pooling remains simple, effective, and everywhere in classic architectures.

Putting It Together: A CNN Architecture

A convolutional network is a stack of the same building block — CONV → ReLU → POOL — repeated a few times. Early layers learn simple features (edges, colours); deeper layers combine them into textures, parts, and whole objects. After the convolutional stack, the maps are flattened and fed to one or two dense layers ending in a softmax classifier.

A shape trace: small CNN on MNIST (28×28 grayscale)

Every \(3\times3\) conv uses \(P=1\) (size-preserving); every pool is \(2\times2\), stride 2 (halves H and W). Follow the shape and parameter count layer by layer:

LayerOutput shape (C×H×W)Parameters
Input1 × 28 × 280
Conv 8 filters, 3×3, P=18 × 28 × 28(1·9+1)·8 = 80
ReLU8 × 28 × 280
MaxPool 2×28 × 14 × 140
Conv 16 filters, 3×3, P=116 × 14 × 14(8·9+1)·16 = 1168
ReLU16 × 14 × 140
MaxPool 2×216 × 7 × 70
Flatten7840
Linear 784 → 1010784·10 + 10 = 7850
Total10 logits9098

Just 9,098 parameters classify handwritten digits — and most of them live in the final dense layer, not the convolutions.

Landmark architectures

  • LeNet-5 (1998) — the original, for handwritten digits: the CONV–POOL–dense recipe above.
  • AlexNet (2012) — deeper, ReLU, dropout, GPU-trained; won ImageNet and started the deep-learning boom.
  • VGG (2014) — showed that stacking many small \(3\times3\) convs works beautifully.
  • ResNet (2015) — skip connections let gradients flow through 100+ layers, solving the degradation problem and enabling very deep nets.

In PyTorch

The shape trace above translates almost line-for-line into a nn.Module:

import torch import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 8, kernel_size=3, padding=1), # 1x28x28 -> 8x28x28 nn.ReLU(), nn.MaxPool2d(2), # -> 8x14x14 nn.Conv2d(8, 16, kernel_size=3, padding=1), # -> 16x14x14 nn.ReLU(), nn.MaxPool2d(2), # -> 16x7x7 ) self.classifier = nn.Sequential( nn.Flatten(), # -> 784 nn.Linear(16 * 7 * 7, num_classes), # 784 -> 10 ) def forward(self, x): x = self.features(x) return self.classifier(x) model = SmallCNN() x = torch.randn(1, 1, 28, 28) # (batch, channels, H, W) print(model(x).shape) # torch.Size([1, 10])

A quick sanity check that the layer really has the parameter count we traced by hand:

total = sum(p.numel() for p in model.parameters()) print(total) # 9098

Note the tensor layout

PyTorch expects images as (N, C, H, W) — batch, channels, height, width. Conv2d(in_channels, out_channels, kernel_size) mirrors our parameter formula exactly: out_channels is the number of filters \(F\), and each filter spans in_channels.

Practice in PyTorch: TorchCode

Implement a convolution, a max-pool, and this small CNN from scratch, then check them against PyTorch — instant feedback, reference solutions, no GPU needed.

Open TorchCode

Exercise: Test Your Understanding

Work each problem by hand before revealing the solution.

1

Output shape and parameter count

A conv layer receives a \(3 \times 64 \times 64\) input (3 channels) and applies 16 filters of size \(5\times5\) with padding \(P=2\), stride \(S=1\). Give the output shape \((C\times H\times W)\) and the number of parameters.

Output size: \(O = \lfloor (64 - 5 + 2\cdot2)/1 \rfloor + 1 = \lfloor 63 \rfloor + 1 = 64\). With 16 filters the output is \(16 \times 64 \times 64\) (a “same” convolution).

Parameters: \((C_{\text{in}}\cdot k\cdot k + 1)\cdot F = (3\cdot5\cdot5 + 1)\cdot 16 = 76 \cdot 16 = 1{,}216\).

2

Convolve by hand

Compute the full valid (\(P=0\), \(S=1\)) convolution of this \(4\times4\) input with the \(3\times3\) vertical-edge kernel \(\begin{smallmatrix}1&0&-1\\1&0&-1\\1&0&-1\end{smallmatrix}\). Then state the output shape if instead \(S=2\).

1201
0132
2101
1023

Valid output size: \(\lfloor(4-3)/1\rfloor + 1 = 2\), so a \(2\times2\) map. Each cell is (left column) − (right column), summed over the three rows:

\((0,0)\): \((1-0)+(0-3)+(2-0) = 1-3+2 = 0\)
\((0,1)\): \((2-1)+(1-2)+(1-1) = 1-1+0 = 0\)
\((1,0)\): \((0-2)+(2-0)+(1-2) = -2+2-1 = -2\)
\((1,1)\): \((1-2)+(1-1)+(0-3) = -1+0-3 = -4\)

Feature map: \(\begin{smallmatrix}0&0\\-2&-4\end{smallmatrix}\)

With \(S=2\): \(\lfloor(4-3)/2\rfloor + 1 = 1\), so a single \(1\times1\) output — just the top-left value, \(0\).

3

Trace shapes through a mini-stack

Starting from a \(1 \times 32 \times 32\) input, give the output shape after each layer, and the flattened length at the end:
(a) Conv 8 filters \(3\times3\), \(P=1\)  →  (b) MaxPool \(2\times2\)  →  (c) Conv 16 filters \(3\times3\), \(P=1\)  →  (d) MaxPool \(2\times2\)  →  (e) Flatten.

(a) \(3\times3\), \(P=1\) preserves size → \(8 \times 32 \times 32\)
(b) pool halves H, W → \(8 \times 16 \times 16\)
(c) size-preserving conv, 16 filters → \(16 \times 16 \times 16\)
(d) pool halves again → \(16 \times 8 \times 8\)
(e) flatten → \(16 \cdot 8 \cdot 8 = \) 1024 values.

(Parameter counts, for reference: conv (a) \((1\cdot9+1)\cdot8 = 80\); conv (c) \((8\cdot9+1)\cdot16 = 1168\); pooling adds none.)

Recap & What's Next

You now understand

Why dense layers can't scale to images, and how CNNs win with local receptive fields, parameter sharing, and translation invariance. You can compute a convolution by hand, use the output-size formula \(O = \lfloor (W-K+2P)/S \rfloor + 1\), count a conv layer's parameters with \((C_{\text{in}} k^2 + 1)F\), apply max pooling, and trace shapes through a full CONV→ReLU→POOL→dense architecture — in maths and in PyTorch.

Next up: Module 9 — Sequence Models. Images are grids; language and time series are sequences. We'll turn to RNNs, LSTMs, and the attention mechanism that powers modern models.

Convolutional Networks

Objectives Why Not Dense? Convolution Stride & Padding Channels & Params Pooling Architecture In PyTorch Exercise Recap