What it actually does
Slide a small learned filter across the input and record how strongly it responds at each position. Two assumptions are baked into that operation and they are the whole reason it works: nearby inputs are related (locality), and a feature worth detecting in one place is worth detecting everywhere (translation equivariance, via weight sharing). Stack these layers and the receptive field widens, so early layers see edges and later ones see objects.
- The input has a spatial or temporal grid structure where nearby elements are related
- A feature worth detecting is worth detecting anywhere in the input — translation equivariance holds
- Enough labelled data exists, or a pretrained backbone in a compatible domain does
- Deployment inputs come from the same capture pipeline as training inputs
Loss function
The loss is ordinary — cross-entropy or squared error, nothing architecture-specific. What is specific is the parameterization: the same kernel weights are reused at every spatial position, so a layer has O(k²·C) parameters rather than O(H·W·C) as a dense layer would. That reduction is why a CNN needs orders of magnitude less data than an unconstrained network to reach the same accuracy on images.
- the kernel — a small weight patch shared across every position
- offsets within the kernel window
- the task loss — cross-entropy for classification, IoU-based for detection
- the stacked convolution/nonlinearity/pooling composition
How it is minimized
Backpropagation with SGD-momentum or AdamW, plus batch normalization and augmentation
- the gradient for one kernel weight sums over EVERY position it was applied at
- a single shared kernel weight
Weight sharing shows up in the backward pass as a sum: because one kernel weight touched every spatial position, its gradient accumulates contributions from all of them. That makes gradients for convolutional layers unusually well-conditioned — they are averages over many positions — which is part of why CNNs train stably at depths that would break a dense network. Batch normalization does the rest, by keeping activation statistics stable so the effective learning rate does not drift layer to layer. Augmentation is not optional polish here: flips, crops and colour jitter are how the translation prior gets extended to scale and rotation, which convolution does not give you for free.
kernel size
Receptive field per layer; 3x3 stacked is near-universally preferred to one large kernel (3x3, occasionally 7x7 at the stem)
stride / pooling
How fast spatial resolution is traded for channel depth (stride 1-2, 2x2 pooling)
channel width
Feature capacity per layer; typically doubles as resolution halves (32 to 512)
learning rate + schedule
Cosine or step decay; warmup matters at large batch (1e-3 AdamW, 1e-1 SGD-momentum)
augmentation strength
The main regularizer, and the way non-translation invariances are taught (flip, crop, colour jitter, mixup)
Non-convex, and reliably trainable — residual connections and normalization made depth a non-issue. The characteristic failures are about data rather than optimization. Overfitting on small datasets is the common one, and the fix is almost always transfer from a pretrained backbone rather than a bigger model. The subtler failure is shortcut learning: the network latches onto a spurious correlate — a watermark, a background texture, a hospital scanner artifact — that separates the classes perfectly in training and does not exist in deployment. Aggregate accuracy hides it completely, which is why slice metrics are mandatory rather than advisable.
O(H · W · C_in · C_out · k²) per convolutional layer, forward and backward. Memory is dominated by stored activations, not weights, which is why batch size is usually what exhausts the GPU first.
How, where, and why it is used
Time-Series Forecasting
ViableApply 1-D convolutions along the time axis with causal padding, so no filter can see past the prediction point. Dilate the kernels across layers and the receptive field grows exponentially with depth, which is how a small stack covers a long history.
- Multivariate sensor forecasting where cross-channel patterns matter
- High-frequency series where an RNN's sequential recurrence is too slow to train
The genuine advantage over recurrence is parallelism: every timestep is computed simultaneously rather than one after another, so training is dramatically faster on long series. The genuine limitation is that the receptive field is fixed by architecture — a dependency longer than the dilated stack covers is simply invisible, whereas an LSTM at least has a chance of carrying it.
- Causal padding is mandatory — symmetric padding leaks the future into the present
- Scale per series before windowing so one high-volume channel does not dominate
- Choose dilation rates so the receptive field covers at least one full seasonal cycle
Rolling-origin backtesting scored with MASE against seasonal naive. Verify the receptive field actually spans the dependency you believe matters.
- Non-causal padding silently leaks future values and produces spectacular validation scores
- A receptive field shorter than the seasonal period makes the model structurally blind to seasonality
Anomaly Detection
ViableTwo routes. Convolutional autoencoders reconstruct normal inputs and flag high reconstruction error. Or a supervised detector, when defect labels exist, with heavy class weighting.
- Manufacturing surface-defect detection on production lines
- Medical imaging screening against a healthy-population baseline
- 1-D convolutional detectors over sensor windows in predictive maintenance
The convolutional prior is what makes this work on spatial data: a defect is a local pattern that can appear anywhere, which is exactly the structure weight sharing encodes. The catch is the same as every reconstruction-based detector — a sufficiently expressive network learns to reconstruct defects too, so the bottleneck has to be constrained deliberately.
- Train exclusively on confirmed-normal images, or the model learns the defect as normal
- Constrain the bottleneck — capacity IS the detector in the autoencoder route
- Score on patch-level error rather than whole-image, so a small defect is not averaged away
Precision@k against confirmed defects, with the threshold from reconstruction-error quantiles on a clean holdout. Slice by product variant and lighting condition.
- Too much capacity reconstructs defects perfectly and the detector goes blind
- Camera or lighting changes shift the input distribution and invalidate the threshold overnight
Optimization
Not applicableA CNN is a perception component: it maps a signal to a semantic reading and has no notion of a decision variable, a constraint, or a feasible region. It regularly sits upstream of an optimizer — a defect classifier feeding a routing decision — but the convolution itself optimizes nothing about the world, only its own parameters.
Computer Vision
Primary fitA pretrained backbone plus a task head: a linear layer for classification, an FPN plus bounding-box regression for detection, a decoder for segmentation. Fine-tune the whole stack or freeze the backbone depending on how much labelled data exists.
- Image classification and quality inspection
- Object detection with IoU-based box regression
- Semantic and instance segmentation
The convolutional prior matches image structure exactly, which is why CNNs need far less data than an unconstrained network for the same accuracy. Vision Transformers overtake them at very large scale — where enough data exists to learn the spatial prior rather than assume it — but at ordinary dataset sizes the built-in prior still wins, and transfer from a pretrained backbone usually removes the data problem entirely.
- Normalize with the pretrained backbone's own statistics, not your dataset's
- Augmentation supplies the invariances convolution does not give free — scale, rotation, colour
- Keep preprocessing versioned with the model; a resize-interpolation change silently degrades accuracy
Top-k for classification, mAP for detection, IoU for segmentation — always sliced by lighting, pose, and demographic. Aggregate accuracy reliably hides the failure that matters.
- Shortcut learning on a spurious correlate that will not exist in deployment
- Train/serve preprocessing mismatch, which is the most common silent accuracy loss in production vision
Natural Language
Adapted1-D convolutions over token embeddings act as learned n-gram detectors, with max-pooling selecting the strongest activation regardless of position.
- Sentence classification where local phrase patterns carry the signal
- Latency-constrained text classification where a transformer is too slow
Fast and surprisingly competitive on short-text classification, because sentiment and intent often hinge on local phrases a filter can catch. Superseded for anything needing long-range dependency or pretraining transfer, which is most modern NLP.
- Multiple kernel widths in parallel to capture different n-gram lengths
- Max-over-time pooling so position does not matter for the classification decision
Macro-F1 against a fine-tuned encoder baseline, so the accuracy-for-latency trade is explicit.
- A fixed receptive field cannot capture long-range agreement or discourse structure
- No pretraining transfer means it needs far more labelled data than a fine-tuned encoder
What it costs to run
Hours to days on GPU from scratch; minutes to hours fine-tuning a pretrained backbone — which is why almost nobody trains from scratch any more.
Milliseconds on GPU, tens of milliseconds on CPU. Quantization to int8 and distillation bring modern architectures within edge-device budgets.
Quarterly, or whenever the capture pipeline changes. A new camera model or lighting rig is a bigger trigger than elapsed time.
- Monitor input distribution — camera, lighting and lens changes are the usual cause of silent degradation
- Track per-slice accuracy, since aggregate metrics hide subgroup failures completely
- Watch prediction-confidence distribution; a shift often precedes a measurable accuracy drop
- Preprocessing must be versioned with the model — resize interpolation and normalization constants change predictions
- Training-time augmentation must be off at inference, and forgetting this is a classic silent bug
- Batch-norm layers behave differently in train and eval mode; leaving the model in train mode corrupts inference
Pros and cons, in context
The structural prior makes it dramatically data-efficient for grid data
Weight sharing gives orders of magnitude fewer parameters than a dense layer for the same receptive field. Decisive at ordinary dataset sizes; the advantage narrows once data is abundant enough for a transformer to learn the prior instead.
Transfer learning from pretrained backbones is extremely effective
Usually removes the data problem entirely — a few thousand labelled images on a frozen backbone often beats a from-scratch model on a hundred thousand.
Fully parallel across spatial positions
Unlike recurrence, every position computes simultaneously. This is what makes 1-D CNNs an attractive alternative to LSTMs on long sequences.
Mature deployment path to edge hardware
Quantization, pruning and distillation are well-trodden for convolutions specifically, with hardware acceleration widely available.
Fixed receptive field bounds what it can see
A dependency longer than the stack covers is structurally invisible. On images this rarely bites; on long sequences it is the reason to prefer attention.
Only translation equivariance comes free
Rotation, scale and viewpoint invariance must be taught through augmentation. A model that never saw rotated examples will fail on them, confidently.
Prone to shortcut learning
Latches onto spurious correlates — watermarks, backgrounds, scanner artifacts — that separate classes in training and vanish in deployment. Aggregate accuracy will not reveal it.
Effectively opaque
Saliency maps are suggestive rather than explanatory. Disqualifying wherever a decision must come with a defensible reason.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""2-D convolution and a forward pass - the operation, written out.
Four nested loops: output position (i, j), then kernel offset (u, v). This is
literally the sum in the equation above.
"""
def conv2d(image, kernel, stride=1):
"""Single-channel valid convolution. image[h][w], kernel[kh][kw]."""
h, w = len(image), len(image[0])
kh, kw = len(kernel), len(kernel[0])
out_h = (h - kh) // stride + 1
out_w = (w - kw) // stride + 1
output = [[0.0] * out_w for _ in range(out_h)]
for i in range(out_h):
for j in range(out_w):
total = 0.0
# The SAME kernel weights are used at every (i, j) - that reuse is
# weight sharing, and it is the entire point of the architecture.
for u in range(kh):
for v in range(kw):
total += image[i * stride + u][j * stride + v] * kernel[u][v]
output[i][j] = total
return output
def relu(feature_map):
return [[max(0.0, v) for v in row] for row in feature_map]
def max_pool(feature_map, size=2):
"""Downsample by taking the strongest activation in each window."""
h, w = len(feature_map), len(feature_map[0])
out = [[0.0] * (w // size) for _ in range(h // size)]
for i in range(h // size):
for j in range(w // size):
window = [
feature_map[i * size + u][j * size + v]
for u in range(size)
for v in range(size)
]
out[i][j] = max(window)
return out
def forward(image, kernels):
"""Conv -> ReLU -> pool, once per kernel. Receptive field widens with depth."""
return [max_pool(relu(conv2d(image, k))) for k in kernels]O(H·W·k²) per kernel in pure Python — thousands of times slower than a real implementation.
How this language actually wants it written.
"""CNN - typed, validated, with the train/eval distinction made explicit."""
from dataclasses import dataclass
import torch
from torch import Tensor, nn
@dataclass(frozen=True)
class CnnConfig:
in_channels: int = 3
num_classes: int = 10
base_width: int = 32
depth: int = 3
dropout: float = 0.1
class ConvBlock(nn.Module):
"""Conv -> BatchNorm -> ReLU, the standard unit.
Bias is disabled on the convolution because BatchNorm immediately
re-centres the activations - the bias term would be learned and then
subtracted away, wasting parameters and slightly hurting conditioning.
"""
def __init__(self, in_ch: int, out_ch: int) -> None:
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
)
def forward(self, x: Tensor) -> Tensor:
return self.block(x)
class SimpleCnn(nn.Module):
def __init__(self, config: CnnConfig) -> None:
super().__init__()
if config.depth < 1:
raise ValueError(f"depth must be >= 1, got {config.depth}")
if not 0.0 <= config.dropout < 1.0:
raise ValueError(f"dropout must be in [0, 1), got {config.dropout}")
layers: list[nn.Module] = []
in_ch = config.in_channels
width = config.base_width
for _ in range(config.depth):
layers.append(ConvBlock(in_ch, width))
layers.append(nn.MaxPool2d(2))
in_ch = width
width *= 2 # channels double as resolution halves
self.features = nn.Sequential(*layers)
# Adaptive pooling makes the head independent of input resolution -
# a fixed Linear would break the moment the image size changed.
self.pool = nn.AdaptiveAvgPool2d(1)
self.head = nn.Sequential(
nn.Dropout(config.dropout),
nn.Linear(in_ch, config.num_classes),
)
def forward(self, x: Tensor) -> Tensor:
if x.dim() != 4:
raise ValueError(f"expected (batch, channels, height, width), got {tuple(x.shape)}")
features = self.pool(self.features(x)).flatten(1)
return self.head(features)
@torch.no_grad()
def evaluate(model: nn.Module, loader, device: str) -> float:
"""Accuracy on a loader.
model.eval() is not cosmetic: it switches BatchNorm from batch statistics
to running averages and disables Dropout. Evaluating in train mode gives
wrong and batch-size-dependent results.
"""
model.eval()
correct = total = 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
predictions = model(images).argmax(dim=1)
correct += int((predictions == labels).sum())
total += labels.numel()
return correct / max(total, 1)What changed: The hand-rolled loops become framework modules with fused conv/norm/activation blocks. Three things that silently break CNNs in production are handled explicitly: bias is disabled where BatchNorm follows, adaptive pooling makes the head resolution-independent, and eval mode is enforced in the evaluation path — leaving BatchNorm in train mode produces batch-size-dependent results that look almost right.
O(H·W·C_in·C_out·k²) per layer, on fused cuDNN kernels.
Faster, and the justification for each change.
"""CNN - channels-last, mixed precision, fused conv-bn for inference."""
import torch
from torch import Tensor, nn
def prepare_for_training(model: nn.Module, device: str) -> nn.Module:
"""channels_last + bfloat16 autocast.
Tensor cores want NHWC. PyTorch defaults to NCHW, so cuDNN inserts a
transpose before every convolution; converting once up front removes that
transpose from every layer of every step.
"""
model = model.to(device, memory_format=torch.channels_last)
return model
def train_epoch(model: nn.Module, optimizer, loader, device: str) -> float:
model.train()
scaler = torch.amp.GradScaler(device)
total_loss = 0.0
for images, labels in loader:
# non_blocking with pinned memory overlaps the host-to-device copy
# with compute, so the GPU is not idling between batches.
images = images.to(device, non_blocking=True, memory_format=torch.channels_last)
labels = labels.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.autocast(device_type=device, dtype=torch.bfloat16):
loss = nn.functional.cross_entropy(model(images), labels)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
total_loss += float(loss.item())
return total_loss / max(len(loader), 1)
@torch.no_grad()
def fuse_conv_bn(conv: nn.Conv2d, bn: nn.BatchNorm2d) -> nn.Conv2d:
"""Fold BatchNorm into the preceding convolution for inference.
At eval time BN is a fixed affine transform, so it can be folded into the
conv weights algebraically:
W' = W * gamma / sqrt(var + eps)
b' = (b - mean) * gamma / sqrt(var + eps) + beta
The result is numerically identical and removes an entire layer - both its
kernel launch and its full pass over the activations - from every forward.
"""
fused = nn.Conv2d(
conv.in_channels, conv.out_channels, conv.kernel_size,
conv.stride, conv.padding, bias=True,
)
scale = bn.weight / torch.sqrt(bn.running_var + bn.eps)
fused.weight.copy_(conv.weight * scale.reshape(-1, 1, 1, 1))
conv_bias = conv.bias if conv.bias is not None else torch.zeros(conv.out_channels)
fused.bias.copy_((conv_bias - bn.running_mean) * scale + bn.bias)
return fusedWhat changed: Three changes, all about hardware rather than the model. Tensors move to channels-last so cuDNN stops inserting a transpose before every convolution. Training runs under bfloat16 autocast with non-blocking pinned transfers so the GPU is not waiting on the host. And for inference, BatchNorm is algebraically folded into the preceding convolution — numerically identical, but it deletes an entire layer and its full pass over the activations from every forward.
Ensure contiguous memory layout and a single dtype
channels_last is the layout tensor cores actually want; without it cuDNN transposes NCHW to NHWC before every single convolution.
Trade-off: Any custom op in the model that assumes NCHW will silently transpose back, which can make the model slower rather than faster.
Fuse adjacent operations so intermediates are never materialized
Folding BatchNorm into the convolution removes a whole layer's kernel launch and its full read/write of the activation tensor from every inference pass.
Trade-off: Only valid in eval mode with frozen statistics — fusing a model that will be fine-tuned further destroys the BatchNorm parameters.
Batch work to amortize interpreter overhead
non_blocking transfers from pinned memory overlap the host-to-device copy with compute, so the input pipeline stops being the bottleneck.
Trade-off: Requires pinned host memory, which is a limited resource and can starve the rest of the system if over-allocated.
Roughly 2x step-time reduction; one fewer layer at inference. Illustrative, not a measured benchmark.