February 17, 2026
Last time: how to train deep networks — gradient flow, batch normalization, regularization.
Today: how to encode domain knowledge about images directly into the network architecture.
The central idea: architecture is a prior. When the prior matches the data generating process, generalization follows.
From L10, we have a complete toolkit for training deep networks:
We applied all of this to CIFAR-10 (32×32 color images, 10 classes) using a 20-layer fully connected MLP and reached ~55% test accuracy.
That’s better than random (10%), but humans get ~94% on CIFAR-10.
Where’s the gap?
To feed an image into a fully connected network, we flatten it to a vector.
A 32×32×3 color image becomes a 3,072-dimensional vector.
Pixel (0,0) and pixel (31,31) are now equally “close” in the vector. Every spatial relationship — edges, shapes, neighborhoods — is destroyed.
In a fully connected layer, every hidden unit connects to every pixel.
We are giving our network the ability (really the directive) to learn mappings of all pixels to every hidden unit!
The first FC layer of our L10 MLP: 3,072 inputs × 256 hidden units = 786,432 parameters.
Most of these connections link spatially distant pixels with no meaningful relationship. We’re estimating hundreds of thousands of parameters for relationships that don’t exist.
And it gets worse with resolution. For a 224×224 color image (standard for modern vision):
\[150{,}528 \times 256 = \textbf{38.5 million parameters}\]
in the first layer alone. For a dataset with a few thousand images, this is hopeless.
In regression, we choose priors that reflect our beliefs about the data:
| Prior | Belief | Method |
|---|---|---|
| Uniform | “I have no beliefs about which coefficients matter” | OLS |
| L2 (Gaussian) | “I believe most coefficients are small” | Ridge |
| L1 (Laplace) | “I believe most coefficients are zero” | LASSO |
A fully connected layer is a uniform prior over spatial structure: any pixel can influence any hidden unit, with no preference for local relationships.
For images, we have strong prior knowledge before seeing any data.
Can we encode it directly into the network architecture?
Before seeing a single training image, we believe:
1. Locality
Useful visual features — edges, textures, corners — are computed from small spatial neighborhoods, not the entire image.
Before seeing a single training image, we believe:
2. Translation Equivariance
The same feature can appear anywhere. A vertical edge in the top-left is the same feature as a vertical edge in the bottom-right.
Before seeing a single training image, we believe:
3. Hierarchy / Compositionality
Complex features are built from simpler features. Edges → textures → parts → objects.
These aren’t engineering convenience. They follow from how the physical world produces images: light interacts locally, objects move, and parts compose into wholes.
Each belief maps to a specific architectural constraint:
| Belief | Constraint | Effect |
|---|---|---|
| Locality | Local receptive fields | Each unit sees a small patch, not all pixels |
| Translation equivariance | Weight sharing | Same filter applied at every position |
| Hierarchy | Stacking layers | Each layer builds on the previous one |
A convolutional layer encodes all three.
But before we build one, let’s be precise about what “translation equivariance” actually means. This is the theoretical heart of why CNNs work.
Let \(T\) be a transformation (e.g., shifting an image 5 pixels to the right).
Equivariance: A function \(f\) is equivariant to \(T\) if:
\[f(T(\mathbf x)) = T(f(\mathbf x))\]
Transforming the input, then applying \(f\), gives the same result as applying \(f\), then transforming the output.
Example: If we detect a cat in an image then shift the cat image 5 pixels right, then we should still detect the cat using the exact same set of rules
Invariance: A function \(f\) is invariant to \(T\) if:
\[f(T(\mathbf x)) = f(\mathbf x)\]
The output doesn’t change at all when the input is transformed.
But we don’t want invariance everywhere — early layers should track where features are (equivariance), and only the final classification should discard location (invariance).
The CNN pipeline:
\[\underbrace{\text{Conv layers}}_{\text{equivariant}} \to \underbrace{\text{Pooling}}_{\text{adds local invariance}} \to \underbrace{\text{Global pool + FC}}_{\text{fully invariant}}\]
The network gradually trades spatial precision for semantic abstraction.
Equivariance is the precise mathematical statement of the inductive bias/prior that CNNs provide:
It’s not just “CNNs are good at images.” It’s: “CNNs encode a specific symmetry — translation — that natural images possess.”
When the symmetry matches the data generating process, generalization follows. This is the same logic as encoding an L2 prior in Ridge regression — but structural rather than parametric.
Conv layers are equivariant to translation but NOT to rotation or scale.
A rotated cat produces a different representation in the hidden space, not a rotated representation.
The network must learn rotation/scale invariance from data — or we provide it via data augmentation (next lecture).
Now let’s build the layer. Recall from L9: each module has a forward pass that computes an output from an input.
In a fully connected layer, the forward pass is:
\[z_k = \mathbf w_k^\top \mathbf x + b_k\]
An inner product with the entire input. This is where the “uniform prior” comes from.
In a convolutional layer, the forward pass is:
\[z_{i,j} = \sum_{m=0}^{k-1} \sum_{n=0}^{k-1} W_{m,n} \cdot x_{i+m,\, j+n} + b\]
An inner product with a small local patch of the input. Same operation, just restricted to a \(k \times k\) neighborhood.
Before 2D, let’s see this in 1D:
\[[1,2,3,4,5,6] \circledast [1,2] = [5,8,11,14,17]\]
Before 2D, let’s see this in 1D:
\[[\boxed{\color{red}1,2},3,4,5,6] \circledast [\boxed{\color{red}1,2}] = [\boxed{\color{red}5}, 8, 11,14,17]\] \[(1 \times 1) + (2 \times 2) = 5\]
Before 2D, let’s see this in 1D:
\[[1,\boxed{\color{red}2,3},4,5,6] \circledast [\boxed{\color{red}1,2}] = [5, \boxed{\color{red}8}, 11,14,17]\] \[(2 \times 1) + (3 \times 2) = 8\]
Before 2D, let’s see this in 1D:
\[[1,2,\boxed{\color{red}3,4},5,6] \circledast [\boxed{\color{red}1,2}] = [5, 8, \boxed{\color{red}11},14,17]\] \[(3 \times 1) + (4 \times 2) = 11\]
The filter slides across the input. Same weights at every position — that’s weight sharing.
Now in 2D: a \(3 \times 3\) image with a \(2 \times 2\) filter.
\[ \begin{bmatrix} \boxed{a_{11}} & \boxed{a_{12}} & a_{13} \\ \boxed{a_{21}} & \boxed{a_{22}} & a_{23} \\ a_{31} & a_{32} & a_{33} \end{bmatrix} \circledast \begin{bmatrix} w_{11} & w_{12} \\ w_{21} & w_{22} \end{bmatrix} \]
\[c_{11} = a_{11}w_{11} + a_{12}w_{12} + a_{21}w_{21} + a_{22}w_{22}\]
\[ \begin{bmatrix} a_{11} & \boxed{a_{12}} & \boxed{a_{13}} \\ a_{21} & \boxed{a_{22}} & \boxed{a_{23}} \\ a_{31} & a_{32} & a_{33} \end{bmatrix} \circledast \begin{bmatrix} w_{11} & w_{12} \\ w_{21} & w_{22} \end{bmatrix} \]
\[c_{12} = a_{12}w_{11} + a_{13}w_{12} + a_{22}w_{21} + a_{23}w_{22}\]
\[ \begin{bmatrix} a_{11} & a_{12} & a_{13} \\ \boxed{a_{21}} & \boxed{a_{22}} & a_{23} \\ \boxed{a_{31}} & \boxed{a_{32}} & a_{33} \end{bmatrix} \circledast \begin{bmatrix} w_{11} & w_{12} \\ w_{21} & w_{22} \end{bmatrix} \]
\[c_{21} = a_{21}w_{11} + a_{22}w_{12} + a_{31}w_{21} + a_{32}w_{22}\]
\[ \begin{bmatrix} a_{11} & a_{12} & a_{13} \\ a_{21} & \boxed{a_{22}} & \boxed{a_{23}} \\ a_{31} & \boxed{a_{32}} & \boxed{a_{33}} \end{bmatrix} \circledast \begin{bmatrix} w_{11} & w_{12} \\ w_{21} & w_{22} \end{bmatrix} \]
\[c_{22} = a_{22}w_{11} + a_{23}w_{12} + a_{32}w_{21} + a_{33}w_{22}\]
The filter slides over every position. Each output value is a local inner product — template matching at that location.
The inner product \(\mathbf w^\top \mathbf x\) measures similarity between a weight vector and an input.
For zero-centered values, this is the covariance — high when \(\mathbf w\) and \(\mathbf x\) point in the same direction.
A convolutional filter is a template for a small visual pattern:
After ReLU: only positive similarity survives. The output tells us where the pattern is present.
The vertical edge filter responds strongly along the vertical edge (bright) and is silent elsewhere. The horizontal edge filter responds along the horizontal edge. Each filter detects one local pattern everywhere in the image.
The same filter \(\mathbf W\) slides over every spatial position.
This is exactly what guarantees translation equivariance:
A fully connected layer doesn’t have this property. Each position has its own weights, so moving the pattern to a new position produces a completely different hidden activation.
One filter detects one pattern. We want to detect many patterns: vertical edges, horizontal edges, color gradients, corners, blobs…
Stack \(C_{\text{out}}\) different filters, each producing its own feature map:
\[\text{Input image} \xrightarrow{C_\text{out} \text{ filters}} C_{\text{out}} \text{ feature maps}\]
The output is a 3D tensor: height × width × \(C_{\text{out}}\).
The layer has learned a vocabulary of local features — each filter is a “word” for a different spatial pattern.
A color image has 3 input channels (R, G, B).
Each filter must have the same depth as the input:
In general, a conv layer with \(C_{\text{in}}\) input channels and \(C_{\text{out}}\) filters has:
\[C_{\text{out}} \times C_{\text{in}} \times k \times k \quad \text{parameters (+ } C_{\text{out}} \text{ biases)}\]
Each filter integrates information across all input channels to produce one feature map.
After the first layer, the “channels” are no longer R/G/B — they’re the feature maps from the previous layer. The next layer’s filters operate on combinations of features, not raw colors.
Let’s make this concrete. For Pets images resized to 224×224:
| FC Layer | Conv Layer (3×3, 64 filters) | |
|---|---|---|
| Input | 150,528 (flattened) | 224×224×3 (spatial) |
| Output | 256 scalar units | 224×224×64 (64 feature maps) |
| Parameters | 150,528 × 256 = 38.5 million | 3×3×3×64 = 1,728 |
The conv layer produces a richer output (64 spatial feature maps vs. 256 scalars) with 22,000× fewer parameters.
This is the payoff of encoding locality + weight sharing as an architectural prior.
The network doesn’t have to learn that nearby pixels are related — the architecture assumes it.
A conv layer fits the same module pattern from L9:
Forward: slide filters across input, compute local inner products, cache input and filter values.
Backward: receive upstream \(\delta\) (a 3D tensor with the same shape as the output), compute gradients for the filter weights, pass \(\delta\) downstream.
The backward pass through a convolution is itself a convolution (with the filter transposed). PyTorch handles this — nn.Conv2d has a forward/backward pair just like nn.Linear.
Recall from L9 that every linear module produces three things in its backward pass:
| FC Layer: \(\mathbf z = \mathbf W^T \mathbf h + \mathbf b\) | Conv Layer: \(\mathbf Z = \mathbf W \circledast \mathbf H + b\) | |
|---|---|---|
| Bias gradient | \(\frac{\partial \mathcal{L}}{\partial \mathbf b} = \boldsymbol \delta\) | \(\frac{\partial \mathcal{L}}{\partial b} = \sum_{i,j} \delta_{ij}\) |
| Weight gradient | \(\frac{\partial \mathcal{L}}{\partial \mathbf W} = \mathbf h \cdot \boldsymbol \delta^T\) | \(\frac{\partial \mathcal{L}}{\partial \mathbf W} = \mathbf H \circledast \boldsymbol \Delta\) |
| Pass-through | \(\frac{\partial \mathcal{L}}{\partial \mathbf h} = \mathbf W \cdot \boldsymbol \delta\) | \(\frac{\partial \mathcal{L}}{\partial \mathbf H} = \text{rot}_{180}(\mathbf W) \circledast_{\text{full}} \boldsymbol \Delta\) |
The structure is identical — the operation changes from matrix multiply to convolution.
Why this matters: The convolution layer is just more multiplies and adds
Just done in a structured way!
GPUs FLY through this kind of computation!
But there’s way more to do!
For a 32 x 32 x 3 flattened image flattened with 256 hidden units: 786,432 multiply-adds
For a 32 x 32 x 3 image with a 3 x 3 convolutional layer with 256 filters: 7,077,088 multiply-adds
Parameter count decreases but compute time increases!
The chain of equations from L9 is identical:
\[\delta_0 \xrightarrow{\text{conv} \;\times} \delta_1 \xrightarrow{\odot \; \mathbb{I}} \delta_2 \xrightarrow{\text{conv} \;\times} \delta_3 \cdots\]
Only the internal operation of each \(\times\) module has changed. Everything from L10 — BN, gradient flow, regularization — still applies.
nn.Conv2d(
in_channels=3, # RGB input
out_channels=64, # learn 64 different filters
kernel_size=3, # each filter is 3×3
padding=1, # preserve spatial dimensions
)Same Sequential chain, same loss.backward(), same optimizer step:
model = nn.Sequential(
nn.Conv2d(3, 64, 3, padding=1),
nn.BatchNorm2d(64), # BN for conv layers (per-channel)
nn.ReLU(),
nn.Conv2d(64, 128, 3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(),
# ...
)The API is almost identical to nn.Linear. What changed is the structure of the weight matrix — from fully connected to local + shared.
The next few slides cover mechanical choices that control the shape of outputs.
These matter for getting the code to run, but they don’t fundamentally affect what the network learns. Think of these as plumbing, not architecture.
A \(3 \times 3\) filter on a \(32 \times 32\) input produces a \(30 \times 30\) output — we lose 1 pixel on each border.
In general, a \(k \times k\) filter on an \(n \times n\) input gives:
\[(n - k + 1) \times (n - k + 1)\]
To preserve spatial dimensions, pad the input with zeros:
By default, the filter moves one pixel at a time (stride = 1).
With stride = 2, the filter jumps two pixels → output spatial dimensions halve.
\[H_{\text{out}} = \left\lfloor \frac{H_{\text{in}} + 2p - k}{s} \right\rfloor + 1\]
You won’t memorize this — PyTorch computes it for you.
Stride is useful because the convolution operator applied at every pixel will produce very similar values in the same neighborhood!
With padding = ⌊k/2⌋, conv layers preserve spatial dimensions.
But we want to reduce spatial resolution as we go deeper. Two reasons:
1. Computational efficiency — processing 224×224×64 tensors at every layer is expensive.
2. Hierarchy — deeper layers should capture larger-scale patterns. Reducing resolution forces each unit to cover a larger region of the original image.
This is Belief #3 (hierarchy): complex features are compositions of simpler ones. To compose, each layer must “see” a progressively larger region.
The simplest downsampling: take non-overlapping \(2 \times 2\) windows, keep only the maximum (Max Pool 2, Stride 2):
\[ \begin{bmatrix} \boxed{1} & \boxed{2} & 3 & 4 \\ \boxed{5} & \boxed{6} & 7 & 8 \\ 9 & 10 & 11 & 12 \\ 13 & 14 & 15 & 16 \end{bmatrix} \xrightarrow{\text{MaxPool}(2)} \begin{bmatrix} \boxed{6} & 8 \\ 14 & 16 \end{bmatrix} \]
Remember that each value in the post-convolution + ReLU feature map says “Centered on this pixel, was the feature present?”
Spatial dimensions halve: \(H \times W \to \frac{H}{2} \times \frac{W}{2}\). No learnable parameters.
What pooling says: “I care that this feature was detected in this \(2 \times 2\) region, but not exactly where within the region.”
This is local spatial invariance — robustness to small shifts.
Stacking \(3 \times 3\) convolutions with pooling gives each unit a progressively larger “view” of the original image:
| Depth | Effective Receptive Field |
|---|---|
| 1 conv layer | 3×3 |
| 2 conv layers | 5×5 |
| 2 conv + pool + 2 conv | ~14×14 |
| 5 blocks with pooling | covers most of 224×224 |
Deeper networks capture longer-range spatial dependencies — not by having larger filters, but by composing many small ones.
Each \(3 \times 3\) filter is cheap (9 parameters). The expressive power comes from depth.
Each pooling step halves the spatial dimensions while the number of channels (features) grows:
After Block 1 (112×112, 64 channels): Simple local features — edges at various orientations, color gradients. Each feature is detected in a small 3×3 patch.
After Block 3 (28×28, 256 channels): Combinations of features — textures, object parts. Each unit’s effective receptive field covers a much larger region of the original image.
After Block 5 (7×7, 512 channels): Abstract, semantic features. Each position “sees” roughly a quarter of the entire image. These features respond to high-level concepts: “cat face,” “dog nose,” “striped fur.”
The network starts with many simple features over small patches and progressively combines them into few complex features that span the whole image.
After the conv/pool stages: a small spatial grid of rich feature maps.
For example: \(7 \times 7 \times 512 = 25{,}088\) values.
Flatten to a vector → feed to one or two FC layers → softmax over classes.
The conv layers did the representation learning — extracting meaningful hierarchical features from raw pixels.
The FC layers do the classification — mapping features to class probabilities.
This is where the ConvNet reconnects to everything from L9–L10: the FC “head” is exactly the MLP we already understand, just operating on much better input features.
After the final conv block, we have a rich feature tensor — say \(7 \times 7 \times 512 = 25{,}088\) values.
If we flatten this directly and connect to a FC classifier with 512 hidden units:
\[25{,}088 \times 512 = \textbf{12.8 million parameters}\]
just in that one layer. This is more parameters than the entire conv backbone that built the features!
Key idea: Each of those 512 feature maps already represents “how much of feature \(k\) is present in this image.”
We don’t need 25,088 numbers to summarize that — we need one number per feature map.
Global Average Pooling (GAP): Average each \(H \times W\) feature map down to a single scalar.
\[\text{GAP}: \quad \underset{H \times W \times C}{\mathbf{F}} \;\longrightarrow\; \underset{C \times 1}{\bar{\mathbf{f}}} \qquad \text{where} \quad \bar{f}_k = \frac{1}{HW} \sum_{i=1}^{H} \sum_{j=1}^{W} F_{ijk}\]
The classifier is now just \(\text{nn.Linear}(512, K)\) — 512 \(\times\) \(K\) parameters. For 10 classes, that’s 5,120 vs. 12.8 million.
VGG (Visual Geometry Group - Simonyan & Zisserman, 2014) is the cleanest instantiation of the three beliefs.
One design rule: always use \(3 \times 3\) filters, double the channels each time you halve the spatial dimensions.
The architecture is just this pattern repeated:
\[[\text{Conv}(3 \times 3) \to \text{BN} \to \text{ReLU}] \times B \to \text{MaxPool}(2 \times 2)\]
(Note: the original VGG predates BN — modern implementations always include it.)
| Block | Conv Layers | Output Size | Channels |
|---|---|---|---|
| Input | — | 224 × 224 | 3 |
| Block 1 | Conv×2, Pool (2,2) | 112 × 112 | 64 |
| Block 2 | Conv×2, Pool (2,2) | 56 × 56 | 128 |
| Block 3 | Conv×3, Pool (2,2) | 28 × 28 | 256 |
| Block 4 | Conv×3, Pool (2,2) | 14 × 14 | 512 |
| Block 5 | Conv×3, Pool (2,2) | 7 × 7 | 512 |
| Classifier | Global Avg Pool → FC | 1 × 1 → \(K\) | 512 → \(K\) |
The conv backbone — the representation learning part — has ~15M parameters.
In the original VGG, the FC head had ~120M parameters. Modern implementations use global average pooling instead: average each \(7 \times 7\) feature map to a single number, giving a 512-dim vector. The classifier becomes a single nn.Linear(512, K).
Two stacked \(3 \times 3\) conv layers have the same effective receptive field as one \(5 \times 5\) layer.
Three \(3 \times 3\) layers match one \(7 \times 7\).
But stacked \(3 \times 3\) is strictly better:
Fewer parameters: \(2 \times (3^2) = 18\) vs. \(5^2 = 25\) for equivalent receptive field (28% fewer).
More nonlinearities: a ReLU between each layer → more expressive per parameter.
Better gradient flow: more BN layers to stabilize training.
def vgg_block(in_ch, out_ch, n_convs):
"""VGG-style block: N × (Conv3×3 → BN → ReLU) → MaxPool"""
layers = []
for i in range(n_convs):
layers.append(nn.Conv2d(
in_ch if i == 0 else out_ch, out_ch,
kernel_size=3, padding=1, bias=False
))
layers.append(nn.BatchNorm2d(out_ch))
layers.append(nn.ReLU(inplace=True))
layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
return nn.Sequential(*layers)Same module pattern. Same loss.backward(). Same optimizer.
Drop-in replacement for the FC layers from L10.
The CNN doesn’t have more capacity than the MLP — the parameter counts are comparable.
The improvement comes entirely from the prior knowledge:
This is bias-variance in action:
When the prior is correct (images have local, translation-equivariant structure), the tradeoff is overwhelmingly favorable.
When the prior is wrong (tabular data, no spatial structure), convolutions hurt. Nobody uses CNNs on Kaggle tabular competitions.
VGG with 13 conv layers works well. But:
Depth ceiling: Past ~20 layers, adding more layers makes VGG worse. Not just test accuracy — training accuracy degrades. This is an optimization failure, not overfitting. And BatchNorm can’t save this one…