10. Convolutional Networks
A convolutional layer is a dense layer that has been forbidden two things: it may not connect a pixel to a distant pixel, and it may not use a different weight in a different place. Nothing is added. Capacity is removed, deliberately.
That is the whole idea, and it is worth stating as a claim about the world rather than about code: in an image, what matters is local, and it means the same thing wherever it appears. An edge in the top-left corner is the same event as an edge in the bottom-right. A dense layer does not know that and has to learn it — separately, at every position, from examples. A convolution is handed it for free.
The deep-learning chapter called architectures priors expressed as weight constraints. This chapter is that sentence worked out in full for one case, and then the honest 2026 accounting of where the prior still pays.
Slides
Click the slides and use ← → to move through them; F goes full screen and P opens the presenter view, with the notes. The four simulators of this page run inside the slides. Open the slides in a new tab
The constraint, priced
Below is one 3×3 kernel sliding over a 9×9 input. Press play and watch what is actually happening: the same nine numbers are read at every position, and each output cell is one dot product.
The line under the panel is the argument. Nine weights produce the entire feature map; the dense layer that maps the same inputs to the same outputs needs thousands, and — this is the part that matters more than the parameter count — it would have to rediscover the edge detector at each position from data it does not have.
Two knobs change the shape of the output and both come up constantly in practice:
- Stride \(s\) — how far the window jumps. \(s = 2\) halves the resolution and is the cheapest form of downsampling; it is also how the receptive field gets to grow geometrically (next section).
- Padding \(p\) — invented zeros around the border, so the output keeps the input's size.
padding = (k-1)/2with odd \(k\) is "same" padding, and is the default you should assume.
Read the shape error, do not guess at it
The overwhelming majority of CNN bugs are shape bugs, and the formula above resolves all of them. When PyTorch complains at the first Linear after the convolutional stack, print the shape rather than adjusting the number until it stops complaining:
From one kernel to a layer: channels
The panel slides one kernel over one grayscale map. A real layer does that many times at once. Its input has \(C_{in}\) channels — three for an RGB image, sixty-four for the output of a previous layer — so each kernel is a \(C_{in} \times k \times k\) block that reads every input channel at the window and adds them all into one number. One kernel gives one feature map; a layer with \(C_{out}\) kernels gives \(C_{out}\) maps, stacked as the channels the next layer reads.
There is no \(H\) and no \(W\) in that count. Conv2d(3, 64, 3) has \(64\,(3 \cdot 3^2 + 1) = 1\,792\) parameters whether the image is 32×32 or 4K. The dense layer that maps a 3×32×32 image to the same 64×32×32 outputs has \(65\,536 \times 3\,073 \approx 201\) million.
Equivariance is what you get; invariance is what you wanted
Weight sharing gives a precise, provable property: shift the input, and the feature map shifts by the same amount. That is translation equivariance, and it is exactly right for a feature detector.
It is not what a classifier needs. A classifier needs invariance — "cat" regardless of where the cat is. Equivariance becomes invariance only when you collapse the spatial axes or when you show the network the transformation, and there are three honest ways to do it:
| Mechanism | What it actually does |
|---|---|
| Pooling / strided convolution | Discards position within a window. Buys a small, local invariance, and buys resolution back as compute. |
| Global average pooling at the end | Collapses \(H \times W\) entirely. This is where classification invariance really comes from, and it replaced the giant fully-connected head that AlexNet and VGG carried. |
| Data augmentation | Random crops, flips, scales. Buys invariance to everything the convolution cannot be equivariant to — rotation, scale, colour — by showing examples. |
Convolutions are not rotation- or scale-invariant, and never were
The prior covers translation and nothing else. Rotation, scale and viewpoint invariance are bought with augmentation and with data, in a CNN exactly as in a Transformer. Claims that "CNNs are invariant to transformations" are a widespread overstatement of a one-word theorem.
Even the translation case is exact only at stride 1. Every stride and every pooling samples the grid, so a shift that is not a multiple of the total stride does not come back identical at the top: real CNNs are only approximately shift-invariant12.
The receptive field is the real budget
A unit deep in the stack does not see the image. It sees whatever fed it, recursively. That set is its receptive field, and its growth is the constraint that shaped every convolutional architecture ever designed.
Move the sliders and three facts fall out:
- With stride 1, reach grows linearly. Each 3×3 layer adds 2. Reaching across a 224-pixel image takes over a hundred layers — which is why no architecture is built that way.
- Stride is what makes it grow geometrically. Every stride-2 layer doubles the step of every layer above it. This — not compute — is the reason CNNs downsample: it is the only affordable route to global context.
- Dilation buys reach without weights or downsampling, by reading with gaps. It is why dilated convolutions took over semantic segmentation, where you need context and full resolution6.
Two 3×3 layers beat one 5×5, and that was the whole VGG paper
Both reach 5 pixels. The pair costs \(2 \times 9 = 18\) weights against 25, and puts a nonlinearity in the middle. Stacking small kernels is strictly better, and this is why 3×3 is the near-universal default and why 11×11 stem convolutions disappeared after 20143.
The arithmetic, in one place
For input \(X \in \mathbb{R}^{B \times C_{in} \times H \times W}\) and kernel \(W \in \mathbb{R}^{C_{out} \times C_{in} \times k \times k}\):
The backward pass is the same operation three times, which is the reason convolutions are fast on hardware built for one kernel:
| Gradient | What it is | Implementation |
|---|---|---|
| \(\partial L / \partial b\) | Sum of the output gradient over batch and space | A reduction |
| \(\partial L / \partial W\) | Cross-correlation of the input with the output gradient | A convolution |
| \(\partial L / \partial X\) | Full cross-correlation of the output gradient with the 180°-rotated kernel — the same thing as a full convolution with the kernel itself | A convolution (the "transposed" one) |
The rotation is not a trick: differentiating \(Y = W \star X\) with respect to \(X\) swaps the roles of the two arguments, and swapping them reverses the index order. With stride \(s > 1\) the recipe is the same after one extra step: insert \(s - 1\) zeros between the entries of the output gradient, so that each of them lands back where its window started. The im2col view, which turns the whole thing into one matrix multiply, is in CS231n11.
The same arithmetic, with numbers
A 3×3 input, a 2×2 kernel and a bias of 1, at stride 1 with no padding:
Each output position is one window multiplied term by term and summed. The first is \(1{\cdot}1 + 2{\cdot}0 + 4{\cdot}(-1) + 5{\cdot}1 + 1 = 3\), and the four together give
Now suppose the loss hands back \(\partial L/\partial Y = \begin{bmatrix} 0.5 & -0.5 \\ 1 & 0 \end{bmatrix}\). The three gradients from the table, on those numbers:
- Bias — sum them all: \(0.5 - 0.5 + 1 + 0 = 1\).
- Kernel — cross-correlate the input with the gradient. The first entry is \(0.5{\cdot}1 - 0.5{\cdot}2 + 1{\cdot}4 + 0{\cdot}5 = 3.5\), and the whole matrix comes out as \(\begin{bmatrix} 3.5 & 4.5 \\ 6.5 & 7.5 \end{bmatrix}\).
- Input — a full convolution of the gradient with the kernel: \(\begin{bmatrix} 0.5 & -0.5 & 0 \\ 0.5 & 1 & -0.5 \\ -1 & 1 & 0 \end{bmatrix}\).
Look at the shapes: the input gradient is 3×3, the size of \(X\), while what arrived was 2×2. Every pixel took part in as many windows as the kernel could reach it in — the corner in one, the centre in four — and the full convolution is exactly the sum over all of them. That is why it needs \(k-1\) of padding on each side, not \(k-1\) in total.
"""A passagem reversa de uma convolução, com números, conferida por diferenças finitas.
As três derivadas são convoluções — é isso que a página afirma e é isso que o print mostra.
"""
import numpy as np
from scipy.signal import correlate2d, convolve2d
def forward(X, K, b):
return correlate2d(X, K, mode="valid") + b # correlação cruzada: é o que as bibliotecas chamam de conv
def backward(dY, X, K):
dX = convolve2d(dY, K, mode="full") # full: dY cresce k-1 de CADA lado
dK = correlate2d(X, dY, mode="valid")
db = dY.sum()
return dX, dK, db
X = np.array([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.]])
K = np.array([[1., 0.], [-1., 1.]])
b = 1.0
dY = np.array([[0.5, -0.5], [1.0, 0.0]]) # gradiente que chega de cima
Y = forward(X, K, b)
dX, dK, db = backward(dY, X, K)
# Conferência: L = sum(dY * Y) tem gradiente exatamente dY, então dL/dX deve bater com dX.
eps, num = 1e-6, np.zeros_like(X)
for i in range(X.shape[0]):
for j in range(X.shape[1]):
Xp, Xm = X.copy(), X.copy()
Xp[i, j] += eps; Xm[i, j] -= eps
num[i, j] = ((dY * forward(Xp, K, b)).sum() - (dY * forward(Xm, K, b)).sum()) / (2 * eps)
np.set_printoptions(precision=2, suppress=True)
print("Y =\n", Y)
print("dY =\n", dY)
print("dX =\n", dX)
print("dK =\n", dK)
print("db =", db)
print("erro máximo contra diferenças finitas:", f"{np.abs(num - dX).max():.1e}")
Y =
[[3. 4.]
[6. 7.]]
dY =
[[ 0.5 -0.5]
[ 1. 0. ]]
dX =
[[ 0.5 -0.5 0. ]
[ 0.5 1. -0.5]
[-1. 1. 0. ]]
dK =
[[3.5 4.5]
[6.5 7.5]]
db = 1.0
erro máximo contra diferenças finitas: 5.1e-10
The last line is a finite-difference check. Without one, a padding mistake like the one above goes unnoticed, because the output still comes out in a plausible shape.
The same computation, as the lab writes it: the forward of conv2d_loop, then the three gradients of section 6, line by line. The default case is the example above. Click a cell of a result to see the sum that produced it, then turn on padding, set the stride to 2 or pick another case and watch which lines change:
Transposed convolution and the checkerboard
That same \(\partial L / \partial X\) operation, used forwards, is ConvTranspose2d — the standard way to upsample in decoders and GANs. When the stride does not divide the kernel size, output positions receive unequal numbers of contributions and the result is a visible checkerboard7. The fix everybody uses now: upsample then convolve (nn.Upsample + Conv2d), never a bare transposed convolution.
Pooling: what it throws away, and where the gradient goes
A pooling layer has no weights at all. It summarizes each window into a number, and the choice of summary decides who receives gradient:
On the way back, max splits nothing. The whole window gets zero except the position that won, which gets the entire gradient:
Average swaps that indicator for \(1/k^2\), the same sum with the credit split evenly. The panel shows both, with a gradient of 1 at every output position, so that the number you see coming back is literally how many windows that cell won.
In the default setting — a 2×2 window at stride 2 — 27 of the 36 input cells get nothing. They influenced no output, so the loss does not move when they change a little. Three consequences worth carrying:
- Max pooling is a router, not a filter. It picks one path per window, and the gradient only travels down that one. Switch to average and every cell receives \(1/k^2\) — steadier, less selective.
- The route changes during training. Today's winner need not be tomorrow's, because it depends on the values rather than the indices. That is why max pooling is not the fixed operation it looks like.
- With a stride smaller than the window, windows overlap and a cell can accumulate gradient from several of them. Drag the stride to 1 and watch the number climb.
"""Max pooling: a ida guarda o índice do vencedor, a volta devolve o gradiente só para ele."""
import numpy as np
def forward(X, k=2, s=2):
H, W = X.shape
Y = np.zeros((H // s, W // s))
arg = np.zeros_like(Y, dtype=int) # índice achatado do vencedor de cada janela
for i in range(0, H - k + 1, s):
for j in range(0, W - k + 1, s):
win = X[i:i + k, j:j + k]
arg[i // s, j // s] = np.argmax(win) # empate: argmax escolhe o primeiro
Y[i // s, j // s] = win.flat[arg[i // s, j // s]]
return Y, arg
def backward(dY, arg, shape, k=2, s=2):
dX = np.zeros(shape)
for i in range(dY.shape[0]):
for j in range(dY.shape[1]):
di, dj = divmod(arg[i, j], k)
dX[i * s + di, j * s + dj] = dY[i, j] # o resto da janela fica em zero
return dX
X = np.arange(1., 17.).reshape(4, 4)
dY = np.array([[0.5, -0.5], [1.0, 0.0]])
Y, arg = forward(X)
dX = backward(dY, arg, X.shape)
np.set_printoptions(precision=2, suppress=True)
print("X =\n", X)
print("Y =\n", Y)
print("dY =\n", dY)
print("dX =\n", dX)
print("gradiente que chegou:", dY.sum(), "· gradiente distribuído:", dX.sum(),
"· posições que receberam algo:", int((dX != 0).sum()), "de", X.size)
X =
[[ 1. 2. 3. 4.]
[ 5. 6. 7. 8.]
[ 9. 10. 11. 12.]
[13. 14. 15. 16.]]
Y =
[[ 6. 8.]
[14. 16.]]
dY =
[[ 0.5 -0.5]
[ 1. 0. ]]
dX =
[[ 0. 0. 0. 0. ]
[ 0. 0.5 0. -0.5]
[ 0. 0. 0. 0. ]
[ 0. 1. 0. 0. ]]
gradiente que chegou: 1.0 · gradiente distribuído: 1.0 · posições que receberam algo: 3 de 16
Flatten: the bridge to the dense head
Between the last convolution and a dense layer, the \((B, C, H, W)\) tensor has to be unrolled into a \((B,\; C \cdot H \cdot W)\) vector. That is flatten: no parameters, no arithmetic, and a backward pass that only puts the original shape back. What it does have is a hidden cost — it is what turns the whole map into the input of one enormous dense matrix, and that is where most of AlexNet's and VGG's parameters lived. Replacing it with global average pooling, in the table above, removed that cost outright.
A whole CNN, layer by layer
Everything above, assembled. This is the network the lab trains on Fashion-MNIST (28×28 grayscale), with the shape, the parameter count and the receptive field after every layer:
| Layer | Output shape | Parameters | Receptive field |
|---|---|---|---|
| input | 1 × 28 × 28 | — | 1 |
Conv2d(1, 16, 3, padding=1) + BatchNorm + ReLU | 16 × 28 × 28 | 192 | 3 |
MaxPool2d(2) | 16 × 14 × 14 | 0 | 4 |
Conv2d(16, 32, 3, padding=1) + BatchNorm + ReLU | 32 × 14 × 14 | 4 704 | 8 |
MaxPool2d(2) | 32 × 7 × 7 | 0 | 10 |
Conv2d(32, 64, 3, padding=1) + BatchNorm + ReLU | 64 × 7 × 7 | 18 624 | 18 |
AdaptiveAvgPool2d(1) + Flatten | 64 | 0 | the whole image |
Linear(64, 10) | 10 | 650 | — |
| total | 24 170 |
Three patterns to read off it, and they hold in every CNN since VGG:
- Resolution goes down, channels go up. Each pooling halves \(H\) and \(W\), and the next convolution doubles the channels. The tensor gets smaller while what it describes gets richer: fewer positions, more questions asked about each.
- The parameters live in the late layers. The first convolution costs 192; the third costs 18 624, because its cost grows with \(C_{in} \cdot C_{out}\), not with the image.
- The head is cheap because of global average pooling. With
Flatteninstead, the last layer would read \(64 \cdot 7 \cdot 7 = 3\,136\) numbers and cost 31 370 parameters on its own — more than the whole network above — and it would tie each of them to a position. An MLP that reaches similar accuracy,784 → 256 → 128 → 10, has 235 146.
How the architecture got its shape
Two decades of vision research — from LeNet-51 and AlexNet2 to ConvNeXt — left a handful of moves that are still in every model, and a larger pile that is not. It is worth knowing which is which.
-
What survived
- 3×3 kernels, stacked — VGG's argument, above3.
- Residual blocks — ResNet4; without them a plain stack past ~20 layers gets worse as it gets deeper, even on its training set. Now in literally every architecture, convolutional or not.
- Batch/group normalization after each convolution.
- Global average pooling instead of a fully-connected head — most of the old parameter count was in that head, and it did nothing that GAP does not do better.
- Depthwise separable convolutions — factor a \(k{\times}k{\times}C_{in}{\times}C_{out}\) kernel into a spatial part and a \(1{\times}1\) channel-mixing part, for roughly \(1/C_{out} + 1/k^2\) of the cost5. Every model that has to run on a phone uses them.
- 1×1 convolutions — a per-position dense layer; how every architecture changes channel count cheaply.
-
What did not
- Local Response Normalization (AlexNet) — superseded by BatchNorm and gone.
- Fully-connected classifier heads with tens of millions of parameters.
- Large stem kernels (11×11 at stride 4, 7×7 at stride 2) — replaced by stacks of 3×3, or by patch embedding.
- Auxiliary classifiers mid-network (GoogLeNet) — a workaround for vanishing gradients that residual connections solved properly.
- Sigmoid and tanh inside vision stacks.
- The ImageNet backbone race itself — a new hand-designed classification backbone is no longer a publishable contribution, and has not been for years.
ConvNeXt: the control experiment
It is tempting to conclude from 2020–2021 that attention beat convolution. ConvNeXt8 is the experiment that tests it properly: take a ResNet-50, and change only the things that are not attention — the training recipe (AdamW, 300 epochs, heavy augmentation), the stage ratios, a 7×7 depthwise kernel, LayerNorm instead of BatchNorm, GELU instead of ReLU, fewer normalizations per block. No attention anywhere.
The result matches Swin Transformer at every scale. The honest reading is not "convolutions win" — it is that a large part of the reported ViT advantage was the training recipe, not the architecture, and that the two families converged on the same block design from opposite directions. Where the architectures still genuinely differ is data scale: see chapter 13.
Where convolutions actually live in 2026
They lost the headline task and kept almost everything else. This matters for your project choices:
| Still convolutional, and not by inertia | Why |
|---|---|
| The VAE in every latent diffusion model | Stable Diffusion, SDXL, SD3 and FLUX all encode pixels to latents with a convolutional autoencoder. The prior is right and the cost is linear in pixels — see chapter 20. |
| Medical and scientific segmentation | nnU-Net9 remains the benchmark-winning default across dozens of biomedical tasks; training sets are hundreds of volumes, not billions of images, and a strong prior is worth more than capacity. |
| On-device and real-time vision | Detection and segmentation at 30 fps on a phone or a car. MobileNet/EfficientNet-class models, depthwise separable throughout. |
| Audio, spectrograms, time series | 1-D convolutions are still the cheap, strong baseline. |
| The stem of many "Transformer" models | A ViT's patch embedding is a strided convolution; many hybrid models keep several real convolutional stages before the first attention block. |
| Now usually a Transformer | Why |
|---|---|
| Large-scale image classification and retrieval | Global relations from layer 1, and better returns from web-scale pretraining. |
| Open-vocabulary recognition | CLIP-style training (chapter 19) needs a text tower and a shared space. |
| Text-to-image generation backbones | DiT and MMDiT replaced the U-Net10 in SD3 and FLUX (chapter 22). |
The rule that generalizes past this chapter
A convolution is a strong, cheap, fixed prior. A Transformer has a weak, expensive, learnable one. The strong prior wins when data is scarce, latency matters or the structure is genuinely local; the weak one wins when you can pay for enough data to learn a better structure than the one you would have imposed. That trade-off is the axis the next four chapters move along.
Lab: the constraint, measured
Every claim on this page is measured in a Colab notebook on Fashion-MNIST (70 000 grayscale clothing images, 28 × 28). It runs in the browser, on CPU; there is nothing to install. Nine sections, each ending in a checkpoint with a check value:
- a convolution written by hand and checked against PyTorch;
- the shape error of the first
Linear, read instead of guessed; - the parameter bill of locality and weight sharing, and of the separable convolution;
- equivariance and invariance, measured, and why the border breaks the first;
- the receptive field read off a gradient, theoretical and effective;
- the backward pass of a convolution and of max pooling, against autograd;
- an MLP against two CNNs that differ only in the head, on test images shifted until the difference shows, and then rotated;
- what the first layer learned;
- a CNN of your own, under a 30 000-parameter budget, scored on the shifted test set.
Open the lab in Colab — save a copy to your Drive (File → Save a copy in Drive) to run and edit it.
Key takeaways
- A convolution is a dense layer with locality and weight sharing imposed. It is capacity removed, not added, and both constraints are claims about images. Its parameter count, \(C_{out}(C_{in}k^2 + 1)\), does not depend on the image size.
- Weight sharing gives translation equivariance. Invariance comes from global pooling and from augmentation — and nothing gives rotation or scale invariance for free.
- The receptive field is the design budget. Stride is what makes it grow geometrically; dilation buys reach without downsampling.
- Two 3×3 layers beat one 5×5 — fewer weights, more nonlinearity. This is why 3×3 is the default.
- The backward pass is three convolutions; used forwards, the input-gradient one is transposed convolution, and it checkerboards. Prefer upsample + convolve.
- Of the classic architecture, what survived is 3×3 stacks, residuals, normalization, global average pooling, depthwise separable and 1×1 convolutions. The big FC heads, LRN and large stem kernels did not.
- ConvNeXt shows much of the ViT advantage was the training recipe. The families converged.
- Convolutions lost the ImageNet backbone race and kept the latent autoencoders, medical segmentation, on-device vision and the patch stem — which is most of the deployed pixels in the world.
-
LeCun, Y., Bottou, L., Bengio, Y., & Haffner, P. (1998). Gradient-Based Learning Applied to Document Recognition — Proc. IEEE. LeNet-5: convolution, pooling and backpropagation already assembled into the shape used today. ↩
-
Krizhevsky, A., Sutskever, I., & Hinton, G. (2012). ImageNet Classification with Deep Convolutional Neural Networks — NIPS. AlexNet; the result that made GPUs standard equipment. ↩
-
Simonyan, K., & Zisserman, A. (2015). Very Deep Convolutional Networks for Large-Scale Image Recognition — ICLR. VGG; the "stack 3×3 instead of using a big kernel" argument, made carefully. ↩↩
-
He, K., Zhang, X., Ren, S., & Sun, J. (2016). Deep Residual Learning for Image Recognition — CVPR. ↩
-
Howard, A., et al. (2017). MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications. Depthwise separable convolutions, with the cost accounting spelled out. ↩
-
Yu, F., & Koltun, V. (2016). Multi-Scale Context Aggregation by Dilated Convolutions — ICLR. Exponentially growing receptive field at constant resolution. ↩
-
Odena, A., Dumoulin, V., & Olah, C. (2016). Deconvolution and Checkerboard Artifacts — Distill. Interactive, and the reason "upsample then convolve" became standard. ↩
-
Liu, Z., Mao, H., Wu, C.-Y., Feichtenhofer, C., Darrell, T., & Xie, S. (2022). A ConvNet for the 2020s — CVPR. The ablation that separates the recipe from the architecture. ↩
-
Isensee, F., Jaeger, P., Kohl, S., Petersen, J., & Maier-Hein, K. (2021). nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation — Nature Methods. Why a well-configured U-Net is still the thing to beat. ↩
-
Ronneberger, O., Fischer, P., & Brox, T. (2015). U-Net: Convolutional Networks for Biomedical Image Segmentation — MICCAI. The encoder–decoder with skip connections that later became the diffusion backbone. ↩
-
Karpathy, A., et al. CS231n: Deep Learning for Computer Vision — Stanford. Still the best free treatment of the mechanics, including the
im2colview of convolution as one matrix multiply. ↩ -
Zhang, R. (2019). Making Convolutional Networks Shift-Invariant Again — ICML. Measures how far a one-pixel shift moves a CNN's output, and fixes it by blurring before each subsampling. ↩