Computing Output Shapes: 32x32 Input, 5x5 Filter, What Comes Out Next?
You have an RGB image tensor shaped (3, 32, 32) — CIFAR-10 layout, channels first — and you want to slide a 5x5 filter across it. Before you touch nn.Conv2d, you should be able to answer two questions out loud: how large is the resulting feature map, and how many parameters does the layer hold? Interviewers ask both, and PyTorch will happily disagree with your hand calculation at inference time if you get the first one wrong.
The output size formula is:
H_out = floor((H_in + 2p - k) / s) + 1 — and identically for width, where k is kernel size, p is padding, s is stride.
Work through three cases on a 32x32 input:
| Input | kernel k |
padding p |
stride s |
Math | Output |
|---|---|---|---|---|---|
| 32x32 | 5 | 0 | 1 | (32 - 5)/1 + 1 | 28x28 |
| 32x32 | 5 | 2 | 1 | (32 + 4 - 5)/1 + 1 | 32x32 |
| 32x32 | 3 | 1 | 2 | floor(31/2) + 1 | 16x16 |
| 7x7 | 3 | 1 | 2 | floor(6/2) + 1 | 4x4 |
Padding of (k-1)/2 with stride 1 reproduces the input size — that is what "same" padding means, and it lets you stack convs without the map shrinking under you. Stride 2 halves each dimension. The floor function matters: when the numerator does not divide evenly, PyTorch drops the final partial position instead of padding it, so your hand-computed shape and the framework's can differ by one when you stack several strided layers.
import torch
import torch.nn as nn
x = torch.randn(1, 3, 32, 32) # NCHW: batch, channels, H, W
conv = nn.Conv2d(3, 16, kernel_size=5, stride=1, padding=0)
y = conv(x)
print(y.shape) # torch.Size([1, 16, 28, 28])
k, p, s = 3, 1, 2
print((32 + 2 * p - k) // s + 1) # 16 — the formula by hand
print(conv.weight.shape) # torch.Size([16, 3, 5, 5])
Now the parameter count. conv.weight holds (out_channels, in_channels, k, k) = 16 x 3 x 5 x 5 = 1,200 weights, plus 16 biases: 1,216 parameters for the entire layer. A fully connected layer over the flattened image would need 3 x 32 x 32 = 3,072 inputs x 16 units = 49,168 weights — 40x more, and every position would need its own copy. That ratio is the whole reason convolutions exist. In Building a Network in PyTorch your inputs were already flat vectors where dense layers make sense; images carry spatial structure, and dense layers throw it away.
Two knobs you will use constantly: padding accepts a tuple (pad_h, pad_w) for asymmetric borders, and dilation stretches the kernel so a 3x3 kernel with dilation 2 reads a 5x5 region without extra parameters. Dilated convolution changes the formula to (H + 2p - d(k-1) - 1)/s + 1, and it is how segmentation networks grow their receptive field without pooling away resolution.
The standard interview follow-up: "what happens when you double the stride?" Each spatial dimension roughly halves, so activations at the next layer drop by about 4x — that trade between resolution and compute follows you through every architecture in CNN Architecture Patterns, so get comfortable computing it on a whiteboard in under a minute.
Two shape errors account for most debugging sessions. First, the channel axis must line up: nn.Conv2d expects (batch, in_channels, H, W), so a grayscale dataset loaded as (N, 1, 28, 28) fed into a layer built with in_channels=3 dies with a weight-shape mismatch, and NHWC-format datasets (TensorFlow exports, most raw image folders) must be permuted to channels-first before touching PyTorch convolutions. Second, in_channels of each conv must equal out_channels of the previous one — the chain of channel counts, not the spatial math, is where a stacked model throws its most confusing error. Trace both axes on paper for your first network; after that, reading model(x) shapes becomes reflexive.
Why One 5x5 Filter Works Everywhere: Parameter Sharing and Receptive Fields
Your layer outputs 16 feature maps. Ask a sharper question: what is each of those 16 filters looking for, and why is the same set of 25 numbers applied at the top-left corner and again at the bottom-right corner of the image?
Parameter sharing is the answer: a convolution uses one weight tensor at every spatial location. When the kernel slides from pixel (0,0) to pixel (27,27), the weights do not change. The gradient for each weight is accumulated across every position where it was used, so a kernel that fires on vertical edges learns from every vertical edge in every image in the batch. Instead of learning 49,168 independent numbers, you learn 1,216 numbers that are reused 784 times each on a 28x28 map. That data efficiency is why CNNs train on thousands of images rather than millions.
Three consequences worth stating precisely, because interviews probe them:
- Translation behavior. Move a whisker pattern 4 pixels right and the conv response moves 4 pixels right with the same values (borders aside). The filter detects the pattern wherever it appears — no separate weights per location.
- Inductive bias. You are assuming useful structure is local. True for images: edges, textures, and color blobs are neighborhoods. False for tabular data where column 3 has no spatial relation to column 4 — feeding shuffled columns into a 1D convolution is a standard misuse of the layer.
- Receptive field growth. The receptive field is the region of the original input that shapes one output value. A first 5x5 conv gives each output a 5x5 view. A second 5x5 conv on that feature map sees 9x9 original pixels (5 + 5 - 1); a third sees 13x13. The field grows linearly with depth for a fixed kernel size, which is the fundamental argument for depth: to recognize a cat, one output must simultaneously see whiskers, ear, and eye, and covering a 224x224 image with 5x5 kernels alone would take dozens of layers. Two standard fixes — small 3x3 kernels stacked deeper, and pooling to jump resolution — are exactly what CNN Architecture Patterns is built around.
You can also inspect what a trained filter responds to, a favorite follow-up: "show me a filter." Pull conv.weight[0] and it typically resembles a Gabor patch — opposing dark and light bands. Simple convolutions trained on natural images converge on edge detectors, color gratings, and blobs regardless of dataset; this was one of the first big visualizations of what deep nets learn.
w = conv.weight.detach() # (16, 3, 5, 5)
edge = w[0] # one filter, all three channels
print(edge.shape) # torch.Size([3, 5, 5])
print(edge.mean(dim=(1, 2))) # per-channel mean near 0 => edge detector
print(edge.abs().sum().item()) # filter "energy" for comparing filters
Push sharing further and you get the depthwise convolution: each input channel gets its own k x k kernel instead of one kernel spanning all channels, and channels are mixed afterwards with 1x1 convolutions. MobileNets cut parameters roughly 8x that way. The mechanism is the same sliding window you just wrote — split into a spatial step and a channel step. The channel-mixing half, the 1x1 convolution, gets its own treatment in the next topic.
One nuance worth having ready: sharing means border positions are not treated exactly like interior positions. With zero padding, a filter touching the image edge multiplies against zeros, so its response is systematically weaker there than the same pattern in the middle — evidence that border pixels are slightly less trusted by the layer, and one reason same padding exists. It is also the honest limitation of the translation claim: responses shift with the pattern only away from the borders.
Halving the Map: What Max Pooling Buys You and What It Costs
After a conv layer you often see MaxPool2d(2): a 28x28 feature map becomes 14x14 and the next layer does a quarter of the arithmetic. Pooling is a fixed operation — no weights, nothing learned — and it earns its place for three reasons, while costing you two things you should never forget.
What it buys:
- Computation. With kernel equal to stride (non-overlapping), a 2x2 pool cuts activations 4x. Compute in the next conv scales with map area, so downsampling before the expensive deep stack is where most of your FLOPs are saved.
- Translation tolerance. Max over a 2x2 window means a feature shifted by one pixel still produces the same pooled value as long as it stays inside the window. Slight movement no longer changes what downstream layers see.
- Receptive field per layer. Each pooled step jumps 2 input pixels, doubling coverage without adding parameters — it is the cheapest way to grow the receptive field discussed in the previous section.
The size formula mirrors convolution: with k=2, s=2, p=0, 28x28 becomes 14x14, but a 13x13 map becomes floor((13-2)/2) + 1 = 6, not 7. Remember the floor: odd-sized maps shrink asymmetrically, and errors here compound across a stack.
import torch
import torch.nn as nn
x = torch.randn(1, 64, 28, 28)
pool = nn.MaxPool2d(kernel_size=2, stride=2) # non-overlapping
print(pool(x).shape) # [1, 64, 14, 14]
same = nn.MaxPool2d(kernel_size=3, stride=1, padding=1) # keeps size
print(same(x).shape) # [1, 64, 28, 28]
What it costs:
- Spatial precision. After three 2x2 pools, one cell covers an 8x8 input region. Detection and segmentation need that resolution back, which is why those architectures substitute strided or dilated convolutions and skip connections for aggressive pooling.
- Discarded evidence. Max keeps one winner and throws away the runner-ups and their positions inside the window; AvgPool keeps the mean instead — smoother, but weaker for "is this pattern present?" features. Nothing about the choice is stored, and the model cannot recover which of the four cells fired.
That leads to the interview staple: "max pool or stride-2 conv?" The modern answer is that learnable downsampling usually wins — a stride-2 convolution halves resolution while letting the network decide what to keep, and that is what ResNet-class backbones use for most of their stages. Max pooling survives in older stacks (LeNet, VGG), in encoder-decoder architectures that need to route maxima back through unpooling, and in the global variant: nn.AdaptiveAvgPool2d(1) collapses any H x W map to 1x1 per channel, replacing a giant flattening layer — ResNet's classification head turns 7x7x512 = 25,088 values into a clean 512-dim vector.
Two refinements show up in real architectures and interviews. Overlapping pooling — kernel_size=3, stride=2 — leaves a one-pixel overlap between windows; OverFeat reported slightly better accuracy from it, and the size formula still runs with the floor: a 224-wide map gives floor((224-3)/2)+1 = 111 under k=3, s=2 versus floor((224-2)/2)+1 = 112 under k=2, s=2 — compute it rather than remember it. And because pooling has no parameters, it behaves identically in train() and eval() — no running statistics, no stochasticity — so it is never the source of a train/eval mismatch; BatchNorm and dropout are.
One practical trap: pooling halves the map every time you use it, so count your pools against your input size. Four 2x2 pools on a 32x32 input leaves 2x2, and your classifier head collapses with it. With the mechanics down, you are ready to assemble whole networks in CNN Architecture Patterns.
Quiz
1. A (1, 3, 32, 32) input goes through nn.Conv2d(3, 16, kernel_size=5, stride=2, padding=2). What is the output spatial size?
2. A Conv2d(3, 16, kernel_size=5) on a 32x32 RGB image sits next to a Linear(3072, 16) over the flattened image. How do their parameter counts compare?
3. A 13x13 feature map passes through MaxPool2d(kernel_size=2, stride=2, padding=0). What comes out?
4. Which statement about parameter sharing in a convolution is correct?
Flashcards
Question
State the convolution/pooling output-size formula and what “same” padding means.
Click to reveal answer
Answer
H_out = floor((H_in + 2p - k) / s) + 1, identically for width. Same padding sets p = (k-1)/2 with stride 1 so the output keeps the input size — stacking convs then shrinks nothing until you deliberately pool.
Question
Why does a 5x5 conv with 16 output channels need ~40x fewer parameters than a dense layer on the same 32x32 RGB image?
Click to reveal answer
Answer
Conv: (16, 3, 5, 5) = 1,200 weights + 16 biases = 1,216, one kernel reused at every position. Dense: 3,072 inputs x 16 = 49,152 + 16 = 49,168 position-specific weights. Sharing converts per-location learning into one filter learned from all locations.
Question
What does pooling buy, and what does it cost?
Click to reveal answer
Answer
Buys: 4x fewer activations per non-overlapping 2x2 pool (compute), tolerance to 1-2px shifts, and faster receptive-field growth. Costs: spatial precision — after three pools one cell covers 8x8 input pixels — and discarded evidence, since max keeps only the winning value.
Question
How does dilation change the output-size formula, and what is it for?
Click to reveal answer
Answer
H_out = floor((H + 2p - d(k-1) - 1) / s) + 1. Dilation d stretches the sampling grid so a 3x3 kernel reads a 5x5 region at d=2 with the same parameter count — receptive field grows without pooling away resolution.
Question
When is max pooling the wrong downsampler?
Click to reveal answer
Answer
When you must recover localization (detection, segmentation), when learned selection of what to discard beats fixed max — stride-2 conv is the modern default in backbones — and when pooling stacks shrink the map faster than the input size allows.
Revision Notes
Key Takeaways
- 1. Output shape is floor((H + 2p - k)/s) + 1 for conv and pool alike: same padding holds size, stride 2 halves it, and the floor is where hand calculations break on odd dimensions
- 2. One kernel is reused at every position — 1,216 parameters versus the dense layer’s 49,168 — and each weight’s gradient is summed over every location the filter visited
- 3. Receptive fields grow about k per layer (5x5 stack: 5 -> 9 -> 13 pixels) and double per pooled step, which is why depth plus small kernels reaches whole-image context
- 4. Pooling quarters activations and adds translation tolerance, but spends spatial precision; strided convolutions let the network learn what to keep and increasingly replace it
- 5. Global average pooling collapses any HxW map to one value per channel, which is how classification heads dodge flattening-induced parameter explosions
Interview Tips
- • "Compute the output shape" — quote floor((H + 2p - k)/s) + 1, work one stride-2 example aloud including the floor, then state what PyTorch will print; the floor on odd sizes is where candidates slip
- • "Why do convolutions beat dense layers for images?" — parameter sharing with the 1,216 vs 49,168 comparison, plus the inductive bias that neighbors relate — and name the case it fails (shuffled tabular columns)
- • "Max pool or stride-2 conv?" — learned downsampling usually wins in modern backbones; keep max pooling for encoder-decoder unpooling and adaptive average pooling for heads; justify with what each preserves
- • "How does a feature’s receptive field grow?" — k per layer for fixed kernels, doubled per pooled step, then connect it to why architectures stack 3x3 kernels instead of one large kernel
Cheat Sheet
Convolution and Pooling — Cheat Sheet
Output shape (conv and pool share it):H_out = floor((H_in + 2p - k) / s) + 1 (same for W). Dilated: (H + 2p - d(k-1) - 1)/s + 1.
| Input | k | p | s | Output |
|---|---|---|---|---|
| 32x32 | 5 | 0 | 1 | 28x28 |
| 32x32 | 5 | 2 | 1 | 32x32 (same) |
| 32x32 | 3 | 1 | 2 | 16x16 |
| 13x13 | 2 | 0 | 2 (pool) | 6x6 (floor!) |
Parameter count: Conv2d(cin, cout, k) = cout * cin * k * k + cout.
3->16 with 5x5: 16325 + 16 = 1,216 vs dense 3,072*16 + 16 = 49,168 (~40x).
PyTorch template:
conv = nn.Conv2d(3, 16, kernel_size=5, stride=1, padding=0)
y = conv(torch.randn(1, 3, 32, 32)) # (1, 16, 28, 28)
pool = nn.MaxPool2d(2) # k=2, s=2 -> halves H and W
head = nn.AdaptiveAvgPool2d(1) # any HxW -> 1x1 per channel
Why it works: one kernel applied at every location (parameter sharing) + local receptive fields that grow ~k per layer; gradients for a weight accumulate over all positions.
Pooling buys: 4x fewer activations per 2x2/stride-2 pool, translation tolerance, receptive-field growth. Costs: spatial precision, discarded runner-up evidence. Strided conv usually beats max pooling in modern backbones; adaptive average pooling replaces huge flattening heads.