What it actually does
Instead of asking a stack of layers to learn the mapping H(x) directly, ask it to learn only the correction F(x) = H(x) - x and add the input back: y = F(x) + x. If the best thing a block can do is nothing, it only has to push its weights toward zero, which is easy; a plain stack has to learn the identity map through several nonlinear layers, which turns out to be hard. That is the whole idea, and its consequence was surprising: before it, making a network deeper made it train worse, not just generalise worse. After it, hundreds of layers train with ordinary SGD, and the shortcut became the default way to wire any deep network.
- The input has a spatial grid structure where nearby pixels are related and a feature worth detecting is worth detecting anywhere (translation equivariance)
- Enough labelled data exists, or a pretrained backbone in a compatible domain does
- Batches are large enough, and drawn similarly enough to deployment inputs, that BatchNorm statistics are meaningful
- The identity is a good default for each block: the useful transformation at each layer is a modest correction rather than a wholesale rewrite
- Deployment inputs come from the same capture pipeline as training inputs
Loss function
The loss is ordinary cross-entropy and says nothing about the architecture. What is distinctive is the first term: the block is parameterised so that its default behaviour is to pass the input through, and learning is the departure from that default. Two consequences follow. A deeper network contains every shallower one as a special case (set the extra residual branches to zero), so adding depth cannot make the best achievable training error worse. And the parameter-free shortcut costs no FLOPs worth counting, which is why the idea was adopted so quickly: it is nearly free.
- the residual branch: two 3x3 convolutions (basic block) or a 1x1-3x3-1x1 stack (bottleneck), each followed by batch normalization
- the block input, carried unchanged along the shortcut. When shapes differ a 1x1 projection replaces it
- the block output. A ReLU follows the addition in the original design, and precedes the convolutions in pre-activation ResNet
- softmax over classes, computed from the globally average-pooled final feature map
- weight decay, written as an L2 penalty. Modern recipes exclude BatchNorm parameters and biases from it
How it is minimized
SGD with momentum, weight decay, a warmup-then-decay learning-rate schedule, He initialisation and zero-initialised last BatchNorm gamma per block
- the identity from the shortcut. Expanding the product leaves one term, the identity path, with no multiplicative factor at all, so the loss signal always reaches every earlier block
- the Jacobian of one residual branch. In a plain network this alone would be the factor, and a long product of such factors is what shrinks or explodes
- momentum coefficient, 0.9 in the original recipe
- learning rate at step t: linear warmup at large batch, then step or cosine decay
- the momentum buffer, accumulating the gradient (weight decay is folded into the gradient in the SGD form)
Plain SGD with momentum is the original and still a strong choice for convolutional classifiers, because batch normalization already makes the loss surface well-conditioned and the generalisation of SGD on this architecture family is hard to beat with adaptive methods. The gradient identity is the heart of it. In a plain network the gradient at layer l is a product of L - l Jacobians, so it shrinks or grows geometrically with depth. In a residual network each factor is I + dF/dx, and the product expands into a sum over paths, one of which is the pure identity. That path guarantees the loss signal reaches the early layers without being multiplied by anything. It does not guarantee the other paths behave: if the branches are large the signal can grow with depth, which is why the initialisation details matter. He initialisation (variance 2 / fan) keeps ReLU activations at a stable scale. Zeroing the last BatchNorm gamma in each block makes every block exactly the identity at step zero, so the network starts as a shallow one and grows effective depth as training proceeds. This is the single cheapest improvement to large-batch training stability.
depth and block type
Basic blocks for 18/34 layers, bottleneck blocks for 50 and above. Depth beyond roughly 100 gives sharply diminishing returns; width and resolution usually pay better (18, 34, 50, 101, 152 layers)
base learning rate and batch size
The linear scaling rule: learning rate grows in proportion to batch size, with warmup to survive the first epochs at the larger rate (0.1 at batch 256, scaled linearly)
learning-rate schedule
Step decay by 10x at fixed epochs in the original; cosine decay in modern recipes. The final low-rate phase is where most of the accuracy is gained (cosine over 90 to 600 epochs)
weight decay
The main explicit regulariser. Applied to convolution and linear weights, usually not to BatchNorm gamma/beta or biases (1e-4 to 5e-5)
zero-init residual gamma
Starts every block as the identity. Off, large-batch training is noticeably less stable; on, it is nearly free (on (gamma = 0 for the last BN of each block))
stochastic depth / label smoothing / mixup
The modern recipe components. Together they account for a large part of the gap between the 2015 numbers and current ResNet-50 accuracy (depth drop 0.0 to 0.2, smoothing 0.1)
Non-convex but reliably trainable, which is the point of the architecture. The degradation problem it addressed is worth stating precisely, because it is usually described wrongly: a 56-layer plain network had higher TRAINING error than a 20-layer one on the same data. That rules out overfitting. It is an optimization failure, since the deeper network could in principle copy the shallower one and set the remaining layers to identity, and SGD could not find that solution. The original paper also noted that batch-normalized plain networks had healthy gradient norms, so this is not simply the classical vanishing-gradient story. The better reading is conditioning: parameterising the block as a perturbation of the identity gives the optimizer a far friendlier landscape, and later work visualising the loss surface found residual networks markedly smoother. The characteristic failures now are different. Small-batch training breaks BatchNorm because the statistics get noisy. Large-batch training without warmup diverges. And shortcut learning on a spurious correlate looks exactly like success until deployment.
ResNet-50 has about 25.6M parameters and about 4.1 GMACs per 224x224 forward pass; the backward pass costs roughly twice that. The bottleneck is the reason: at 256 channels a basic block (two 3x3 convolutions) holds about 1.18M weights while a 1x1-3x3-1x1 bottleneck holds about 70k, a roughly 17-fold saving that is what lets depth reach 50 and beyond at affordable cost. Memory is dominated by stored activations for the backward pass, not weights.
How, where, and why it is used
Time-Series Forecasting
AdaptedThe 2-D ImageNet ResNet is not a forecaster, and presenting it as one would be dishonest. What carries over is the residual block. A temporal convolutional network is a stack of residual blocks whose convolutions are 1-D, causal and dilated; WaveNet uses gated residual and skip connections the same way; N-BEATS stacks MLP blocks that each subtract their own reconstruction (backcast) from the input and pass the remainder on, which is residual learning applied to the signal itself.
- Multivariate sensor and telemetry forecasting with a TCN-style residual dilated stack
- High-rate signals such as audio, where a WaveNet-style residual stack models the waveform
- Univariate benchmark forecasting with N-BEATS-style doubly-residual stacks
The residual connection is what makes a 10 to 20 layer dilated stack trainable at all, and a dilated stack is how a convolution covers a long history cheaply. The honest limits are structural. The receptive field is fixed by the architecture, so a dependency longer than the dilation schedule covers is invisible. The output is a point forecast unless a probabilistic head is added. And on ordinary business series with calendar structure, gradient-boosted trees on lag features or a seasonal-naive baseline frequently match or beat it. Reach for it when the series is high-rate and multivariate, not by default.
- Causal padding is mandatory: symmetric padding leaks the future into the present and produces spectacular, false validation scores
- Choose the dilation schedule so the receptive field covers at least one full seasonal cycle
- Scale each series before windowing so one high-volume channel does not dominate
- Keep the residual branch narrow enough that the shortcut, not the branch, carries the signal at initialisation
Rolling-origin backtesting with MASE against seasonal naive, reported per horizon. Verify the receptive field actually spans the dependency you believe matters, rather than assuming it from the layer count.
- Non-causal padding silently leaks future values
- A receptive field shorter than the seasonal period makes the model structurally blind to seasonality
- Comparing against no simple baseline; the residual stack must beat seasonal naive and a boosted-tree model to justify its cost
Anomaly Detection
ViableUse a pretrained ResNet as a frozen feature extractor and do the detection in feature space, the PatchCore recipe. Take mid-level feature maps (layer2 and layer3 of a wide ResNet-50), average each position with its local neighbourhood, and store the patch features of confirmed-normal images in a memory bank, subsampled with a coverage-preserving coreset. At test time the anomaly score of a patch is its distance to the nearest stored normal patch, and the image score is the maximum over patches. No defect labels are needed.
- Manufacturing surface-defect and assembly inspection, where defects are rare and heterogeneous
- Medical screening against a normal-population baseline
- Wafer and PCB inspection, where the anomaly is small and local
The hard part of visual anomaly detection is a good representation, and an ImageNet-pretrained ResNet supplies one without a single defect label. It is applied here as a distance in feature space, not as a classifier. The distance matters. PatchCore uses Euclidean distance between patch features, which is reasonable when patches come from one fixed network and one scale. Swap in a global-average-pooled image embedding with cosine instead and the detector goes blind to small defects, because averaging over the whole map dilutes a local anomaly into the mean. The layer matters equally: the final stage is too specialised to ImageNet classes and too spatially coarse (7x7 at 224 pixels), while the earliest stage is too generic.
- Take intermediate-stage features, not the classifier features, so texture detail survives
- Build the memory bank from confirmed-normal images only, then subsample it or lookup cost grows without bound
- Aggregate each patch with its neighbours before scoring so a defect straddling a boundary is not halved
- Score by the maximum over patches rather than the mean, because a defect is local and the mean averages it away
Image-level and pixel-level AUROC reported separately, since a detector can flag the right image for the wrong region. Threshold from a quantile of normal-image scores on a clean holdout, then slice by product variant and lighting. Compare against a trivial intensity-deviation baseline on the same data.
- A memory bank that quietly contains defective images teaches the detector that defects are normal
- Objects that appear at varying pose or position inflate normal-patch distances and need alignment or a pose-robust score
- The backbone is blind to anything ImageNet training made it invariant to, such as small colour shifts
- A camera or lighting change moves the whole feature distribution and invalidates the threshold overnight
Optimization
Not applicableA ResNet is a perception component: it maps pixels to a semantic reading and has no notion of a decision variable, a constraint or a feasible region. It can sit upstream of an optimizer, for example a defect classifier feeding a routing decision, but the network optimizes nothing about the world, only its own weights. The residual connection is an optimization aid for training the network, which is a different matter from solving an optimization problem with it.
Computer Vision
Primary fitA pretrained backbone with a task head. Replace the final linear layer for classification; feed the stage outputs into a feature pyramid for detection and instance segmentation; use the stages as the encoder of a segmentation decoder; or drop the head entirely and use the pooled penultimate-layer vector as an image embedding for retrieval and transfer.
- Image classification and, as the default backbone, detection and segmentation (Faster R-CNN, RetinaNet, Mask R-CNN encoders)
- Transfer learning to small labelled datasets by fine-tuning or by training a linear probe on frozen features
- Image retrieval and deduplication from the pooled embedding
- Encoder for self-supervised pretraining and for contrastive image-text models
It is the default baseline because it is simple, well understood, and trains reliably on modest hardware. The similarity decision is the part worth getting right. The pooled penultimate-layer vector is the standard image representation: compare two images by cosine similarity on L2-normalised embeddings, not Euclidean distance on pixels. A one-pixel shift of a textured image changes every pixel coordinate, so pixel distance can rank a shifted copy as farther away than an unrelated blank image. The embedding is built from translation-equivariant convolutions followed by global pooling, so the same shift barely moves it. Normalising first matters because ReLU features are non-negative and their length tracks activation energy, such as contrast and object size, which is not semantics. After L2 normalisation, cosine and Euclidean rank identically, and the dot product is the cosine. Vision Transformers overtake it at very large data scale, and modern ConvNeXt-style recipes close most of the remaining gap, but at ordinary dataset sizes a well-trained ResNet is hard to beat for the effort.
- Normalize with the pretrained backbone's own mean and standard deviation, not your dataset's
- Keep resize interpolation and crop policy identical between training and serving
- L2-normalise embeddings before indexing, and centre them on the corpus mean if raw cosine scores are all uniformly high
- Train at a lower resolution than you test at: object apparent size differs between random-crop training and centre-crop testing, and a modestly higher test resolution recovers the difference
Top-1 and top-5 for classification, mAP for detection, mean IoU for segmentation, recall@k for retrieval on a held-out query and gallery split. Always slice by capture condition and subgroup; aggregate accuracy hides the failure that matters.
- Shortcut learning on a background, watermark or scanner artifact that will not exist in deployment
- Leaving BatchNorm in training mode at inference, which makes outputs batch-dependent
- Treating classifier-trained embeddings as a metric space: they are shaped for linear separability, so fine-grained retrieval needs metric-learning or self-supervised fine-tuning
- Small translations still flip predictions, because strided layers alias; the model is equivariant in theory and only approximately in practice
Recommendation & Ranking
AdaptedUse the pooled, L2-normalised ResNet embedding of an item image as a content feature: either index it directly for visual similar-item lookup with an approximate nearest-neighbour search on inner product, or feed it as the image side of a two-tower retrieval model alongside interaction-derived features.
- Visual search and shop-similar widgets in product catalogues
- Cold-start items that have an image but no interaction history
Image embeddings solve the cold-start problem for visual catalogues, because a new item is placed by what it looks like rather than by who has clicked it. The limitation is that visual similarity is not purchase affinity: two visually near-identical items can serve different buyers, and two dissimilar ones can be co-purchased constantly. Treat it as one feature in a ranker, not as the ranker. Cosine on normalised vectors is the right comparison; unnormalised Euclidean ranks by feature magnitude and surfaces high-contrast images regardless of content.
- Embed once offline and store normalised vectors; recompute only when the backbone changes
- Deduplicate near-identical listings before they crowd the top-k
- Combine with interaction-derived signals instead of ranking on visual similarity alone
Offline recall@k against logged co-engagement, then an online A/B on click-through and conversion, sliced by new versus established items.
- Changing the backbone silently invalidates the whole index; version the embedding model with the vectors
- Product photos with inconsistent backgrounds cluster by photography style rather than by product
What it costs to run
ResNet-50 on ImageNet for 90 epochs takes roughly a day on one 8-GPU node with the classic recipe, and about an hour across a few hundred GPUs at batch sizes in the thousands with warmup and the linear scaling rule. Illustrative, not a benchmark. Fine-tuning a pretrained backbone is minutes to hours, which is why almost nobody trains from scratch.
About 4.1 GMACs per 224x224 image: milliseconds on a GPU, tens to a couple of hundred milliseconds on a CPU depending on core count. BatchNorm folds into the preceding convolution at inference, and int8 quantization is a well-trodden path with broad hardware support, which is a large part of why the architecture is still deployed.
Quarterly, or whenever the capture pipeline changes. A new camera, lens or lighting rig is a stronger trigger than elapsed time. Any change to the backbone also forces a full re-embedding of every stored vector.
- Monitor the input distribution: camera, lighting and compression changes are the usual cause of silent degradation
- Track the distribution of embedding norms and nearest-neighbour similarity to the corpus; a shift precedes a measurable accuracy drop
- Track per-slice accuracy, since aggregate metrics hide subgroup failures completely
- Preprocessing (resize interpolation, crop, mean and standard deviation) must be versioned with the model; a change silently degrades accuracy
- BatchNorm behaves differently in train and eval mode, and leaving a served model in train mode corrupts every prediction
- Vector indexes must be invalidated and rebuilt when the embedding model changes; mixing embeddings from two backbones gives meaningless similarities
- Embeddings must be L2-normalised consistently at index time and query time, or inner-product search ranks by length
Pros and cons, in context
Makes depth trainable with plain SGD
The identity shortcut removes the degradation problem, so networks of 50 to over 1000 layers optimize. The benefit is largest between about 20 and 150 layers; beyond that, width and resolution usually buy more accuracy per unit of compute.
The shortcut is nearly free
An identity addition adds no parameters and negligible FLOPs, which is why the idea spread to almost every deep architecture. Projection shortcuts, used only where shapes change, add a small amount.
A strong, well-understood default backbone
Reliable to train, widely pretrained, supported by every deployment toolchain, and the encoder inside most detection and segmentation systems. Its failure modes are documented, which matters more than a point of benchmark accuracy.
The pooled embedding is the standard image representation
It transfers well to retrieval, deduplication and anomaly detection without labels from the target domain. Strongest when compared by cosine on normalised vectors and weakest when used raw for fine-grained discrimination it was never trained for.
Depends on BatchNorm
Small batches give noisy statistics, and a train/eval mismatch or domain shift in those statistics degrades accuracy. Group or layer normalization fix the first problem at some cost in accuracy; fine-tuning with frozen BN statistics is the usual workaround.
Only approximately shift-invariant
Strided convolutions and pooling alias, so a one- or two-pixel translation can change a prediction. Anti-aliased downsampling (blur-pool) helps, and augmentation compensates, but the invariance the architecture is credited with is partial.
Classifier embeddings are not a calibrated metric space
Cross-entropy shapes features for linear separability by class, not for nearest-neighbour geometry. Retrieval at fine granularity needs metric-learning or self-supervised fine-tuning on top; the raw embedding is a strong starting point, not an endpoint.
Superseded at the largest scales and in the newest recipes
Vision Transformers and modernised ConvNets overtake it when data and compute are abundant. The gap is smaller than the original numbers suggest, since a modern training recipe lifts ResNet-50 several points without touching the architecture.
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.
"""A residual block, forward pass only: y = relu(F(x) + x), written out.
Tensors are nested lists indexed [channel][row][col]. The convolution is the
literal sum from the CNN entry with a channel loop added and zero padding, so
the output keeps the input's spatial size. That matters: the shortcut can only
be added to F(x) if the two have exactly the same shape.
"""
import math
def conv3x3(x, weights):
"""x[c_in][h][w], weights[c_out][c_in][3][3]. Stride 1, zero padding 1."""
c_in, h, w = len(x), len(x[0]), len(x[0][0])
c_out = len(weights)
out = [[[0.0] * w for _ in range(h)] for _ in range(c_out)]
for oc in range(c_out):
for i in range(h):
for j in range(w):
total = 0.0
for ic in range(c_in):
for u in range(3):
for v in range(3):
row, col = i + u - 1, j + v - 1
if 0 <= row < h and 0 <= col < w:
total += x[ic][row][col] * weights[oc][ic][u][v]
out[oc][i][j] = total
return out
def conv1x1(x, weights):
"""The projection shortcut. weights[c_out][c_in]; mixes channels only."""
c_in, h, w = len(x), len(x[0]), len(x[0][0])
c_out = len(weights)
return [
[[sum(weights[oc][ic] * x[ic][i][j] for ic in range(c_in)) for j in range(w)]
for i in range(h)]
for oc in range(c_out)
]
def relu(x):
return [[[max(0.0, value) for value in row] for row in plane] for plane in x]
def add(a, b):
return [
[[a[c][i][j] + b[c][i][j] for j in range(len(a[0][0]))] for i in range(len(a[0]))]
for c in range(len(a))
]
def residual_block(x, w1, w2, w_projection=None):
"""y = relu(F(x) + shortcut(x)), with F = conv -> relu -> conv.
The shortcut is x itself, unless the channel count changes, in which case a
1x1 convolution projects x to the new shape so the addition is defined.
"""
f = conv3x3(relu(conv3x3(x, w1)), w2)
shortcut = x if w_projection is None else conv1x1(x, w_projection)
return relu(add(f, shortcut))
def chain_gradient(weight, depth, is_residual):
"""d(output)/d(input) through a chain of scalar layers.
A plain layer is y = weight * x, with derivative weight. A residual layer is
y = weight * x + x, with derivative 1 + weight. The chain rule multiplies
one factor per layer, so a small weight shrinks the plain product toward
zero while the residual product has the identity term in every factor.
"""
product = 1.0
for _ in range(depth):
product *= (1.0 + weight) if is_residual else weight
return product
def global_average_pool(x):
"""Collapse each channel's map to its mean: the penultimate-layer embedding."""
return [sum(sum(row) for row in plane) / (len(plane) * len(plane[0])) for plane in x]
def cosine(a, b):
"""Compare two embeddings by direction, ignoring their length."""
dot = sum(p * q for p, q in zip(a, b))
norm_a = math.sqrt(sum(p * p for p in a))
norm_b = math.sqrt(sum(q * q for q in b))
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)O(C_out·C_in·H·W·k²) per convolution in pure Python, thousands of times slower than a real implementation.
How this language actually wants it written.
"""ResNet - basic and bottleneck blocks, projection shortcuts, and the
initialisation details that make deep stacks train."""
from dataclasses import dataclass
import torch
import torch.nn.functional as F
from torch import Tensor, nn
BASIC_EXPANSION = 1
BOTTLENECK_EXPANSION = 4
STAGE_STRIDES_AFTER_FIRST = 2
@dataclass(frozen=True)
class ResNetConfig:
block: str = "bottleneck" # "basic" (18/34) or "bottleneck" (50+)
layers: tuple[int, ...] = (3, 4, 6, 3) # ResNet-50
num_classes: int = 1000
in_channels: int = 3
base_width: int = 64
zero_init_residual: bool = True
def __post_init__(self) -> None:
if self.block not in ("basic", "bottleneck"):
raise ValueError(f"block must be 'basic' or 'bottleneck', got {self.block!r}")
if not self.layers or any(count < 1 for count in self.layers):
raise ValueError(f"every stage needs at least one block, got {self.layers}")
def _conv_bn(in_ch: int, out_ch: int, kernel: int, stride: int = 1) -> list[nn.Module]:
"""Conv -> BN with no conv bias: BN re-centres, so a bias would be wasted."""
return [
nn.Conv2d(in_ch, out_ch, kernel, stride=stride, padding=kernel // 2, bias=False),
nn.BatchNorm2d(out_ch),
]
def _shortcut(in_ch: int, out_ch: int, stride: int) -> nn.Module:
"""Identity when shapes match; a 1x1 projection when channels or stride change."""
if stride == 1 and in_ch == out_ch:
return nn.Identity()
return nn.Sequential(*_conv_bn(in_ch, out_ch, kernel=1, stride=stride))
class ResidualBlock(nn.Module):
"""y = relu(F(x) + shortcut(x)). The body F always ends in a BatchNorm."""
def __init__(self, body: nn.Sequential, shortcut: nn.Module) -> None:
super().__init__()
self.body = body
self.shortcut = shortcut
self.act = nn.ReLU(inplace=True)
@property
def last_bn(self) -> nn.BatchNorm2d:
last = self.body[-1]
if not isinstance(last, nn.BatchNorm2d):
raise TypeError("a residual body must end in BatchNorm2d")
return last
def forward(self, x: Tensor) -> Tensor:
return self.act(self.body(x) + self.shortcut(x))
def basic_block(in_ch: int, width: int, stride: int) -> tuple[ResidualBlock, int]:
"""Two 3x3 convolutions. Used by ResNet-18 and ResNet-34."""
out_ch = width * BASIC_EXPANSION
body = nn.Sequential(
*_conv_bn(in_ch, width, 3, stride), nn.ReLU(inplace=True), *_conv_bn(width, out_ch, 3)
)
return ResidualBlock(body, _shortcut(in_ch, out_ch, stride)), out_ch
def bottleneck_block(in_ch: int, width: int, stride: int) -> tuple[ResidualBlock, int]:
"""1x1 reduce -> 3x3 -> 1x1 expand. The 3x3 runs on few channels, which is
where the roughly 17-fold parameter saving over a basic block comes from.
Stride sits on the 3x3 (the v1.5 convention), not the first 1x1."""
out_ch = width * BOTTLENECK_EXPANSION
body = nn.Sequential(
*_conv_bn(in_ch, width, 1),
nn.ReLU(inplace=True),
*_conv_bn(width, width, 3, stride),
nn.ReLU(inplace=True),
*_conv_bn(width, out_ch, 1),
)
return ResidualBlock(body, _shortcut(in_ch, out_ch, stride)), out_ch
class ResNet(nn.Module):
def __init__(self, config: ResNetConfig) -> None:
super().__init__()
make_block = basic_block if config.block == "basic" else bottleneck_block
self.stem = nn.Sequential(
*_conv_bn(config.in_channels, config.base_width, 7, stride=2),
nn.ReLU(inplace=True),
nn.MaxPool2d(3, stride=2, padding=1),
)
stages: list[nn.Module] = []
in_ch = config.base_width
for stage_index, block_count in enumerate(config.layers):
width = config.base_width * 2**stage_index
first_stride = 1 if stage_index == 0 else STAGE_STRIDES_AFTER_FIRST
blocks: list[nn.Module] = []
for block_index in range(block_count):
block, in_ch = make_block(in_ch, width, first_stride if block_index == 0 else 1)
blocks.append(block)
stages.append(nn.Sequential(*blocks))
self.stages = nn.Sequential(*stages)
# Adaptive pooling makes the head independent of input resolution.
self.pool = nn.AdaptiveAvgPool2d(1)
self.head = nn.Linear(in_ch, config.num_classes)
self._initialise(config.zero_init_residual)
def _initialise(self, zero_init_residual: bool) -> None:
for module in self.modules():
if isinstance(module, nn.Conv2d):
# He initialisation: variance 2 / fan keeps ReLU activations at a
# stable scale from layer to layer.
nn.init.kaiming_normal_(module.weight, mode="fan_out", nonlinearity="relu")
elif isinstance(module, nn.BatchNorm2d):
nn.init.ones_(module.weight)
nn.init.zeros_(module.bias)
if zero_init_residual:
# Every block starts as exactly the identity, so the network begins
# shallow and effective depth grows as the branches learn.
for module in self.modules():
if isinstance(module, ResidualBlock):
nn.init.zeros_(module.last_bn.weight)
def features(self, x: Tensor) -> Tensor:
"""The penultimate-layer embedding: global-average-pooled final stage."""
if x.dim() != 4:
raise ValueError(f"expected (batch, channels, height, width), got {tuple(x.shape)}")
return self.pool(self.stages(self.stem(x))).flatten(1)
def forward(self, x: Tensor) -> Tensor:
return self.head(self.features(x))
@torch.no_grad()
def embed(model: ResNet, images: Tensor) -> Tensor:
"""L2-normalised embeddings, so a dot product between two rows is their cosine.
eval() is not cosmetic: it switches BatchNorm to its running statistics.
Embeddings from a model left in train mode depend on the batch they were
computed in, which makes a vector index quietly inconsistent.
"""
model.eval()
return F.normalize(model.features(images), dim=1)What changed: The nested loops become composable modules. A residual block is one class parameterised by its body and its shortcut, with basic and bottleneck bodies as factories. The shortcut is explicit: identity when shapes match, a 1x1 convolution plus BatchNorm when channels or stride change. Three details that decide whether deep networks train are now handled in code rather than left implicit: He initialisation, zeroing the last BatchNorm gamma of every block so each starts as the identity, and eval mode enforced on the embedding path.
About 25.6M parameters and 4.1 GMACs per 224x224 image for the default configuration, on fused cuDNN kernels.
Faster, and the justification for each change.
"""ResNet - channels-last, bf16, folded BatchNorm, compiled epilogues, batched retrieval."""
import torch
from torch import Tensor, nn
def fuse_conv_bn(conv: nn.Conv2d, bn: nn.BatchNorm2d) -> nn.Conv2d:
"""Fold an eval-mode BatchNorm into the convolution before it.
BN at inference is a fixed per-channel affine map, so it merges into the
weights: W' = W * gamma / sqrt(var + eps), b' = beta - mean * gamma / sqrt(var + eps).
Numerically the same, one fewer layer and one fewer pass over the activations.
"""
scale = bn.weight / torch.sqrt(bn.running_var + bn.eps)
fused = nn.Conv2d(
conv.in_channels, conv.out_channels, conv.kernel_size,
stride=conv.stride, padding=conv.padding, bias=True,
).to(conv.weight.device)
with torch.no_grad():
fused.weight.copy_(conv.weight * scale.reshape(-1, 1, 1, 1))
fused.bias.copy_(bn.bias - bn.running_mean * scale)
return fused
def fuse_sequential(sequence: nn.Sequential) -> nn.Sequential:
"""Replace every Conv2d -> BatchNorm2d pair with one conv and an Identity."""
layers = list(sequence.children())
fused: list[nn.Module] = []
index = 0
while index < len(layers):
has_partner = index + 1 < len(layers)
if has_partner and isinstance(layers[index], nn.Conv2d) and isinstance(
layers[index + 1], nn.BatchNorm2d
):
fused.append(fuse_conv_bn(layers[index], layers[index + 1]))
index += 2
else:
fused.append(layers[index])
index += 1
return nn.Sequential(*fused)
@torch.no_grad()
def prepare_for_inference(model: nn.Module, device: str) -> nn.Module:
"""Eval mode, folded BN in the stem, bodies and projection shortcuts, channels-last."""
model = model.eval().to(device)
model.stem = fuse_sequential(model.stem)
# Collect first: mutating modules while iterating modules() is undefined.
blocks = [module for module in model.modules() if hasattr(module, "body")]
for block in blocks:
block.body = fuse_sequential(block.body)
if isinstance(block.shortcut, nn.Sequential):
block.shortcut = fuse_sequential(block.shortcut)
# Tensor cores want NHWC. Converting the weights once removes the
# NCHW->NHWC transpose cuDNN would otherwise insert before each convolution.
model = model.to(memory_format=torch.channels_last)
# The compiler fuses the residual add and ReLU into the conv epilogue, so
# the pre-activation sum is never written to memory.
return torch.compile(model)
def train_step(model: nn.Module, optimizer, images: Tensor, labels: Tensor, device: str) -> float:
"""One step in bf16 autocast. bf16 keeps fp32's exponent range, so unlike
fp16 it needs no loss scaling."""
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)
loss.backward()
optimizer.step()
return float(loss.detach())
@torch.inference_mode()
def build_index(model: nn.Module, loader, device: str) -> Tensor:
"""Embed a whole corpus in large batches: one L2-normalised matrix, kept in float32."""
chunks = []
for images in loader:
images = images.to(device, non_blocking=True, memory_format=torch.channels_last)
with torch.autocast(device_type=device, dtype=torch.bfloat16):
features = model.features(images)
# Normalise in float32: bf16 has an 8-bit mantissa, so near-duplicates
# would tie and the ranking among them would be arbitrary.
chunks.append(nn.functional.normalize(features.float(), dim=1))
return torch.cat(chunks)
@torch.inference_mode()
def top_k(index: Tensor, queries: Tensor, k: int) -> tuple[Tensor, Tensor]:
"""Cosine nearest neighbours as one matrix product.
Both sides are unit length, so queries @ index.T IS the cosine matrix: a
single GEMM replaces a Python loop over query/item pairs.
"""
scores = queries @ index.T
return scores.topk(min(k, index.shape[0]), dim=1)What changed: Five changes, all about hardware and none about the network. BatchNorm is folded into the preceding convolution everywhere it appears, including the projection shortcuts, which deletes a layer and a full activation pass per convolution. Weights and activations move to channels-last and compute runs in bf16 autocast. torch.compile fuses the residual add and ReLU into the convolution epilogue, which matters here because the shortcut addition is memory-bound. Embedding runs in large batches under inference mode. And retrieval collapses from a pairwise loop to one matrix product, valid only because the embeddings were L2-normalised so that the dot product is the cosine.
Ensure contiguous memory layout and a single dtype
channels_last is the layout tensor cores want, and a single bf16 compute dtype avoids per-layer casts. Together they remove the transposes and conversions that dominate small convolutions.
Trade-off: bf16 has roughly three significant decimal digits, so near-duplicate similarity scores tie; the index and similarity matmul stay in float32. Any custom op that assumes NCHW silently transposes back and can make the model slower.
Fuse adjacent operations so intermediates are never materialized
Folding BatchNorm into the convolution removes a layer and a full read and write of the activations; compiling fuses the residual addition and ReLU into the convolution epilogue so the sum is never stored.
Trade-off: Folding is valid only in eval mode with frozen statistics, and a folded model cannot be fine-tuned with BatchNorm behaviour intact. torch.compile adds warm-up latency and recompiles on new input shapes.
Batch work to amortize interpreter overhead
Embedding the corpus in large batches under inference mode keeps the GPU busy and avoids per-image Python overhead and autograd bookkeeping.
Trade-off: Large batches raise peak memory, and a final short batch changes the compiled shape. Latency-sensitive single-image serving does not benefit.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
On unit-length embeddings the cosine matrix is queries @ index.T, one GEMM that replaces a loop over every query and item pair.
Trade-off: The score matrix is queries by items in size, so a large corpus needs chunking or an approximate index. The shortcut is also only valid if both sides were normalised identically.
Roughly 2 to 3x inference throughput from folding, channels-last and bf16 on tensor-core hardware; retrieval a single GEMM. Illustrative, not a measured benchmark.