PROMETHEUS OS
NATIVE AI OPERATING SYSTEM
[INIT] Prometheus OS loading...
[CORE] Neural interface initializing...
[AI] LLM router connecting...
[MEM] Memory graph loading...
[AGT] Agent chains on standby...
[SEC] Security protocols active...
[NET] Provider mesh established...
[UI] Workspace rendering...
[DONE] All systems nominal.
Welcome to PROMETHEUS OS.
READY · OFFLINE · SECURE · PRIVATE
🎨 THEME ENGINE
PROMETHEUS OS · 23 THEMES
Trial version: Dark + Light themes only.
Full theme access with PROMETHEUS OS license.
Dark theme active
Experience all 23 themes in PROMETHEUS OS

Convolutional Neural Networks

Intermediate 19 min· 4 exercises

Compute convolutions, output sizes, receptive fields and parameter counts by hand, build and fine-tune CNNs in PyTorch, and decide when a CNN or a vision transformer is the right backbone.

By the end of this lesson you will be able to
  • Compute a 2-D convolution by hand and explain why libraries implement cross-correlation
  • Apply the output-size and receptive-field formulas to any stack of convolution and pooling layers
  • Count the parameters and multiply-accumulates of convolutional layers and compare them with fully connected layers
  • Explain the design ideas behind LeNet, AlexNet, VGG, ResNet and EfficientNet
  • Implement transfer learning with a pretrained torchvision backbone and appropriate data augmentation
  • Compare CNNs with vision transformers and choose between them for a given data and compute budget

Before this: Neural Networks and Backpropagation, Training Deep Networks in Practice

Why convolutional networks matter

Convolutional neural networks (CNNs) are the architecture that made deep learning work for images. They power photo search, medical image triage, defect detection on factory lines, document scanning, and the perception stacks of robots and cars. Even in a period dominated by transformers, convolutions remain the default when you need a fast, data-efficient vision model, and they live on inside many hybrid and generative architectures. This lesson takes you from the convolution operation itself to fine-tuning a pretrained backbone, and closes with a careful comparison against vision transformers.

Intuition: a small stencil slid across the image

Picture a 3×3 cardboard stencil with nine numbers written on it. You lay it on the top-left corner of a grayscale image, multiply each stencil number by the pixel underneath, add the nine products, and write the sum in a new grid. Then you slide the stencil one pixel right and repeat, row by row, until you have covered the image. The new grid is a feature map: it is bright wherever the image locally "looks like" the stencil's pattern. A stencil with positive numbers on its left column and negative numbers on its right lights up at vertical edges.

A CNN learns the numbers on hundreds of such stencils (called kernels or filters) by gradient descent, stacks them in layers, and lets deeper layers combine edges into textures, textures into parts, and parts into objects. Two built-in assumptions make this efficient:

  • Locality: useful patterns are built from nearby pixels, so each output only looks at a small neighbourhood.
  • Weight sharing / translation equivariance: the same detector is useful everywhere, so one kernel is reused at every position. If the input shifts, the feature map shifts with it.

These assumptions are called the CNN's inductive bias. They are why a CNN with a few million parameters can learn from tens of thousands of images, where a fully connected network would need billions of weights.

The convolution operation, formally

Let the input be a tensor X∈RCin×H×WX \in \mathbb{R}^{C_{in} \times H \times W} (channels, height, width) and let the layer have CoutC_{out} kernels, each of shape Cin×k×kC_{in} \times k \times k. The output feature map for kernel oo at position (i,j)(i, j) is

Yo,i,j=bo+∑c=1Cin∑u=0k−1∑v=0k−1Ko,c,u,v Xc,  iS+u,  jS+vY_{o,i,j} = b_o + \sum_{c=1}^{C_{in}} \sum_{u=0}^{k-1} \sum_{v=0}^{k-1} K_{o,c,u,v}\, X_{c,\; iS+u,\; jS+v}

where KK is the kernel weight tensor, bob_o is a per-output-channel bias, and SS is the stride (how many pixels the kernel jumps between positions). Note that each kernel spans all input channels: a 3×3 kernel on an RGB image really has 3×3×3=273 \times 3 \times 3 = 27 weights.

Strictly, this is cross-correlation; true mathematical convolution flips the kernel first. Deep learning libraries (PyTorch's conv2d included) implement cross-correlation and call it convolution. Because the kernel is learned, the flip does not matter.

Worked example: one kernel, every step

Input XX (4×4, one channel) and a vertical-edge kernel KK (3×3), stride 1, no padding:

X=[1201013121001012],K=[10−110−110−1]X = \begin{bmatrix} 1 & 2 & 0 & 1 \\ 0 & 1 & 3 & 1 \\ 2 & 1 & 0 & 0 \\ 1 & 0 & 1 & 2 \end{bmatrix}, \qquad K = \begin{bmatrix} 1 & 0 & -1 \\ 1 & 0 & -1 \\ 1 & 0 & -1 \end{bmatrix}

Because KK has +1+1 in its left column and −1-1 in its right column, each output equals (sum of the left column of the patch) minus (sum of the right column).

  1. Top-left, rows 0–2, columns 0–2: left column 1+0+2=31+0+2 = 3, right column 0+3+0=30+3+0 = 3, output 3−3=03-3 = 0.
  2. Top-right, rows 0–2, columns 1–3: left column 2+1+1=42+1+1 = 4, right column 1+1+0=21+1+0 = 2, output 4−2=24-2 = 2.
  3. Bottom-left, rows 1–3, columns 0–2: left 0+2+1=30+2+1 = 3, right 3+0+1=43+0+1 = 4, output −1-1.
  4. Bottom-right, rows 1–3, columns 1–3: left 1+1+0=21+1+0 = 2, right 1+0+2=31+0+2 = 3, output −1-1.
Y=[02−1−1]Y = \begin{bmatrix} 0 & 2 \\ -1 & -1 \end{bmatrix}

A 4×4 input and 3×3 kernel give a 2×2 output: the kernel fits in only two positions along each axis.

Output size, padding, stride, dilation

Padding PP adds PP rows/columns of (usually) zeros around the border so the kernel can be centred on edge pixels. For input width WW, kernel kk, padding PP, stride SS:

Wout=⌊W−k+2PS⌋+1W_{out} = \left\lfloor \frac{W - k + 2P}{S} \right\rfloor + 1

The same formula applies to height. Two cases to memorise: with stride 1, "same" padding P=(k−1)/2P = (k-1)/2 for odd kk keeps the size unchanged (3×3 with P=1P=1, 5×5 with P=2P=2); with k=Sk = S and P=0P = 0 (patchify), the size divides by SS.

Worked example — the classic ResNet "stem" on a 224×224 image: a 7×7 convolution with stride 2 and padding 3 gives ⌊(224−7+6)/2⌋+1=⌊111.5⌋+1=112\lfloor (224 - 7 + 6)/2 \rfloor + 1 = \lfloor 111.5 \rfloor + 1 = 112. A following 3×3 max-pool with stride 2 and padding 1 gives ⌊(112−3+2)/2⌋+1=55+1=56\lfloor (112 - 3 + 2)/2 \rfloor + 1 = 55 + 1 = 56.

Dilation DD spaces the kernel taps DD pixels apart, giving an effective kernel size keff=D(k−1)+1k_{eff} = D(k-1) + 1 with no extra weights; substitute keffk_{eff} for kk in the formula. Dilated convolutions are common in segmentation and audio models where a large context is needed at full resolution.

Pooling

Pooling downsamples each channel independently with a fixed (non-learned) function. Max pooling with a 2×2 window and stride 2 keeps the largest value in each window, halving height and width and giving a little local translation invariance. Average pooling takes the mean. Global average pooling averages each entire channel to a single number, turning a C×H×WC \times H \times W map into a length-CC vector; modern classifiers use it in place of large fully connected layers, which removes most of their parameters and lets the network accept varied input sizes. Many recent designs replace max pooling with strided convolutions, letting the network learn how to downsample.

Parameter and compute counting

A convolutional layer has

params=kh kw Cin Cout+Cout\text{params} = k_h\, k_w\, C_{in}\, C_{out} + C_{out}

and costs about khkwCinCoutHoutWoutk_h k_w C_{in} C_{out} H_{out} W_{out} multiply-accumulates (MACs). Crucially, the parameter count does not depend on image size.

Worked example: a 3×3 convolution from 64 to 128 channels has 3⋅3⋅64⋅128+128=73,728+128=73,8563 \cdot 3 \cdot 64 \cdot 128 + 128 = 73{,}728 + 128 = 73{,}856 parameters. At a 56×56 output it costs 73,728×3,136≈2.3×10873{,}728 \times 3{,}136 \approx 2.3 \times 10^8 MACs. A fully connected layer mapping the same 56⋅56⋅64=200,70456 \cdot 56 \cdot 64 = 200{,}704 inputs to 56⋅56⋅128=401,40856 \cdot 56 \cdot 128 = 401{,}408 outputs would need about 8.1×10108.1 \times 10^{10} weights — roughly a million times more. That gap is the practical payoff of locality and weight sharing.

Receptive field

The receptive field of a unit is the region of the input that can influence it. For a stack of layers with kernel sizes klk_l and strides sls_l, the receptive field after layer LL is

rL=1+∑l=1L(kl−1)∏i=1l−1sir_L = 1 + \sum_{l=1}^{L} (k_l - 1) \prod_{i=1}^{l-1} s_i

The product of earlier strides (the "jump") is how many input pixels one step in layer ll's input corresponds to. Continuing the ResNet stem: the 7×7 stride-2 conv gives r=7r = 7; the 3×3 stride-2 pool adds (3−1)×2=4(3-1)\times 2 = 4, giving r=11r = 11; a following 3×3 stride-1 conv adds (3−1)×4=8(3-1) \times 4 = 8, giving r=19r = 19.

Two consequences matter in practice. First, stacking two 3×3 convolutions gives a 5×5 receptive field with 2⋅9C2=18C22 \cdot 9 C^2 = 18C^2 weights versus 25C225C^2 for one 5×5 layer (with CC channels in and out), plus an extra nonlinearity; three 3×3 layers match a 7×7 at 27C227C^2 versus 49C249C^2. This is the VGG design argument. Second, the effective receptive field — where gradients are actually large — is concentrated near the centre and smaller than the theoretical one, which is why downsampling and global pooling are needed to see whole objects.

Building blocks of a modern CNN

A typical stage is: convolution → batch normalization (normalizes each channel using batch statistics, then applies a learned scale and shift) → ReLU, repeated, followed by downsampling that halves resolution while doubling channels. The network ends with global average pooling and a linear classifier.

The code below checks the worked example against PyTorch and builds a small CIFAR-sized CNN.

import numpy as np, torch, torch.nn as nn, torch.nn.functional as F

X = np.array([[1,2,0,1],[0,1,3,1],[2,1,0,0],[1,0,1,2]], dtype=np.float32)
K = np.array([[1,0,-1],[1,0,-1],[1,0,-1]], dtype=np.float32)

def conv2d_valid(x, k, stride=1):            # single channel, no padding
    kh, kw = k.shape
    oh, ow = (x.shape[0]-kh)//stride + 1, (x.shape[1]-kw)//stride + 1
    out = np.zeros((oh, ow), dtype=x.dtype)
    for i in range(oh):
        for j in range(ow):
            patch = x[i*stride:i*stride+kh, j*stride:j*stride+kw]
            out[i, j] = np.sum(patch * k)    # cross-correlation (no flip)
    return out

print(conv2d_valid(X, K))                                   # [[0,2],[-1,-1]]
print(F.conv2d(torch.from_numpy(X)[None, None], torch.from_numpy(K)[None, None])[0, 0])

class SmallCNN(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
            nn.MaxPool2d(2),                                    # 32x32 -> 16x16
            nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2))                                    # 16x16 -> 8x8
        self.head = nn.Sequential(nn.AdaptiveAvgPool2d(1), nn.Flatten(),
                                  nn.Linear(64, num_classes))
    def forward(self, x):
        return self.head(self.features(x))

model = SmallCNN()
print(sum(p.numel() for p in model.parameters()))           # 29,546
print(model(torch.randn(8, 3, 32, 32)).shape)               # torch.Size([8, 10])

PyTorch tensors for images are laid out as (batch, channels, height, width), and convolution weights as (out_channels, in_channels, kH, kW) — hence the [None, None] to add batch and channel axes. The 29,546 parameters break down as 896 + 9,248 + 18,496 for the three convolutions, 64 + 64 + 128 for the batch-norm scales and shifts, and 650 for the classifier.

Classic architectures and what each one taught

ModelYearKey ideaLesson for practitioners
LeNet-5 (LeCun et al.)1998Conv → pool → conv → pool → fully connected, trained end to end on digitsThe basic recipe; worked on small grayscale images
AlexNet (Krizhevsky, Sutskever, Hinton)20125 conv + 3 fully connected layers, about 60M parameters, ReLU, dropout, GPU trainingScale + GPUs + data won ImageNet decisively and started the deep learning boom
VGG (Simonyan & Zisserman)2014Only 3×3 convolutions, 16–19 weight layersSmall kernels stacked deep beat large kernels; simple but heavy (VGG-16 is about 138M parameters, mostly in its fully connected layers)
ResNet (He, Zhang, Ren, Sun)2015Residual connections y=x+F(x)y = x + F(x), up to 152 layersIdentity shortcuts make very deep nets trainable; ResNet-152 used about 11.3 GFLOPs versus 19.6 for VGG-19 and won ILSVRC 2015
EfficientNet (Tan & Le)2019Compound scaling of depth, width and resolution together (α=1.2\alpha=1.2, β=1.1\beta=1.1, γ=1.15\gamma=1.15)Scale all three dimensions in balance; B7 reached 84.3% ImageNet top-1 with about 66M parameters

Why residuals work. In a residual block the layers learn a correction F(x)F(x) rather than a full mapping, and the gradient has a direct path: ∂y/∂x=I+∂F/∂x\partial y / \partial x = I + \partial F / \partial x. Even if ∂F/∂x\partial F/\partial x is small, the identity term carries signal backward through dozens of blocks. A bottleneck block (1×1 conv to reduce channels, 3×3 conv, 1×1 conv to expand) keeps the cost of deep networks manageable; ResNet-50 and deeper use it.

1×1 convolutions mix channels at each pixel without looking at neighbours. They are cheap channel-wise linear layers used to shrink or expand channel counts. Depthwise-separable convolutions (a per-channel k×kk \times k conv followed by a 1×1 conv) cut parameters from k2CinCoutk^2 C_{in} C_{out} to k2Cin+CinCoutk^2 C_{in} + C_{in} C_{out} and are the basis of mobile architectures, including EfficientNet's building blocks.

Transfer learning with a pretrained backbone

Training a CNN from scratch needs a large labelled dataset. In practice you almost always start from a backbone pretrained on a large dataset such as ImageNet, because its early layers already detect generic edges, colours and textures. There are two standard regimes:

  • Feature extraction (linear probing): freeze the backbone, replace the final classifier, train only the new head. Fast, needs little data, hard to overfit.
  • Fine-tuning: unfreeze some or all of the backbone and train with a small learning rate (often 10× smaller than for the head), so the pretrained features adapt without being destroyed.
import torch, torch.nn as nn
from torchvision import models, transforms

weights = models.ResNet18_Weights.DEFAULT
model = models.resnet18(weights=weights)
for p in model.parameters():
    p.requires_grad = False                         # stage 1: freeze backbone
model.fc = nn.Linear(model.fc.in_features, 5)       # new trainable head, 5 classes

train_tf = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.6, 1.0)),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(0.2, 0.2, 0.2),
    transforms.ToTensor(),
    transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
eval_tf = weights.transforms()                      # preprocessing matching pretraining
head_opt = torch.optim.AdamW(model.fc.parameters(), lr=1e-3)

# stage 2: unfreeze the last stage and fine-tune with discriminative learning rates
for p in model.layer4.parameters():
    p.requires_grad = True
ft_opt = torch.optim.AdamW([
    {"params": model.layer4.parameters(), "lr": 1e-4},
    {"params": model.fc.parameters(), "lr": 1e-3},
], weight_decay=0.01)

The Normalize statistics are the ImageNet channel means and standard deviations the backbone was trained with; using different preprocessing at inference than at pretraining silently costs accuracy. The weights enum API shown here is the current torchvision interface (older code used pretrained=True).

Data augmentation

Data augmentation applies random, label-preserving transformations to training images so the model sees a different version of each image every epoch. It is a regularizer that encodes invariances you believe in. Standard choices: random resized crops, horizontal flips, small rotations, colour jitter; stronger recipes add RandAugment-style random policies, Mixup (blend two images and their labels) and CutMix (paste a patch from one image into another and mix labels in proportion to area). Augment only the training set, and choose transformations that truly preserve the label: horizontal flips are fine for cats, wrong for reading text, and vertical flips are usually wrong for street scenes but fine for satellite imagery.

CNNs versus vision transformers

A vision transformer (ViT) (Dosovitskiy et al., 2020) cuts an image into fixed patches — 16×16 pixels for ViT-B/16, so a 224×224 image becomes 14×14=19614 \times 14 = 196 tokens — linearly embeds each patch (equivalent to a convolution with kernel size and stride both 16), adds position embeddings, and runs a standard transformer encoder (see the transformers lesson). Every patch can attend to every other patch from the first layer, so the receptive field is global immediately.

AspectCNNVision transformer
Inductive biasStrong: locality, translation equivarianceWeak: must learn spatial structure from data
Data needsGood from thousands of images, especially with pretrainingThe original ViT trailed comparable ResNets when trained on ImageNet alone and excelled only after pretraining on much larger sets (ImageNet-21k, JFT-300M)
Compute vs resolutionLinear in pixel countSelf-attention is quadratic in the number of patches
Global contextBuilt up gradually with depthAvailable in every layer
Scaling with dataSaturates earlierKeeps improving with more data and compute

In practice the gap has narrowed from both sides: transformers borrowed hierarchical, local-window designs, and modernized CNNs adopted transformer training recipes. As a rule of thumb: for small data, edge devices or high resolution, start with a CNN or a hybrid; when a large pretrained ViT backbone is available for your domain, fine-tuning it is often the strongest option.

Common pitfalls

  • Wrong preprocessing for a pretrained model: mismatched resize, crop or normalization statistics; or feeding BGR images (OpenCV's default) to an RGB-trained model.
  • Forgetting model.eval() at inference: batch-norm then uses the current batch's statistics and dropout stays active, giving noisy, batch-dependent predictions.
  • Fine-tuning with a high learning rate: wipes out pretrained features in a few hundred steps. Use a low rate for the backbone and warm up.
  • Tiny batch sizes with batch norm: statistics become noisy. Freeze BN statistics or use GroupNorm.
  • Augmentations that change the label: flipping digits, rotating text, colour-jittering tasks where colour is the signal.
  • Shape bugs: channels-last arrays fed to a channels-first API, or miscomputed output sizes. Compute sizes with the formula, or print them after each layer.
  • Leakage across splits: near-duplicate images (frames from the same video, the same patient) in both train and test make results look far better than they are.

In practice

  • Start with a pretrained backbone from a maintained library (torchvision, timm, Hugging Face) rather than a hand-built architecture. ResNet-50 and EfficientNet-class models remain strong, cheap baselines; try a pretrained ViT or modern hybrid if latency allows.
  • Train in two stages — head only, then partial or full unfreezing with discriminative learning rates — and track validation metrics per stage.
  • Use mixed precision, channels-last memory format on GPUs, and a learning-rate schedule with warmup and cosine decay.
  • Budget compute with the MAC formula; resolution is usually the most expensive knob because cost scales with H×WH \times W.
  • For deployment, export to ONNX or a runtime such as TensorRT or Core ML, quantize to INT8 where accuracy allows, and benchmark latency on the target device, not your workstation.
  • The capstone capstone-image-classifier walks through this pipeline end to end.

Key takeaways

  • A convolution slides a small learned kernel over the input, so its parameter count is independent of image size and the same detector is reused everywhere.
  • Output width is floor((W - k + 2P)/S) + 1, and receptive field grows by (k - 1) times the product of earlier strides at each layer.
  • Stacks of 3x3 convolutions reach large receptive fields with fewer parameters and more nonlinearities than single large kernels.
  • Residual connections give gradients an identity path, which is what made networks with 100+ layers trainable.
  • In practice you fine-tune a pretrained backbone with matching preprocessing, low backbone learning rates and label-preserving augmentation.
  • CNNs carry strong locality bias and need less data; vision transformers have global attention from layer one and scale better with large pretraining data.

Terms in this lesson

Kernel (filter)
A small learned weight tensor spanning all input channels that is slid across the input to produce one output feature map.
Stride
The number of input positions the kernel moves between successive outputs; a stride of 2 roughly halves each spatial dimension.
Padding
Extra border values (usually zeros) added around the input so kernels can be applied at the edges and output size can be controlled.
Receptive field
The region of the input image that can influence a given unit in a deeper layer.
Pooling
A fixed, non-learned downsampling operation, such as taking the maximum or average over each local window of a feature map.
Residual connection
A shortcut that adds a block's input to its output, y = x + F(x), so the block learns a correction and gradients flow through the identity path.
Transfer learning
Reusing a network pretrained on a large dataset as the starting point for a new task, either by training only a new head or by fine-tuning some layers.
Data augmentation
Applying random label-preserving transformations to training inputs to expose the model to more variation and reduce overfitting.
Inductive bias
Assumptions built into a model's architecture, such as locality and weight sharing in CNNs, that shape what it can learn easily from limited data.

Check your understanding

  1. 1.

    A 32x32 input passes through a 5x5 convolution with stride 1 and no padding, then a 2x2 max-pool with stride 2. What is the output height and width?

  2. 2.

    How many parameters (weights plus biases) does a 3x3 convolution from 3 input channels to 16 output channels have?

  3. 3.

    Why did VGG replace large kernels with stacks of 3x3 convolutions?

  4. 4.

    You have 1,500 labelled photos across 4 classes and a modest GPU. Which approach is most likely to give the best accuracy quickly?

  5. 5.

    What is the main reason residual connections let very deep networks train?

Exercises

Work each problem before opening the solution.

  1. Exercise 1Core

    A 128x128x3 image passes through: (1) Conv 3->32, 3x3, stride 1, padding 1; (2) MaxPool 2x2, stride 2; (3) Conv 32->64, 5x5, stride 2, padding 2; (4) Conv 64->64, 3x3, stride 1, padding 0.

    For each layer give the output shape, the parameter count, and the receptive field (in input pixels) of one output unit.

    Show solution

    Use Wout=⌊(W−k+2P)/S⌋+1W_{out} = \lfloor (W - k + 2P)/S \rfloor + 1, params =k2CinCout+Cout= k^2 C_{in} C_{out} + C_{out}, and rl=rl−1+(kl−1)jl−1r_l = r_{l-1} + (k_l - 1) j_{l-1} where jj is the product of earlier strides.

    LayerOutputParamsReceptive fieldJump after
    Conv 3x3 s1 p1(128-3+2)/1+1 = 128 -> 128x128x32333*32+32 = 8961 + 2*1 = 31
    MaxPool 2 s2(128-2)/2+1 = 64 -> 64x64x3203 + 1*1 = 42
    Conv 5x5 s2 p2floor((64-5+4)/2)+1 = floor(31.5)+1 = 32 -> 32x32x645532*64+64 = 51,2644 + 4*2 = 124
    Conv 3x3 s1 p0(32-3)/1+1 = 30 -> 30x30x643364*64+64 = 36,92812 + 2*4 = 204

    Total parameters: 896 + 51,264 + 36,928 = 89,088. Each final unit sees a 20x20 patch of the input.

  2. Exercise 2Core

    Implement conv2d(x, w, b, stride=1, padding=0) in NumPy for multi-channel input x of shape (C_in, H, W), weights w of shape (C_out, C_in, k, k) and bias b of shape (C_out,). Verify it against torch.nn.functional.conv2d on random data.

    Show solution
    import numpy as np, torch, torch.nn.functional as F
    
    def conv2d(x, w, b, stride=1, padding=0):
        C_in, H, W = x.shape
        C_out, _, k, _ = w.shape
        xp = np.pad(x, ((0, 0), (padding, padding), (padding, padding)))
        H_out = (H - k + 2 * padding) // stride + 1
        W_out = (W - k + 2 * padding) // stride + 1
        out = np.zeros((C_out, H_out, W_out), dtype=x.dtype)
        for i in range(H_out):
            for j in range(W_out):
                patch = xp[:, i*stride:i*stride+k, j*stride:j*stride+k]  # (C_in, k, k)
                # contract over C_in, k, k for every output channel at once
                out[:, i, j] = np.tensordot(w, patch, axes=([1, 2, 3], [0, 1, 2])) + b
        return out
    
    rng = np.random.default_rng(0)
    x = rng.standard_normal((3, 9, 9)).astype(np.float32)
    w = rng.standard_normal((4, 3, 3, 3)).astype(np.float32)
    b = rng.standard_normal(4).astype(np.float32)
    mine = conv2d(x, w, b, stride=2, padding=1)
    ref = F.conv2d(torch.from_numpy(x)[None], torch.from_numpy(w), torch.from_numpy(b),
                   stride=2, padding=1)[0].numpy()
    print(mine.shape, np.allclose(mine, ref, atol=1e-5))   # (4, 5, 5) True
    

    np.pad with a 3-tuple of pad widths pads only the spatial axes. The output size is (9 - 3 + 2)/2 + 1 = 5. The tensordot sums over input channels and both kernel axes, which is exactly the formula Yo,i,j=bo+∑c,u,vKo,c,u,vXc,iS+u,jS+vY_{o,i,j} = b_o + \sum_{c,u,v} K_{o,c,u,v} X_{c,iS+u,jS+v}.

  3. Exercise 3Challenge

    With C = 256 channels in and out (ignore biases), compare (a) one 7x7 convolution, (b) three stacked 3x3 convolutions and (c) a depthwise-separable 7x7 (depthwise 7x7 then pointwise 1x1). Give parameter counts, receptive fields, and the number of nonlinearities if each conv is followed by ReLU. Which would you choose for a mobile model and why?

    Show solution

    C2=65,536C^2 = 65{,}536.

    • (a) One 7x7: 49C2=3,211,26449 C^2 = 3{,}211{,}264 weights; receptive field 7; 1 ReLU.
    • (b) Three 3x3: 3×9C2=27C2=1,769,4723 \times 9 C^2 = 27 C^2 = 1{,}769{,}472 weights; receptive field 1+2+2+2=71 + 2 + 2 + 2 = 7; 3 ReLUs.
    • (c) Depthwise-separable: depthwise 49C=12,54449 C = 12{,}544 plus pointwise C2=65,536C^2 = 65{,}536, total 78,08078{,}080 weights; receptive field 7; 1 or 2 ReLUs depending on placement.

    Compute scales the same way per output pixel. For a mobile model, (c) is the clear choice: about 41x fewer weights than (a) and about 23x fewer than (b) for the same receptive field. The trade-off is that the depthwise step does not mix channels, so separable layers are somewhat less expressive per layer and are run in efficient blocks (as in MobileNet and EfficientNet). Option (b) is the classic VGG-style compromise when compute is less constrained.

  4. Exercise 4Challenge

    You must classify chest X-rays into 3 classes and have 2,000 labelled images from 400 patients. Write a training plan and the core PyTorch training loop for fine-tuning a pretrained ResNet-18, covering: data split, augmentation choices (and which to avoid), the two-stage freezing schedule, batch-norm handling, and evaluation.

    Show solution

    Plan.

    1. Split by patient, not by image (e.g. 280/60/60 patients for train/val/test) so images of one patient never appear in two splits.
    2. Augmentation: small random resized crops (scale 0.8-1.0), small rotations (+/-10 degrees), brightness/contrast jitter. Avoid horizontal flips if laterality matters (heart position, text markers) and avoid hue jitter (X-rays are grayscale; replicate the single channel to 3 channels to match the backbone).
    3. Stage 1: freeze the backbone, train a new 3-way head for a few epochs at lr 1e-3. Stage 2: unfreeze layer3 and layer4, lr 1e-4 for them and 1e-3 for the head, with cosine decay.
    4. Batch norm: with small batches keep frozen layers in eval mode so their running statistics are not overwritten.
    5. Evaluate with per-class recall, macro-F1 and a confusion matrix on the patient-held-out test set, once, at the end.
    import torch, torch.nn as nn
    from torchvision import models
    
    model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
    model.fc = nn.Linear(model.fc.in_features, 3)
    
    def set_stage(model, stage):
        for name, p in model.named_parameters():
            trainable = name.startswith("fc") or (stage == 2 and name.startswith(("layer3", "layer4")))
            p.requires_grad = trainable
    
    def train_epoch(model, loader, opt, device):
        model.train()
        for m in model.modules():                 # keep BN stats fixed in frozen parts
            if isinstance(m, nn.BatchNorm2d) and not next(m.parameters()).requires_grad:
                m.eval()
        loss_fn = nn.CrossEntropyLoss()
        for x, y in loader:
            x, y = x.to(device), y.to(device)
            loss = loss_fn(model(x), y)
            opt.zero_grad(); loss.backward(); opt.step()
    
    @torch.no_grad()
    def evaluate(model, loader, device):
        model.eval(); preds, labels = [], []
        for x, y in loader:
            preds.append(model(x.to(device)).argmax(1).cpu()); labels.append(y)
        return torch.cat(preds), torch.cat(labels)
    
    set_stage(model, 1)
    opt1 = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-3)
    # ... run train_epoch for a few epochs with opt1, then:
    set_stage(model, 2)
    opt2 = torch.optim.AdamW([
        {"params": [p for n, p in model.named_parameters() if n.startswith(("layer3", "layer4"))], "lr": 1e-4},
        {"params": model.fc.parameters(), "lr": 1e-3}], weight_decay=0.01)
    

    The loaders (not shown) apply the training transforms only to the training set and the pretrained model's resize/normalize transforms to validation and test. Select the checkpoint on validation macro-F1, then report test metrics once.

Further reading

Go further

Sources

Fact-checked and last reviewed 2026-11-05

Stay in the loop
Prometheus OS™ · Native AI OS · Built for the future © 2025 - 2026