Convolutional Neural Networks¶
What This Is¶
Convolutional neural networks detect spatial patterns — edges, textures, shapes — by sliding small learned filters across the input. Unlike fully connected layers, convolutions share weights across positions, which makes them efficient and translation-equivariant.
The core insight is that local patterns matter more than global position. A vertical edge is a vertical edge whether it appears on the left or the right of the image. That single insight drives every architecture choice on this page: stacked small filters, hierarchical pooling, residual connections, depthwise separables.
When You Use It¶
- classifying or analyzing images
- extracting features from grid-structured data (spectrograms, dense 2D maps)
- building a backbone for detection, segmentation, or generation
- when the input has spatial structure that a fully connected layer would ignore
- when you need a model that can run under 10 ms on a CPU / mobile device — see the depthwise-separable section below
Do Not Use It When¶
- the input is truly one-dimensional and long-range (use attention instead)
- you need a model that is permutation-invariant on the input (a graph, a set) — see Graph Neural Networks
- a pretrained transformer already beats your hand-rolled CNN on the benchmark — use the pretrained model
Architecture Building Blocks¶
| Layer | What It Does | Key Parameters |
|---|---|---|
nn.Conv2d |
applies learned filters to extract spatial features | in_channels, out_channels, kernel_size, stride, padding, groups |
nn.MaxPool2d |
downsamples by taking the maximum in each window | kernel_size, stride |
nn.AvgPool2d |
downsamples by averaging | same |
nn.AdaptiveAvgPool2d |
pools to a fixed output size regardless of input size | output_size |
nn.BatchNorm2d |
normalizes per channel for stable training | num_features |
nn.Dropout2d |
drops entire channels to regularize | p |
nn.Flatten |
reshapes spatial output into a vector for the classifier head | — |
How Convolution Works¶
A 3×3 filter slides across the input, computing a dot product at each position:
Input (5×5) Filter (3×3) Output (3×3)
┌─────────────┐ ┌─────────┐ ┌─────────┐
│ . . . . . │ │ w w w │ │ o o o │
│ . . . . . │ × │ w w w │ = │ o o o │
│ . . . . . │ │ w w w │ │ o o o │
│ . . . . . │ └─────────┘ └─────────┘
│ . . . . . │
└─────────────┘
- stride controls how far the filter moves each step (stride=2 halves the spatial size)
- padding adds zeros around the input to control the output size;
padding="same"keeps the dimensions unchanged - groups enables grouped or depthwise convolutions (see below)
Minimal Architecture¶
import torch.nn as nn
model = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1), # 3 channels in, 32 out
nn.BatchNorm2d(32),
nn.ReLU(),
nn.MaxPool2d(2), # halve spatial size
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(2),
nn.AdaptiveAvgPool2d(1), # global average pool
nn.Flatten(),
nn.Linear(64, 10), # 10-class output
)
Receptive Field — The Single Most Useful Inspection¶
The receptive field of a unit is the region of input pixels that contributes to its value. Each layer expands it. For a stack of 3×3 convolutions (stride 1, padding 1), the receptive field grows additively by 2 per layer; with stride-2 steps or pooling, it grows multiplicatively.
Quick recipe for rectangular, non-dilated convs:
def receptive_field(layers):
"""layers: list of (kernel_size, stride) tuples from input toward output"""
rf = 1
jump = 1
for k, s in layers:
rf = rf + (k - 1) * jump
jump = jump * s
return rf
# example: three 3×3 convs + a stride-2 pool + two more 3×3 convs
layers = [(3,1), (3,1), (3,1), (2,2), (3,1), (3,1)]
print(receptive_field(layers)) # -> 16 pixels on a side
Why this matters: if your network's receptive field is smaller than the object you are trying to recognize, no amount of training will make it work. If you are classifying objects that can span 100 pixels, you need a receptive field that comfortably exceeds 100. The fix is either more layers, a larger kernel, dilation, or stride.
Small Filters Stacked Deep — Why¶
Stacking 3×3 convolutions grows the receptive field:
- 1 layer: 3×3
- 2 layers: 5×5
- 3 layers: 7×7
With fewer parameters than a single 7×7 filter (3·(9 C²) vs. 49 C²) and more nonlinearities between them. This is the design rule behind VGG and everything after: prefer small, stacked filters over single large ones.
Depthwise-Separable Convolutions¶
A standard conv learns out_channels · in_channels · k · k parameters. A depthwise-separable conv factors that into two cheap steps:
- depthwise — one filter per input channel (
groups=in_channels) - pointwise (1×1 conv) — combine channels
Parameter cost drops from C_out · C_in · k² to C_in · k² + C_in · C_out. For a 3×3 conv with 256 in / 256 out, that is 589k params → 2.3k + 65.5k ≈ 68k — nearly a 9× reduction.
class DepthwiseSeparable(nn.Module):
def __init__(self, c_in, c_out, k=3, stride=1):
super().__init__()
self.dw = nn.Conv2d(c_in, c_in, k, stride=stride,
padding=k//2, groups=c_in, bias=False)
self.pw = nn.Conv2d(c_in, c_out, 1, bias=False)
self.bn = nn.BatchNorm2d(c_out)
def forward(self, x):
return F.relu(self.bn(self.pw(self.dw(x))))
Depthwise-separables are the workhorse of MobileNet, EfficientNet, and most mobile/edge CNNs. The quality gap to full conv is small; the speed gap is large.
Residual Connections — Why Depth Works¶
Naïve deep CNNs (VGG-19 and deeper) degrade on training set as they grow deeper — not overfitting, optimization difficulty. Residual connections fix this by adding the input to the output of a block:
y = F(x) + x
At initialization, F(x) ≈ 0, so y ≈ x — gradients flow through the identity. The network only has to learn the residual correction. This is the ResNet insight, and it made >100-layer networks trainable.
class ResidualBlock(nn.Module):
def __init__(self, c, k=3):
super().__init__()
self.conv1 = nn.Conv2d(c, c, k, padding=k//2, bias=False)
self.bn1 = nn.BatchNorm2d(c)
self.conv2 = nn.Conv2d(c, c, k, padding=k//2, bias=False)
self.bn2 = nn.BatchNorm2d(c)
def forward(self, x):
h = F.relu(self.bn1(self.conv1(x)))
h = self.bn2(self.conv2(h))
return F.relu(h + x)
When channels or spatial size change (stride>1), the shortcut becomes a 1×1 projection so the shapes match. Modern variants (pre-activation, bottleneck) tweak the block structure but keep the identity skip.
Modern Block Trade-offs¶
| Block | When it wins | Trade |
|---|---|---|
| plain conv + BN + ReLU (VGG-style) | shallow networks, maximum interpretability | does not scale past ~20 layers |
| residual block (ResNet) | depth up to 152, strong general baseline | slightly more compute per parameter |
| bottleneck residual (ResNet-50+) | very deep networks, fewer params | 1×1 → 3×3 → 1×1 complexity |
| depthwise-separable (MobileNet) | mobile, edge, tight latency | small quality drop vs. full conv |
| inverted residual (MobileNetV2) | mobile detection/classification | relies on narrow bottlenecks |
| SE block (Squeeze-and-Excitation) | adds channel attention to any backbone | ~1% param cost, small but real win |
| ConvNeXt block | modern CNN competitive with ViT | depthwise + LN + GELU pattern |
Design Ladder¶
- Start small — 2–3 conv blocks with batch norm and pooling
- Overfit one batch — if the network cannot drive training loss to near-zero on 8 images, the architecture is broken
- Add depth gradually — more layers, more filters
- Switch to a pretrained backbone (ResNet, EfficientNet, ConvNeXt) before designing from scratch
- Fine-tune selectively — see Transfer and Fine-Tuning
Common Architectures¶
| Architecture | Depth | Key Idea |
|---|---|---|
| LeNet | shallow | the original: conv → pool → conv → pool → FC |
| AlexNet | 8 layers | GPU training at scale, ReLU, dropout |
| VGG | 16–19 layers | stack 3×3 convs, double channels |
| ResNet | 18–152 layers | skip connections |
| MobileNet / EfficientNet | scalable | depthwise-separables, compound scaling |
| ConvNeXt | 22–51 | modernized CNN with transformer-era patterns |
What To Inspect¶
- receptive field vs. object size — the calculator above
- training loss on a single batch — must drop to near-zero on a 4-class, 8-image overfit test
- validation loss trajectory — should mirror training after a short lag; a flat validation loss signals the model is stuck, not slow
- feature maps at each stage — are the early layers detecting edges? Are the later layers detecting class-relevant structure?
- per-class confusion matrix — where does the CNN actually err?
- gradient norms per layer — very deep networks without residuals show shrinking gradients in early layers
Failure Pattern¶
Building a CNN with no pooling or stride, keeping the spatial size constant throughout. The model becomes extremely slow and memory-hungry without learning hierarchical features.
A second failure: flattening too early. If you flatten a 14×14×256 feature map directly into a linear head, the classifier has 50k weights per output class and overfits immediately. Use AdaptiveAvgPool2d(1) before flattening.
A third failure: stacking more than ~20 plain conv layers without residuals. Training loss stops falling; the fix is residual connections, not more tuning.
Common Mistakes¶
- forgetting
padding=1with 3×3 kernels — the feature map shrinks by 2 per layer - using several fully connected layers after convolutions instead of global average pooling
- skipping batch normalization — deep CNNs train much harder without it
- applying the model to inputs of the wrong spatial size without
AdaptiveAvgPool2d - designing from scratch when a pretrained backbone would beat it in a day
- ignoring the receptive field and wondering why small objects are missed
- using large kernels (7×7) when stacked 3×3s would do the same job with fewer parameters
Decision: Which Backbone¶
| Need | Start with | Why |
|---|---|---|
| classification from scratch, small data | ResNet-18 pretrained | the universal strong baseline |
| classification from scratch, plenty of data + compute | ConvNeXt / EfficientNet-B3+ | better accuracy per FLOP |
| mobile / edge inference | MobileNetV3 / EfficientNet-Lite | depthwise-separables pay off |
| very long range spatial patterns | ViT or ConvNeXt | attention-like global mixing |
| dense prediction (segmentation, detection) | ResNet + FPN or Swin | feature-pyramid-friendly |
Practice¶
- Build a 3-layer CNN and overfit one batch of 8 CIFAR images to confirm the architecture is wired correctly.
- Compute the receptive field of your network. Compare it to the smallest object you must recognize. Adjust if the field is smaller.
- Replace
MaxPool2dwith a strided 3×3 convolution. Compare accuracy and training speed. - Remove batch normalization. Re-train and measure convergence speed; explain the gap.
- Swap one plain conv block for a depthwise-separable block. Compare parameter count and accuracy.
- Stack 40 plain conv layers and train on CIFAR-10. Observe the training loss. Convert the same network to residual blocks and re-run.
- Fine-tune a pretrained ResNet-18 on the same dataset. Compare to your hand-built CNN; explain the result.
Runnable Example¶
Longer Connection¶
CNNs sit next to:
- Transfer and Fine-Tuning — for using a pretrained CNN backbone instead of training from scratch
- Vision Augmentation and Shift Robustness — the augmentation recipes that make CNNs survive deployment
- Batch Normalization and Initialization — the normalization layer that makes deep CNNs trainable
- Attention and Transformers — the architecture that ate CNNs' lunch on large-data regimes; see ViT for the crossover
- Object Detection Basics — where CNN backbones feed into detection heads
- Detection and Segmentation — the pixel-level dense-prediction cousin
A CNN is a tool for hierarchical spatial pattern extraction. The decisions — kernel size, stride, depth, residuals, separables — each have a specific job. The moment the student cannot name the job a block is doing, the architecture has become folklore and should be simplified.