CNN Geometry: What Shape Reaches the Next Layer?

Explain this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

Apply this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

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_out is declared by the layer, while the incoming tensor must satisfy the layer’s C_in contract;
  • H_out and W_out are derived from H_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

  1. The opening failure, both repairs. Trigger the NHWC error. Repair it once by changing in_channels and once by permuting the tensor. Compare output shape, weight shape and parameter count.

  2. 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.

  3. Derive and verify. Implement conv_out, sweep kernel/stride/padding/dilation combinations, and assert the predicted spatial sizes match PyTorch.

  4. Predict, then trace. Choose a CNN you did not write. Record every expected shape before execution, attach the tracer, and identify the first disagreement.

  5. Three faults, three runs. Reproduce the BrokenCNN sequence: NHWC input, stale in_channels, missing padding. Fix exactly one divergence per run.

  6. 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.

  7. 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.