CNN Geometry: What Shape Reaches the Next Layer?
Here is a batch of 32 RGB images and an ordinary first convolution.
x = torch.randn(32, 224, 224, 3)
conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3)
conv(x)
RuntimeError: Given groups=1, weight of size [16, 3, 3, 3],
expected input[32, 224, 224, 3] to have 3 channels, but got 224 channels instead
The images have three channels. PyTorch says there are 224. The message names a number that appears nowhere in the model definition, so the obvious reading is that the layer was declared wrong, and the obvious repair is to declare it right:
conv = nn.Conv2d(in_channels=224, out_channels=16, kernel_size=3)
That repair works in the narrowest sense: it does not raise, and it returns a tensor, so training can proceed:
in_channels=224 output shape: (32, 16, 222, 1)
weight shape: (16, 224, 3, 3)
params: 32272
Look at the output width. It is 1. The layer consumed a spatial axis of size 3 with a kernel of size 3 and produced a single column, and it built a weight tensor of 32,272 parameters to do it. The correct layer has 448.
Nothing about that tensor is illegal, and some downstream layers may accept it. But the model is now treating the 224 image rows as channels and the original three RGB values as the spatial width. It may train and its loss may fall; neither would show that the input geometry is correct.
The exception was accurate. Conv2d reads the channel count from axis 1, axis 1 held 224, and it reported exactly that. What it could not tell us is that axis 1 was never the channel axis in the first place. This batch is [B, H, W, C] and the layer expects [B, C, H, W], so the correct repair is not to the layer at all:
x = x.permute(0, 3, 1, 2)
permuted shape: (32, 3, 224, 224) contiguous: False
output shape: (32, 16, 222, 222)
weight shape: (16, 3, 3, 3)
params: 448
Same error message, two repairs, and only one of them is about the actual problem. That gap is what this chapter is for.
Do not repair a dimension until you know what that dimension means.
Where we are
Chapter 6 turned input delivery into an observable system and asked where the training loop was actually waiting. Chapter 7 took the batch apart and asked what the model was actually seeing, ending with a tensor whose contract we can state and test:
[B, C, H, W] float32, normalized under a known convention
Both chapters stopped at the same place: the moment model(x) is called. Chapter 6 owns delivery. Chapter 7 owns representation. This chapter begins once the trusted tensor crosses the model boundary.
Chapter 4 built a network that consumed a flat vector, and Chapter 5 organized it into a module tree. Neither did anything with the fact that two of those four axes are spatial β that pixel (h, w) sits next to (h, w+1) in a way that feature 300 does not sit next to feature 301 in a flattened vector. A convolutional network is the architecture that takes that adjacency seriously.
Taking it seriously means transforming the channel axis and the spatial axes according to rules that are entirely mechanical, entirely derivable, and entirely easy to get wrong by one. So the question for this chapter is narrow and practical:
Given a tensor shaped
[B, C, H, W]and a layer, what should the output shape be, why, and where did the geometry first diverge if the observed shape is different?
That is not the same as “what is a CNN?”. Plenty of readers can already describe convolution in words and still spend an afternoon changing in_features until an exception goes away. The goal here is the ability to reason mechanically about CNN tensor geometry β well enough to read an unfamiliar convolutional network, predict what it does to a shape, and diagnose it when it disagrees.
The environment
The examples were executed with PyTorch 2.13.0, TorchVision 0.28.0 and Python 3.12.3 on Linux CPU. Error wording can change between releases, so treat the messages below as evidence about shapes and contracts rather than stable API text.
[B, C, H, W] is four different kinds of axis
Chapter 7 insisted that a shape is not four integers. That insistence does most of the work in this chapter, so it is worth restating in the specific form convolution needs.
The two spatial axes are the ones with adjacency. Channel indices do not have spatial adjacency. If channels are permuted consistently and the corresponding input-channel weights in the next convolution are permuted with them, the computation is unchanged; permuting the activation channels alone generally changes the result.
That distinction is worth saying in one line, because it is the line that makes convolution geometry make sense:
Height and width say where. Channels say what is measured there.
For the first layer, C = 3 usually does mean red, green and blue, and it is fine to picture it that way. The trouble starts immediately afterward. A network with
C = 3 β 16 β 32 β 64 β 128
does not have 128 colors. Those are learned feature coordinates: for each spatial position, 128 numbers produced by 128 different learned filters. They are not individually meaningful in any guaranteed way. A given channel may respond to something a human would name, or it may carry a fragment of several such things distributed across neighbors. The safe statement is the structural one β each channel is one learned measurement available at each location β and any claim about what a particular channel detects is a hypothesis requiring its own evidence.
Two consequences follow immediately, and they are the two questions this chapter keeps asking:
C_outis declared by the layer, while the incoming tensor must satisfy the layer’sC_incontract;H_outandW_outare derived fromH_in,W_in, kernel size, stride, padding and dilation.
Those are separate transformations that happen to be performed by one layer. Keeping them separate in your head is most of what makes CNN debugging tractable, so the rest of the chapter derives them separately.
One convolution, computed by hand
Before any formula, the smallest case that shows the mechanism. One input channel, a 4Γ4 grid, one 2Γ2 kernel, one bias.
inp = torch.tensor([[[[ 1., 2., 3., 4.],
[ 5., 6., 7., 8.],
[ 9., 10., 11., 12.],
[13., 14., 15., 16.]]]]) # [1, 1, 4, 4]
k = torch.tensor([[[[1., 0.],
[0., 1.]]]]) # [1, 1, 2, 2]
b = torch.tensor([0.5])
out = F.conv2d(inp, k, b)
output shape: (1, 1, 3, 3)
output:
tensor([[ 7.5000, 9.5000, 11.5000],
[15.5000, 17.5000, 19.5000],
[23.5000, 25.5000, 27.5000]])
Now the arithmetic for three of those nine values, done by placing the kernel over the input and multiplying position by position:
(0,0) patch=[1, 2, 5, 6] products=[1, 0, 0, 6] sum=7 +bias=7.5
(0,1) patch=[2, 3, 6, 7] products=[2, 0, 0, 7] sum=9 +bias=9.5
(1,0) patch=[5, 6, 9, 10] products=[5, 0, 0, 10] sum=15 +bias=15.5
That is the whole operation:
take a local patch
multiply elementwise by the kernel
sum
add the bias
write one output value
move to the next position
Nine positions fit inside a 4Γ4 grid, so the output is 3Γ3. Computing all nine by hand and comparing gives manual == pytorch: True.
One precision point while the arithmetic is in view. The patch at (0,0) is [1, 2, 5, 6] and the kernel is applied to it in that order, without being flipped first. With an asymmetric kernel the difference is visible:
kernel [[1,2],[3,4]] out[0,0] = 44.0 (1*1 + 2*2 + 3*5 + 4*6)
flipped [[4,3],[2,1]] out[0,0] = 26.0
PyTorch describes Conv2d using valid 2-D cross-correlation: the learned kernel is applied without first being flipped. Deep-learning libraries conventionally call the layer a convolution; the distinction matters mainly when you compare PyTorch against a reference implementation that uses the signal-processing convention.
Every output value sums across all input channels
The one-channel example hides the channel reduction. With:
The same weights at every position
A convolution reuses the same kernel weights at every spatial location. That is weight sharing: unlike a Linear layer over a flattened image, the layer does not learn a different weight set for every pixel position.
That reuse gives ordinary convolution a translation-equivariant structure under the appropriate conditions: shifting the input tends to shift the feature map rather than requiring new weights. It is not a blanket claim of translation invariance. Padding, finite boundaries, stride, pooling and the classifier head all complicate that relationship.
Whether shared local computation is useful is a property of the data, not of Conv2d.
The bias is one number per output channel
For a convolution with bias, bias.shape == [C_out]. PyTorch adds bias[j] to every spatial position of output channel j for every example in the batch. That is the same broadcasting mechanism Chapter 2 established and the same learned-offset role the bias played in Chapter 4; no new geometry is introduced here.
The weight tensor explains the whole layer
Much of a Conv2d layer’s structural contract is visible directly on the module: channel counts, kernel size, stride, padding, dilation, groups, weight shape and bias shape. The input image height and width are not stored there; they arrive at runtime.
c = nn.Conv2d(16, 32, kernel_size=(5, 3), stride=(2, 1),
padding=(2, 1), dilation=(1, 2), groups=4)
Conv2d(16, 32, kernel_size=(5, 3), stride=(2, 1), padding=(2, 1), dilation=(1, 2), groups=4)
in_channels 16
out_channels 32
kernel_size (5, 3)
stride (2, 1)
padding (2, 1)
dilation (1, 2)
groups 4
weight (32, 4, 5, 3) = [C_out, C_in/groups, K_h, K_w]
bias (32,)
The weight shape is the layer, written as four numbers:
[C_out, C_in/groups, K_h, K_w]
32 output feature channels
4 input channels combined per output channel (16 / 4 groups)
5 kernel height
3 kernel width
For the ordinary case, groups=1, that second axis is simply C_in, and the reading is the one from the previous section:
nn.Conv2d(3, 16, kernel_size=3) -> weight [16, 3, 3, 3]
16 output feature channels
for each one: combine all 3 input channels over a 3x3 spatial neighborhood
For the ordinary groups=1 case, a weight shaped [64, 32, 3, 3] means 64 output channels, 32 input channels and a 3Γ3 kernel. If grouped convolution is possible, the second dimension is only C_in / groups, so the weight tensor by itself does not reveal the total input-channel count. Inspect groups, the module definition or the neighboring activation shape before inferring it.
Note what the weight shape does not contain: any height or width of an image. Kernel geometry is in there. Image geometry is not. That absence is the subject of a later section.
groups, briefly, because it explains the second weight axis
groups partitions channel connectivity. Both in_channels and out_channels must be divisible by groups, and the weight shape becomes:
Two transformations, not one
A convolution changes two independent things, and the single biggest improvement you can make to CNN debugging is refusing to think about them together.
CHANNEL QUESTION SPATIAL QUESTION
What is C_out? What are H_out and W_out?
determined by: determined by:
out_channels kernel_size
stride
padding
dilation
Note the asymmetry. C_out is not derived from anything β you declared it, and the layer produces it. There is no arithmetic. The C_in side is a compatibility requirement rather than a calculation: the incoming tensor must have the channel count the layer was built for.
The spatial side is the opposite. Nothing declares H_out. It is a consequence of four parameters and the input size, and getting it wrong is what produces most of the shape errors in this chapter.
So every convolution in a shape ledger gets two lines of reasoning, not one:
[B, C_in, H_in, W_in]
β conv: channels C_in -> C_out (declared)
β conv: geometry H_in -> H_out, W_in -> W_out (derived)
[B, C_out, H_out, W_out]
Deriving H_out and W_out
Start with the simplest possible question and no formula at all: how many distinct positions can a kernel of width k occupy along an axis of length n?
input 8, kernel 3, stride 1, padding 0 -> 6 (8 - 3 + 1 = 6)
input 8, kernel 5, stride 1, padding 0 -> 4 (8 - 5 + 1 = 4)
input 8, kernel 1, stride 1, padding 0 -> 8 (8 - 1 + 1 = 8)
input 8, kernel 8, stride 1, padding 0 -> 1 (8 - 8 + 1 = 1)
The kernel’s left edge can sit at position 0, and its right edge cannot pass position n-1, so there are n - k + 1 valid placements. Every other term in the general formula is a modification of that count.
Padding adds p positions at each end before the count is taken, so n becomes n + 2p:
input 8, kernel 3, padding 0 -> 6 (8 + 2*0 - 3 + 1 = 6)
input 8, kernel 3, padding 1 -> 8 (8 + 2*1 - 3 + 1 = 8)
input 8, kernel 3, padding 2 -> 10 (8 + 2*2 - 3 + 1 = 10)
Stride does not change which positions are valid; it changes how many of them are visited. Starting at the first and stepping by s, the number of visited positions among n - k + 1 candidates is floor((n - k)/s) + 1:
input 8, kernel 3, stride 1 -> 6 (floor((8-3)/1) + 1 = 6)
input 8, kernel 3, stride 2 -> 3 (floor((8-3)/2) + 1 = 3)
input 8, kernel 3, stride 3 -> 2 (floor((8-3)/3) + 1 = 2)
The floor is where integer geometry enters, and it is not a rounding convenience. The kernel either fits at a position or it does not; a partial placement is not a placement.
Dilation spaces the kernel’s sampled positions apart. A 3Γ3 kernel with dilation=2 still has nine weights, but they are read from a 5Γ5 footprint. The quantity that matters is the effective kernel size:
effective = d * (k - 1) + 1
dilation 1: effective kernel 3, input 8 -> 6 (8 - 3 + 1 = 6)
dilation 2: effective kernel 5, input 8 -> 4 (8 - 5 + 1 = 4)
dilation 3: effective kernel 7, input 8 -> 2 (8 - 7 + 1 = 2)
Substituting the effective size for k in the stride version gives the general rule, per axis:
H_out = floor( (H_in + 2*P_h - D_h*(K_h - 1) - 1) / S_h + 1 )
W_out = floor( (W_in + 2*P_w - D_w*(K_w - 1) - 1) / S_w + 1 )
The - 1 ... + 1 pair is the same off-by-one that turned n - k into n - k + 1 above, rearranged so the floor applies after the division.
The two axes are independent. Every one of those parameters can be a tuple, in which case height and width follow different rules entirely β as the layer earlier in this chapter does:
Conv2d(16, 32, kernel_size=(5,3), stride=(2,1), padding=(2,1), dilation=(1,2))
input [8, 16, 100, 200]
derived [8, 32, 50, 198] observed (8, 32, 50, 198) match=True
Written once, in code:
import math
def conv_out(size, k, s=1, p=0, d=1):
return math.floor((size + 2*p - d*(k - 1) - 1) / s + 1)
This is worth verifying rather than trusting. Sweeping input sizes 4 through 33, kernels {1,2,3,5,7}, strides {1,2,3}, paddings {0,1,2,3} and dilations {1,2,3}, skipping combinations where the padded input is smaller than the effective kernel:
combinations tested: 5055, mismatches: 0
The table worth keeping
input kernel stride padding derived observed
8 3 1 0 6 6
8 3 1 1 8 8
8 3 2 1 4 4
7 3 2 1 4 4
8 5 1 0 4 4
8 5 1 2 8 8
8 2 2 0 4 4
31 2 2 0 15 15
31 3 2 1 16 16
Rows three and four are the ones to sit with. Inputs of 8 and 7 both produce 4. The floor absorbed the difference, which means output size does not uniquely determine input size, and it means an off-by-one upstream can vanish at one layer and reappear at another.
padding=1 does not preserve size
It preserves size under one specific combination, and the folklore drops the conditions:
kernel=3 stride=1 padding=1: 32 -> 32 preserved=True
kernel=5 stride=1 padding=1: 32 -> 30 preserved=False
kernel=3 stride=2 padding=1: 32 -> 16 preserved=False
kernel=5 stride=1 padding=2: 32 -> 32 preserved=True
kernel=7 stride=1 padding=3: 32 -> 32 preserved=True
The mechanism is visible in the formula. With stride=1 and dilation=1, the output is n + 2p - k + 1, which equals n exactly when 2p = k - 1. That is the familiar odd-kernel rule behind 3βpadding 1, 5βpadding 2, and 7βpadding 3.
Once stride is greater than 1, that half-kernel rule no longer preserves the input size. Other padding values can produce the same numerical output size for a particular input, but that is a different relation rather than the usual “same convolution” recipe.
padding="same"
PyTorch 2.13 supports padding="same" when stride=1, choosing padding so the spatial output size matches the input size. It also handles cases such as even kernels and dilation; for some even-kernel/odd-dilation combinations PyTorch warns that an explicit padded copy may be required internally.
It does not support padding="same" with strided convolution. Use "same" when preserving spatial size is the contract you actually want, but do not use it as a substitute for understanding the geometry.
Pooling is the same question with fewer parameters
Pooling is another sliding-window spatial transformation. MaxPool2d uses the same output-size geometry as the convolution formula, including dilation; AvgPool2d uses the corresponding kernel/stride/padding geometry without a dilation argument. Neither changes the channel count.
(8, 32, 64, 64) -> (8, 32, 32, 32)
(8, 3, 31, 31) -> (8, 3, 15, 15)
(2, 128, 7, 7) -> (2, 128, 3, 3)
There is one API trap worth internalizing, because it is a genuine difference between two layers that otherwise look alike:
MaxPool2d(2) (8, 32, 64, 64) -> (8, 32, 32, 32)
MaxPool2d(2, stride=1) (8, 32, 64, 64) -> (8, 32, 63, 63)
nn.Conv2d(3,3,2).stride = (1, 1)
nn.MaxPool2d(2).stride = 2
Conv2d defaults to stride=1. MaxPool2d defaults its stride to kernel_size. So MaxPool2d(2) halves the spatial dimensions and Conv2d(..., 2) does not, and the reason is a default rather than anything conceptual.
Odd sizes, and where the floor becomes physical
With the default stride=kernel_size, MaxPool2d(2) makes the integer rule visible:
The technique: derive, trace, compare
Every chapter of this book has added an investigation method. Chapter 2 asked for the first wrong tensor rather than the first illegal one. Chapter 3 asked where the gradient path first stops existing. Chapter 5 asked which of four structures disagreed about ownership. Chapter 6 asked where useful work stopped flowing. Chapter 7 asked where the sample stopped satisfying its contract.
Chapter 8 adds:
Derive the shape before the layer runs. Compare it with the observed shape. The first disagreement is the place to investigate.
The order is the entire point. Printing shapes after a failure tells you what happened. Writing them down first and then running tells you where your model of the network was wrong, which is a different and much more useful piece of information.
Here is a small CNN and the prediction for a batch of 8 images at 64Γ64, derived using nothing but the two questions from earlier β declared channels, derived geometry:
class SmallCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
self.relu1 = nn.ReLU()
self.pool1 = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
self.relu2 = nn.ReLU()
self.pool2 = nn.MaxPool2d(2)
self.gap = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(32, num_classes)
def forward(self, x):
x = self.pool1(self.relu1(self.conv1(x)))
x = self.pool2(self.relu2(self.conv2(x)))
x = self.gap(x)
return self.fc(torch.flatten(x, 1))
expected = [(8,16,64,64), (8,16,64,64), (8,16,32,32),
(8,32,32,32), (8,32,32,32), (8,32,16,16),
(8,32,1,1), (8,10)]
Then run it and compare.
stage op observed in expected out observed out
conv1 Conv2d (8, 3, 64, 64) (8, 16, 64, 64) (8, 16, 64, 64)
relu1 ReLU (8, 16, 64, 64) (8, 16, 64, 64) (8, 16, 64, 64)
pool1 MaxPool2d (8, 16, 64, 64) (8, 16, 32, 32) (8, 16, 32, 32)
conv2 Conv2d (8, 16, 32, 32) (8, 32, 32, 32) (8, 32, 32, 32)
relu2 ReLU (8, 32, 32, 32) (8, 32, 32, 32) (8, 32, 32, 32)
pool2 MaxPool2d (8, 32, 32, 32) (8, 32, 16, 16) (8, 32, 16, 16)
gap AdaptiveAvgPool2d (8, 32, 16, 16) (8, 32, 1, 1) (8, 32, 1, 1)
fc Linear (8, 32) (8, 10) (8, 10)
first divergence: none
parameters: 5,418
The tracer
Producing that table takes about twenty lines and no changes to the model. register_forward_hook runs a callback after a module’s forward returns, receiving the module, its inputs and its output.
from contextlib import contextmanager
@contextmanager
def trace_shapes(model):
"""Record the output shape of every leaf module during one forward pass."""
rows, handles = [], []
def make_hook(name):
def hook(module, inputs, output):
ins = [tuple(t.shape) for t in inputs if torch.is_tensor(t)]
out = tuple(output.shape) if torch.is_tensor(output) else type(output).__name__
rows.append((name, type(module).__name__, ins[0] if ins else None, out))
return hook
for name, module in model.named_modules():
if name and not list(module.children()): # leaves only
handles.append(module.register_forward_hook(make_hook(name)))
try:
yield rows
finally:
for h in handles:
h.remove()
def report(rows, expected):
first = None
for i, (name, kind, oin, oout) in enumerate(rows):
exp = tuple(expected[i]) if i < len(expected) else None
agree = exp is None or exp == tuple(oout)
if not agree and first is None:
first, mark = name, " <-- FIRST DIVERGENCE"
elif not agree:
mark = " (downstream)"
else:
mark = ""
print(f" {name:<8}{kind:<12}{str(exp):<20}{str(oout):<20}{mark}")
print(" first divergence:", first or "none")
Three details are load-bearing. The named_modules() walk is Chapter 5’s registered module tree β the tracer sees exactly what PyTorch registered, which is why an unregistered layer stored in a plain Python list would be invisible here too. Filtering to leaves avoids printing a container’s output alongside its last child’s. And the finally block matters: hooks left attached to a model outlive the debugging session and will quietly print during training.
The important limitation: hooks observe module boundaries. A torch.flatten call or an F.relu in forward is not a module and will not appear. If your forward does geometry with functional calls, either instrument those lines directly or use nn.Flatten and nn.ReLU modules so the tracer can see them.
What it looks like when something is wrong
The SmallCNN above uses adaptive pooling, so removing conv2 padding would change an intermediate shape but would not produce a Linear mismatch: AdaptiveAvgPool2d((1, 1)) would still hand [B, 32] to fc. To expose the classic downstream symptom, use a fixed-spatial-head variant instead: remove gap, flatten after pool2, set fc = nn.Linear(32 * 16 * 16, 10), then remove conv2 padding while keeping the original shape prediction:
RuntimeError: mat1 and mat2 shapes cannot be multiplied (8x7200 and 8192x10)
stage op expected out observed out
conv1 Conv2d (8, 16, 64, 64) (8, 16, 64, 64)
pool1 MaxPool2d (8, 16, 32, 32) (8, 16, 32, 32)
conv2 Conv2d (8, 32, 32, 32) (8, 32, 30, 30) <-- FIRST DIVERGENCE
pool2 MaxPool2d (8, 32, 16, 16) (8, 32, 15, 15) (downstream)
first divergence: conv2
The exception names the Linear layer. The trace names conv2. The gap between those two answers is this chapter.
Notice also that the trace has rows after the failure point, because hooks fire as each module completes and the exception came later. The last row is pool2, not fc β the run died inside fc, before its hook could fire. A partial trace is still evidence.
The flatten boundary
At some point most classifiers stop treating the tensor as a grid and start treating it as a feature vector. That transition deserves attention because it changes what the axes mean.
before (8, 32, 16, 16)
flatten(x, 1) (8, 8192) 32*16*16 = 8192
flatten(x) (65536,) 8*32*16*16 = 65536
x.mean((2,3)) (8, 32)
The 1 in torch.flatten(x, 1) is the start dimension: everything from axis 1 onward is collapsed into one axis, and axis 0 is left alone. Drop it and the batch axis is collapsed too, producing a single 65,536-element vector in which all eight examples have been concatenated. That tensor is not eight examples any more, and if it happens to reach a Linear layer whose in_features matches, it will produce one row of logits for a batch of eight images without complaining.
So the question at this boundary is always the same:
Where did 8192 come from?
It came from 32 * 16 * 16, which came from a channel count you declared and a spatial size you derived. Three separate decisions, multiplied into one number, which is then hard-coded into the next layer:
nn.Linear(32 * 16 * 16, 10)
Written that way rather than as nn.Linear(8192, 10), at least the factors are visible. That is a real improvement in readability and it is not a solution β the number is still a claim about the geometry above it, and nothing checks the claim until the multiplication fails.
Two related mechanics belong to Chapter 2 rather than here, so this is a pointer rather than a re-teaching. flatten and reshape will handle a non-contiguous tensor; view will not, and after a permute you may see view size is not compatible with input tensor's size and stride. The fix is reshape, or contiguous().view(...), and the diagnosis is the stride inspection Chapter 2 covered.
The Linear error whose cause is upstream
Here is the failure in its ordinary form. A network is written for 64Γ64 inputs, someone adds a stride to the second convolution, and this appears:
RuntimeError: mat1 and mat2 shapes cannot be multiplied (8x2048 and 8192x10)
The error is a fact about a matrix multiplication: a [8, 2048] activation met a [8192, 10] weight. The suggested repair writes itself, and an assistant will suggest it in one line:
nn.Linear(2048, 10) # was 32 * 16 * 16
That repair may be correct. It is correct exactly when the geometry change that produced 2048 was intended. If the stride was deliberate β a downsampling decision made on purpose β then 2048 is the right feature count and the classifier should be updated to match.
If the stride was not deliberate, then changing in_features takes an accidental architectural change and makes it permanent, in the one place where nobody will ever look for it. The model will train. Nothing will raise. The architecture is now something no one chose.
Which is why the error message answers a different question than the one you have:
A dimension error tells you which contract failed. It does not tell you which side of the contract is wrong.
The investigation, in order:
1. derive the expected shape after every spatial layer
2. trace the actual shapes
3. find the FIRST divergence
4. decide whether that divergence was intended
5. only then update anything downstream
Step 4 is a decision about the architecture, and it is the only step that cannot be automated.
A worked sequential diagnosis
Here is a network with several faults at once, which is the realistic case. It arrives with a batch from a pipeline that produced [B, H, W, C].
class BrokenCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
self.pool1 = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(32, 32, 3)
self.pool2 = nn.MaxPool2d(2)
self.fc = nn.Linear(32 * 16 * 16, num_classes)
def forward(self, x):
x = self.pool1(F.relu(self.conv1(x)))
x = self.pool2(F.relu(self.conv2(x)))
return self.fc(torch.flatten(x, 1))
The intended architecture, written down before touching anything, for a batch of 8 at 64Γ64:
input [8, 3, 64, 64]
conv1 [8, 16, 64, 64] 16 channels, padding=1 preserves 64
pool1 [8, 16, 32, 32] halved
conv2 [8, 32, 32, 32] 32 channels, spatial preserved
pool2 [8, 32, 16, 16] halved
flatten [8, 8192] 32 * 16 * 16
fc [8, 10]
Run 1.
RuntimeError: Given groups=1, weight of size [16, 3, 3, 3],
expected input[8, 64, 64, 3] to have 3 channels, but got 64 channels instead
The received shape is printed right there: [8, 64, 64, 3]. Four axes, and the only one that could plausibly be a channel count is the last. This is the opening failure, so the repair is at the input boundary, not in the layer:
x = batch.permute(0, 3, 1, 2).contiguous()
Fix only that. Rerun.
Run 2.
RuntimeError: Given groups=1, weight of size [32, 32, 3, 3],
expected input[8, 16, 32, 32] to have 32 channels, but got 16 channels instead
A different message with the same shape. Now the received tensor is genuinely [B, C, H, W] β 16 channels on a 32Γ32 grid, which is exactly what pool1 was predicted to produce. So the tensor is right and the layer is wrong: conv1 produces 16 channels and conv2 was built for 32. This is the compositional contract:
conv1.out_channels == conv2.in_channels
unless something between them deliberately changes the channel count. Nothing does. conv2 is stale β built when the first layer produced 32 channels, and never updated.
self.conv2 = nn.Conv2d(16, 32, 3)
Fix only that. Rerun.
Run 3.
RuntimeError: mat1 and mat2 shapes cannot be multiplied (8x7200 and 8192x10)
The decoy is nn.Linear(7200, 10). Derive instead:
conv1: H -> 64
pool1: H -> 32
conv2: H -> 30 <-- expected 32
pool2: H -> 15
conv2 has no padding. A 3Γ3 kernel with padding=0 on a 32Γ32 input gives 30, and 7200 = 32 * 15 * 15. The intended architecture wanted conv2 to preserve spatial size, which is padding=1. The Linear layer was never wrong; it was the only layer honest enough to complain.
self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
Run 4.
stage op expected out observed out
conv1 Conv2d (8, 16, 64, 64) (8, 16, 64, 64)
pool1 MaxPool2d (8, 16, 32, 32) (8, 16, 32, 32)
conv2 Conv2d (8, 32, 32, 32) (8, 32, 32, 32)
pool2 MaxPool2d (8, 32, 16, 16) (8, 32, 16, 16)
fc Linear (8, 10) (8, 10)
first divergence: none
Three faults, three runs, one repair each, and at no point did anyone guess. Each error message named a real contract violation and none of them named its cause. The discipline is to fix one thing, rerun, and let the next divergence present itself, because a repair made while three problems are in flight is a repair made without evidence.
Adaptive pooling declares an output contract
The reason Linear(32 * 16 * 16, 10) is fragile is that it encodes an intermediate spatial size into the classifier. Change the input resolution, add a stride, insert a pool, and the number is wrong.
AdaptiveAvgPool2d inverts the relationship. Instead of computing an output size from the input size, you declare the output size and the layer arranges the pooling regions to produce it:
self.gap = nn.AdaptiveAvgPool2d((1, 1))
input (32, 32) features (2, 32, 8, 8) gap (2, 32, 1, 1) flatten (2, 32)
input (64, 64) features (2, 32, 16, 16) gap (2, 32, 1, 1) flatten (2, 32)
input (31, 31) features (2, 32, 7, 7) gap (2, 32, 1, 1) flatten (2, 32)
input (97, 53) features (2, 32, 24, 13) gap (2, 32, 1, 1) flatten (2, 32)
input (224, 224) features (2, 32, 56, 56) gap (2, 32, 1, 1) flatten (2, 32)
The convolutional body produces different spatial feature maps, while the adaptive layer gives the classifier the same [B, 32, 1, 1] contract every time. For (1, 1), adaptive average pooling is equivalent to averaging over both spatial axes.
That removes one dependency; it does not make the body geometry-free. The body must still be able to produce a feature map at all, and accepting a new resolution does not prove the model will behave well at that resolution.
AdaptiveAvgPool2d((1, 1)) also discards spatial position by averaging each channel over the grid. That can be appropriate for classification and inappropriate when position is part of the target. Understand the fixed C*H*W dependency first; then adaptive pooling becomes a deliberate contract rather than a way to avoid deriving shapes.
What the parameter count reveals
A convolution’s parameter count depends on channel and kernel geometry, not on input height or width:
Receptive field is accumulated geometry
Shape tells us how large the feature map is. Receptive field asks how much of the original input can influence one position in that map.
For plain stride-1, dilation-1 stacks of 3Γ3 convolutions, the structural extent grows 3 β 5 β 7 β 9 as layers are added. Stride and dilation can make it grow faster.
A gradient probe can make active numerical dependencies visible for a chosen model and input, but a zero gradient does not by itself prove that a structural dependency is impossible: weights, nonlinearities and cancellations can also produce zeros.
Local operations compose into larger spatial dependencies.
Shape composes; shape does not mean
Chapter 7’s warning still applies inside the model: a correct shape ledger proves that dimensions compose, not that the axes mean what you intended.
For a non-square input, swapping height and width can preserve the flattened feature count because C * H * W == C * W * H. Every layer can run and the classifier can accept the result while the image geometry is wrong.
| A verified shape ledger supports | It does not establish |
|---|---|
| layer dimensions compose | axis semantics are correct |
| channel contracts match | the architecture suits the task |
| derived and observed geometry agree | preprocessing preserved meaning |
in_features matches flatten |
predictions are correct |
Use the ledger to diagnose geometry. Use the known-sample and semantic checks from Chapter 7 to diagnose meaning.
Reading dimension errors structurally
Treat a dimension error as a violated contract, not an instruction:
| Symptom | First questions |
|---|---|
expected ... 3 channels, got 224 |
What shape arrived? In a batched tensor, what does axis 1 mean? Is 224 actually height or width? |
expected ... 32 channels, got 64 |
Which layer produced the input? Does its out_channels match this layer’s in_channels? |
mat1 and mat2 shapes cannot be multiplied |
What C*H*W reached flatten? Which earlier geometry change produced it? Was that change intended? |
For the batched [B,C,H,W] tensors used throughout this chapter, Conv2d reads channels from axis 1. PyTorch also accepts unbatched [C,H,W] input, where the channel axis is 0.
The error identifies the place where a contract became illegal. When the upstream mistake was itself legal, the root cause can be several layers earlier.
Which operations can even be responsible
A useful narrowing move, because it eliminates most of the network immediately:
| Operation | Channels | Spatial | Feature dim |
|---|---|---|---|
Conv2d |
C_in β C_out |
may change | β |
MaxPool2d / AvgPool2d |
unchanged | may change | β |
AdaptiveAvgPool2d |
unchanged | set to declared size | β |
ReLU, Sigmoid, Dropout, BatchNorm2d |
unchanged | unchanged | β |
flatten(x, 1) |
β | β | C*H*W |
Linear |
β | β | in_features β out_features |
Verified:
ReLU (4, 16, 31, 31) -> (4, 16, 31, 31)
BatchNorm2d (4, 16, 31, 31) -> (4, 16, 31, 31)
Dropout (4, 16, 31, 31) -> (4, 16, 31, 31)
Sigmoid (4, 16, 31, 31) -> (4, 16, 31, 31)
Conv2d(16,16,1) (4, 16, 31, 31) -> (4, 16, 31, 31)
Conv2d(16,32,3) (4, 16, 31, 31) -> (4, 32, 29, 29)
MaxPool2d(2) (4, 16, 31, 31) -> (4, 16, 15, 15)
If H was 16 and became 15, the activation functions did not do it. Only a Conv2d, a pooling layer, or something that changed the input resolution can be responsible, and that is usually a list of two or three candidates rather than twenty.
BatchNorm2d is on this table for exactly one reason: it appears in almost every real CNN and it is geometrically inert. Its statistical behavior β running statistics, train versus eval, what happens with a batch of size 1 β is genuinely interesting and belongs with the training-behavior material, not here. For shape reasoning it is a no-op, and treating it as one keeps this chapter’s scope intact.
Reading an architecture without running it
The end state is being able to look at four lines and answer the question before executing anything.
self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
self.pool = nn.MaxPool2d(2)
self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
self.fc = nn.Linear(32 * 8 * 8, 10)
input 32x32: input 32 -> conv1 32 -> pool 16 -> conv2 16 -> pool 8
flatten 2048 fc wants 2048 composes=True
input 64x64: input 64 -> conv1 64 -> pool 32 -> conv2 32 -> pool 16
flatten 8192 fc wants 2048 composes=False
The model is not broken. What the classifier really declares is an internal contract: the convolutional body must produce an 8Γ8 spatial grid before flattening. A 32Γ32 input is one way to satisfy that contract, but floor operations can map several nearby input sizes to the same 8Γ8 result. Read the 8 * 8 backward to recover the required internal geometry, then test which input sizes actually satisfy it.
Writing the contract down
Chapter 7 turned preprocessing expectations into assertions. The same move is useful at component boundaries:
Using AI on CNN geometry
A stack trace makes locally plausible repairs easy to propose:
The CNN geometry debugging sequence
Exercises
-
The opening failure, both repairs. Trigger the NHWC error. Repair it once by changing
in_channelsand once by permuting the tensor. Compare output shape, weight shape and parameter count. -
One convolution by hand. Compute a 2Γ2 kernel over a 4Γ4 input, then repeat with three input channels and verify both against
F.conv2d. -
Derive and verify. Implement
conv_out, sweep kernel/stride/padding/dilation combinations, and assert the predicted spatial sizes match PyTorch. -
Predict, then trace. Choose a CNN you did not write. Record every expected shape before execution, attach the tracer, and identify the first disagreement.
-
Three faults, three runs. Reproduce the
BrokenCNNsequence: NHWC input, stalein_channels, missing padding. Fix exactly one divergence per run. -
Adaptive pooling. Run one convolutional body at several input resolutions with and without
AdaptiveAvgPool2d. Record what dependency the adaptive layer removes and what it does not. -
The ledger that proves nothing. Use a non-square input, swap height and width, and build a case where every shape still composes. Add a semantic check that catches the mistake.
Next: features without a grid
The model boundary is no longer opaque. A convolution performs two independent transformations β a declared change of channels and a derived change of spatial geometry β and both can be written down before the layer runs. Pooling does the second without the first. Flatten converts a grid into a feature vector whose length is C * H * W. Linear maps one feature dimension to another. Every one of those is mechanical, and when the observed trace disagrees with the derivation, the first disagreement is the diagnosis.
The two failures at the center of this chapter are worth carrying as a pair. The NHWC batch produced an error naming the wrong problem, and repairing the layer instead of the tensor would have built a model out of a misreading. The missing padding produced an error several layers downstream, and repairing the layer that raised would have made an unintended architecture permanent. Both are the same shape of mistake:
A CNN is a sequence of tensor-geometry contracts. Write down the expected
[B, C, H, W]after every shape-changing operation, run the model, and investigate the first place where reality disagrees with the derivation. The error message tells you which contract failed. It does not tell you which side of it is wrong.
Notice where the chapter ended up. After the convolutional body and the pooling and the flatten, the tensor is:
[B, 32]
The spatial grid is gone. What remains is one vector per example, and its 32 entries are learned feature coordinates with no adjacency, no ordering and no picture attached. That vector is what the classifier actually sees, and nn.Linear(32, num_classes) treats it as a point in a 32-dimensional space and asks which side of a boundary it falls on.
Thirty-two is already more dimensions than anyone can draw. Real feature vectors are 128, 512, 768, 4096. The next chapter takes that seriously and asks what a coordinate, a direction and a boundary actually mean in a space we cannot picture β and builds a classifier from scratch to show that the geometry keeps working long after the intuition stops.
Into the feature space.