← Back to the learning map

DEEP LEARNING / WEEK 04

Convolutional Neural Networks

A useful visual pattern should not need to be learned again at every pixel. Build a network that understands neighbourhoods, reuses detectors, and gradually combines local evidence.

7 lessonsInteractive labPython experimentPractice + answers

Bring with you: Matrix multiplication, gradients, ReLU, and the training/validation discipline from Week 3.

BY THE END OF THIS WEEK

  • Calculate a convolution by hand and track every tensor dimension.
  • Explain locality, weight sharing, equivariance, and their limits.
  • Compute parameters, receptive fields, and approximate convolution cost.
  • Train a small CNN and defend its architecture against a dense baseline.

LESSON 4.1

Why images need a structural assumption

An image is an array with a neighbourhood structure: nearby pixels often belong to the same edge, texture, or object part. Flattening an image retains its values, but a fully connected layer does not explicitly encode which values were neighbours. It assigns a separate coefficient to every input–output connection.

A convolutional layer makes two assumptions. A local patch can contain useful evidence, and the same pattern may matter at multiple locations. Local connectivity restricts where a detector looks; weight sharing reuses that detector across the image. These are inductive biases, not universal truths. Absolute position or long-range relations may require other mechanisms.

Keep this idea. Convolution changes the model's assumptions and parameterization; it does not magically add information to the image.

LESSON 4.2

From one patch to a feature map

Slide a small kernel over the input. At each location, multiply corresponding entries, sum them, and add a bias. Applying the same kernel repeatedly produces one feature map. A kernel is a learned set of coefficients; its feature map depends on the current input.

Most deep learning libraries compute cross-correlation: the kernel is not flipped before the dot product. Calling it convolution is the standard convention in this setting. With several input channels, one output detector has a separate spatial kernel for each input channel and sums their contributions.

Y[o, i, j] = b[o] + Σc Σu Σv W[o,c,u,v] X[c, i·s+u−p, j·s+v−p]

INTERACTIVE · SLIDE THE KERNEL

Compute a feature map, one window at a time

Every number below is calculated from the input, the kernel, the stride and the padding — change any of them and the whole map is recomputed. Select an output cell to see the nine products that produced it.

INPUT 7×7

0009999000999900099990009999000999900099990009999

KERNEL 3×3

10-110-110-1

OUTPUT 7×7

00-18-18001800-27-27002700-27-27002700-27-27002700-27-27002700-27-27002700-18-180018
Output size
7×7
floor((n + 2p − k) / s) + 1
floor((7 + 2 − 3) / 1) + 1
Weights (1 channel)
9 + 1 bias
Multiply–accumulates
441

LESSON 4.3

Stride, padding, and output dimensions

Stride determines the distance between successive window origins. Padding extends the boundary so edge locations can participate in more windows. Zero, reflection, and replication padding impose different assumptions about what lies beyond the observed image.

Dilation spaces out kernel entries. Its effective width is d(k−1)+1. Apply the output-size formula independently to height and width; floor division matters when a final partial window does not fit. A shape calculation should precede a training run.

Hout = floor((Hin + 2p − d(k−1) − 1) / s) + 1
Wout uses the same formula with Win.
Weight shape: (Cout, Cin / groups, kh, kw)
Input / kernel / padding / strideOutput sizeReason
32 / 3 / 1 / 132One pixel of padding preserves size.
32 / 5 / 0 / 128No windows extend past the boundary.
28 / 3 / 1 / 214Stride reduces sampling density.
64 / 7 / 3 / 232A larger window can still halve resolution.

LESSON 4.4

Count parameters, then count computation

For an ordinary convolution with groups = 1, each output channel sees all input channels. The trainable weights number kh × kw × Cin × Cout, plus Cout biases when enabled. The number of spatial positions does not change this parameter count.

It does change compute and activation memory. Reusing each filter at Hout × Wout locations creates many multiply–accumulate operations. A layer with few parameters can be expensive on a large image. A dense layer and a convolutional layer also produce different representations, so raw parameter counts alone are not a fair accuracy comparison.

Parameters = kh·kw·Cin·Cout + Cout
Approximate MACs = Hout·Wout·kh·kw·Cin·Cout

LESSON 4.5

How small windows see larger structures

A later unit combines earlier units, each of which already depends on an input neighbourhood. This composition increases the theoretical receptive field. Keep two quantities: r, the receptive-field size, and j, the distance in input pixels between adjacent feature positions.

The theoretical receptive field describes possible dependence. The effective influence of different input pixels depends on learned weights, activations, and data. A large theoretical field does not prove a model uses distant context.

Start: r₀ = 1, j₀ = 1
rℓ = rℓ₋₁ + (kℓ − 1)·dℓ·jℓ₋₁
jℓ = jℓ₋₁·sℓ
LayerReceptive field rJump j
3×3 convolution, stride 131
3×3 convolution, stride 151
2×2 pool, stride 262
3×3 convolution, stride 1102

LESSON 4.6

Downsampling, equivariance, and invariance

Max pooling selects the largest local response; average pooling summarizes local response strength. A strided convolution instead learns its reduction. All three reduce spatial resolution, which saves later computation but can remove fine detail or alias high-frequency patterns.

Away from boundaries, stride-one convolution is translation equivariant: translating the input translates the feature map. Invariance means the output does not change. Pooling and global aggregation can encourage some tolerance to position, but arbitrary translations, padding, and stride prevent a blanket guarantee of invariance.

OperationWhat it preserves or learnsWhat to watch
Max poolingPresence of a strong local responseWeak but useful details can disappear.
Average poolingAverage local evidenceSmall sharp features may be diluted.
Strided convolutionA learned spatial reductionMore parameters; aliasing remains possible.
Global average poolingOne mean per channelDiscards precise location information.

LESSON 4.7

Build a complete classifier

Separate a feature extractor from a prediction head. For a 28 × 28 grayscale image, two padded 3 × 3 convolutions with 2 × 2 pooling leave a 32 × 7 × 7 tensor. Flatten only after the convolutional stages, then produce one logit per class.

Use cross-entropy on raw logits with integer class labels. During training, inspect shapes, loss, and validation errors. Feature maps can reveal dead filters or unexpected responses, but a bright activation is not proof that a filter represents a named human concept.

model = nn.Sequential(
    nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Flatten(), nn.Linear(32 * 7 * 7, 10),
)
# images: [batch, 1, 28, 28]; labels: [batch], dtype long
loss = F.cross_entropy(model(images), labels)

Keep this idea. Choose the smallest architecture that tests your hypothesis, then use validation evidence to justify complexity.

WHEN THE MODEL SURPRISES YOU

Mistakes worth catching early

A flattening mismatch

Write down channels × height × width before Linear. Changing a stride changes the head's input size.

An augmentation changes the label

A vertical/horizontal stripe task cannot use a 90° rotation while keeping its label. Check the task semantics first.

The parameter count tells the whole story

Report resolution, MACs or measured runtime, and activation memory alongside parameter count.

A beautiful feature map proves understanding

Inspect held-out mistakes and controlled perturbations; visualization alone does not establish causal reliance.

FROM EQUATIONS TO A RUNNING MODEL

A CNN learns orientation

Train a two-stage CNN on noisy 16 × 16 horizontal and vertical stripes. The script creates fixed train, validation, and test sets, selects a checkpoint using validation loss, and reports the final test accuracy plus the feature shapes and parameter count.

Download the script, create a Python environment, and run the commands below in the folder containing the download. The example uses PyTorch on CPU and generates its own small dataset. No dataset or model download is required.

python -m venv .venv
# macOS / Linux: source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install torch
python dl-week-4.py
# Fast execution check (not a convergence result):
python dl-week-4.py --smoke
Download Week 4 Python ↓
Read the complete, runnable experiment
"""DL Week 4: Convolutional Neural Networks.

An independent teaching example accompanying the Algorithms II Week 5 material.
Run with: python -m pip install torch
          python dl-week-4.py

Builds a synthetic stripe-orientation task locally, trains a small CNN and a
dense baseline under the same split and budget, selects each by validation
loss, and reports held-out accuracy plus a shifted/noisier stress set.
No dataset or checkpoint is downloaded.
"""
import argparse
import copy

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

torch.set_num_threads(1)

SIZE = 16
CLASSES = ("horizontal", "vertical")


def make_stripes(count, seed, noise=0.9, phases=(0,)):
    """Half horizontal, half vertical stripes, drawn only at the given phases.

    Training uses one phase; the stress set uses phases never seen. The label
    rule does not depend on phase, so a model that has learned orientation
    rather than pixel positions should survive the shift.
    """
    g = torch.Generator().manual_seed(seed)
    images = torch.zeros(count, 1, SIZE, SIZE)
    labels = torch.zeros(count, dtype=torch.long)
    for i in range(count):
        vertical = i % 2
        period = 4
        phase = phases[int(torch.randint(0, len(phases), (1,), generator=g).item())]
        band = ((torch.arange(SIZE) + phase) % period < period // 2).float()
        images[i, 0] = band.repeat(SIZE, 1) if vertical else band.unsqueeze(1).repeat(1, SIZE)
        labels[i] = vertical
    images += noise * torch.randn(images.shape, generator=g)
    return TensorDataset(images, labels)


class SmallCNN(nn.Module):
    """Two convolution stages, then one linear head. Shapes are printed once."""

    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
        )
        self.head = nn.Sequential(nn.Flatten(), nn.Linear(16 * 4 * 4, 2))

    def forward(self, x):
        return self.head(self.features(x))


class DenseBaseline(nn.Module):
    """A flatten-first model, given a comparable parameter budget."""

    def __init__(self, hidden=24):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(), nn.Linear(SIZE * SIZE, hidden), nn.ReLU(), nn.Linear(hidden, 2)
        )

    def forward(self, x):
        return self.net(x)


def evaluate(model, loader):
    model.eval()
    loss_sum = correct = total = 0
    with torch.no_grad():
        for images, labels in loader:
            logits = model(images)
            loss_sum += nn.functional.cross_entropy(logits, labels, reduction="sum").item()
            correct += (logits.argmax(1) == labels).sum().item()
            total += labels.numel()
    return loss_sum / total, correct / total


def train(model, train_loader, val_loader, epochs, lr=3e-3):
    """Fit, keeping the parameters that gave the lowest validation loss."""
    optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
    best_loss, best_state, best_epoch = float("inf"), copy.deepcopy(model.state_dict()), 0
    for epoch in range(1, epochs + 1):
        model.train()
        for images, labels in train_loader:
            optimizer.zero_grad()
            loss = nn.functional.cross_entropy(model(images), labels)
            loss.backward()
            optimizer.step()
        val_loss, val_acc = evaluate(model, val_loader)
        if val_loss < best_loss:
            best_loss, best_state, best_epoch = val_loss, copy.deepcopy(model.state_dict()), epoch
        print(f"    epoch {epoch:2d}  val loss {val_loss:.4f}  val acc {val_acc:.3f}")
    model.load_state_dict(best_state)
    return best_loss, best_epoch


def count_parameters(model):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--smoke", action="store_true", help="execution check, not a convergence result")
    args = parser.parse_args()

    torch.manual_seed(0)
    epochs = 2 if args.smoke else 12
    n_train = 128 if args.smoke else 1024

    # Train, validate and test on one stripe phase; stress on phases never seen.
    train_set = make_stripes(n_train, seed=1, phases=(0,))
    val_set = make_stripes(256, seed=2, phases=(0,))
    test_set = make_stripes(256, seed=3, phases=(0,))
    stress_set = make_stripes(256, seed=4, phases=(1, 2, 3))

    train_loader = DataLoader(train_set, batch_size=32, shuffle=True)
    val_loader = DataLoader(val_set, batch_size=128)
    test_loader = DataLoader(test_set, batch_size=128)
    stress_loader = DataLoader(stress_set, batch_size=128)

    sample = train_set[0][0].unsqueeze(0)
    probe = SmallCNN()
    print("Tensor shapes through the CNN")
    print(f"  input           {tuple(sample.shape)}")
    with torch.no_grad():
        feats = probe.features(sample)
    print(f"  after features  {tuple(feats.shape)}")
    print(f"  logits          {tuple(probe(sample).shape)}   (expected [1, 2])")

    results = {}
    for name, model in (("CNN", SmallCNN()), ("Dense baseline", DenseBaseline())):
        print(f"\n{name} — {count_parameters(model):,} trainable parameters")
        before_loss, before_acc = evaluate(model, val_loader)
        print(f"    before training: val loss {before_loss:.4f}  val acc {before_acc:.3f}")
        best_loss, best_epoch = train(model, train_loader, val_loader, epochs)
        test_loss, test_acc = evaluate(model, test_loader)
        stress_loss, stress_acc = evaluate(model, stress_loader)
        results[name] = dict(params=count_parameters(model), before=before_loss, best=best_loss,
                             epoch=best_epoch, test=test_acc, stress=stress_acc)
        print(f"    selected epoch {best_epoch} (val loss {best_loss:.4f})")
        print(f"    test accuracy {test_acc:.3f}   shifted-phase stress set {stress_acc:.3f}")

    print("\nSummary")
    print(f"  {'model':<16}{'params':>9}{'val before':>12}{'val best':>10}{'test':>8}{'shifted':>9}")
    for name, r in results.items():
        print(f"  {name:<16}{r['params']:>9,}{r['before']:>12.4f}{r['best']:>10.4f}{r['test']:>8.3f}{r['stress']:>9.3f}")
    print("\nThe test column uses the phase the models trained on; the stress column")
    print("uses phases they never saw. A gap between the two is the quantity of")
    print("interest, not the headline accuracy. One run on one synthetic task —")
    print("repeat across seeds before treating either architecture as better.")
    if args.smoke:
        print("Smoke run: execution only. These numbers are not a convergence result.")


if __name__ == "__main__":
    main()

Read the output, then challenge it

  • The output must have shape [batch, 2], with finite cross-entropy and gradients.
  • Compare validation loss before and after training; a smoke run only checks execution.
  • Shift the stripe or increase noise in a separate stress set. State which change should preserve its label.
  • Replace the feature extractor with a dense baseline and keep the split and tuning budget fixed.

These are small mechanism studies, not benchmark claims. Seeds make runs easier to compare; versions and hardware can still change exact results. Decide your validation protocol before tuning and reserve final test results for reporting.

PREDICT → CALCULATE → EXPLAIN

Practice with worked answers

1. Design a 64 × 64 RGB classifier with fewer than 250,000 parameters.

Check your reasoning

One option is padded 3×3 convolutions 3→16→32→64, each followed by ReLU and 2×2 pooling; global average pooling; and Linear(64,10). With biases: 448 + 4,640 + 18,496 + 650 = 24,234 parameters. Explain whether losing location information is appropriate for your task.

2. What does dilation 2 do to a 3 × 3 kernel on a 32 × 32 input?

Check your reasoning

Its effective width is 5. With stride 1 and padding 2, output size remains 32. It still has nine spatial weights per input/output channel pair, not 25.

3. Why can a CNN still fail after an image shifts by one pixel?

Check your reasoning

Stride and pooling change which samples are grouped. Padding and cropping alter boundary evidence. Equivariance for an ideal stride-one operator does not imply invariance of the whole classifier.

4. Plan a fair dense-versus-CNN experiment.

Check your reasoning

Fix data splits, preprocessing, optimizer search budget, and stopping criteria. Compare validation-selected models, parameters, runtime, and error types; report the reserved test result once. Repeat with multiple seeds before claiming a reliable advantage.

EXPLAIN IT WITHOUT THE CODE

Week 4 review questions

01What is shared in weight sharing?

The same kernel coefficients and output-channel bias are used at each spatial location.

02Does one filter look at only one input channel?

For ordinary convolution it spans every input channel; depthwise and grouped convolutions alter that connectivity.

03What is the difference between a kernel and a feature map?

The kernel is a parameter tensor. The feature map is an input-dependent activation tensor produced by applying it.

04Does padding add measured information?

No. It supplies an assumed boundary extension, such as zeros or reflected values.

05Why stack small kernels?

Composition increases receptive field and inserts nonlinearities, while often using fewer weights than a single wide kernel at comparable channel widths.

06Why can fewer parameters still mean high computation?

Each shared parameter is reused across many output positions, and those operations must still be executed.

07When might a CNN be the wrong bias?

When local translation patterns are unhelpful or absolute positions and distant interactions dominate, other architectures or added positional mechanisms may fit better.

08What should you inspect besides accuracy?

Loss curves, per-class mistakes, sensitivity to transformations, parameter count, runtime, and memory.

RESEARCH CONNECTIONS

Follow the original ideas

PyTorch: Conv2d ↗

Exact channel, kernel, stride, padding, dilation, and output-shape conventions.

Course alignment: AI Algorithms 2, lecture Week 5. This site counts from Week 0. Explanations, browser labs, and runnable experiments are authored study companions to the supplied lecture material.

CONNECT THE LAYERS

We can now build spatial hierarchies. Next, we ask why making them deeper can make training worse—and how residual connections and pretrained features help.

See the complete learning map →