← Back to the learning map

DEEP LEARNING / WEEK 05

Residual Networks and Transfer Learning

Depth gives a model more opportunities to transform a representation. Residual paths make those transformations easier to optimize; transfer learning lets us begin with features learned from another task.

7 lessonsInteractive labPython experimentPractice + answers

Bring with you: CNN tensor shapes, backpropagation, overfitting, and validation-based model selection.

BY THE END OF THIS WEEK

  • Distinguish optimization degradation from overfitting.
  • Derive the residual gradient path and implement a projection shortcut.
  • Choose between frozen features and fine-tuning with an explicit BatchNorm policy.
  • Compare transfer and training from scratch without hiding their different data budgets.

LESSON 5.1

When a deeper network trains worse

A deeper model may have enough capacity to represent a shallower model's solution yet fail to find it with the chosen training procedure. If both training and validation error rise with depth, suspect an optimization problem before blaming overfitting.

Overfitting has a different signature: training improves while held-out performance deteriorates. These explanations can coexist. Compare curves under controlled initialization, learning-rate tuning, and training budgets rather than diagnosing from the final validation score alone.

LESSON 5.2

Learn a correction to the current representation

A residual block outputs y = x + F(x). If preserving the input is useful, the residual branch can learn a small correction instead of reconstructing the identity through every nonlinear layer. This changes the optimization problem without preventing substantial transformations.

Differentiating the addition gives an identity term alongside the residual branch's Jacobian. That provides a direct route for a gradient, but it is not an unconditional guarantee: the branch can cancel the identity, and activations after the sum can block components. In a vector network, matrix products and directions matter, not just a single scalar multiplier.

y = x + F(x; θ)
∂y/∂x = I + JF(x)
∇x L = (I + JF(x))ᵀ ∇y L

INTERACTIVE · GRADIENT THROUGH DEPTH

Why the shortcut changes the product

Stack L identical blocks whose branch is F(x) = a·x. A plain stack multiplies a at every layer; a residual stack multiplies (1 + a). Both curves below are evaluated directly, on a logarithmic axis.

1e-301e-201e-101e01e6solid: residual (1+a)ᴸ · dashed: plain aᴸ

The vertical axis shows absolute derivative magnitude; values below 10⁻³⁰ are drawn at the floor. Signed values remain visible below.

Plain stack aᴸ
1.00e-10
Residual (1+a)ᴸ
2.594
Ratio
2.59e+10
Per-layer factor
1.1

Keep this idea. This is the case the shortcut is for: the plain product has collapsed while the residual one is still a usable size.

LESSON 5.3

Make both branches agree on shape

Addition requires compatible tensors. An identity shortcut works when channels and spatial dimensions are unchanged. If a block downsamples or expands channels, use a projection on the shortcut, commonly a 1 × 1 convolution with the required stride.

A projection is learned and is no longer a literal identity. Track both branches explicitly. Broadcasting a mistaken shape may run without representing the architecture you intended.

Input: [N, 64, 56, 56]
Residual: 3×3, stride 2, 64→128 → [N, 128, 28, 28]
Shortcut: 1×1, stride 2, 64→128 → [N, 128, 28, 28]
Sum: [N, 128, 28, 28]
shortcut = nn.Conv2d(64, 128, 1, stride=2, bias=False)
branch = nn.Sequential(
    nn.Conv2d(64, 128, 3, stride=2, padding=1, bias=False),
    nn.BatchNorm2d(128), nn.ReLU(),
    nn.Conv2d(128, 128, 3, padding=1, bias=False),
    nn.BatchNorm2d(128),
)
y = F.relu(branch(x) + shortcut(x))

LESSON 5.4

Activation order and BatchNorm state

A post-activation block applies an activation after adding the branches. A pre-activation design moves normalization and activation before the convolutions and can leave the addition path unobstructed by a final ReLU. Treat these as distinct designs; moving layers changes the function and gradient routes.

BatchNorm has both trainable affine parameters and running statistics. Setting requires_grad=False freezes parameter gradients but does not stop running mean and variance updates in training mode. Conversely, eval() changes BatchNorm and dropout behavior but does not disable autograd. A frozen feature extractor normally needs an explicit evaluation-mode policy.

LESSON 5.5

Reuse features, then choose what may adapt

Pretraining learns parameters on a source task or dataset. Transfer learning reuses some of those parameters for a target task. Earlier visual features often transfer more broadly than a source-specific classifier, but domain and acquisition differences can make that intuition fail.

Feature extraction freezes the backbone and trains a replacement head. Fine-tuning updates selected backbone layers or the entire model. The useful choice depends on label count, domain similarity, compute, and validation evidence. Replacing an RGB input with grayscale or changing normalization and resolution also changes the inputs the pretrained filters receive.

Target situationStarting hypothesisEvidence to collect
Few labels, similar imageryFrozen backbone + new headProbe performance and overfitting gap
More labels, similar imageryFine-tune upper blocks firstValidation gains versus added cost
Different acquisition or domainCompare partial/full adaptation and scratchWhether source features cause negative transfer
Tiny dataset, large domain shiftConservative tuning and strong baselinesUncertainty across splits or seeds

Keep this idea. A transferred model has benefited from source data. Record that advantage instead of treating it as the same data budget as training from scratch.

LESSON 5.6

Implement an explicit adaptation policy

First train a newly initialized head while holding the backbone fixed. If validation supports further adaptation, unfreeze selected layers and use a smaller learning rate there. Differential rates are a practical hypothesis, not a universal optimum.

An optimizer only updates the parameters assigned to it. Rebuild or extend parameter groups when unfreezing. During every training epoch, model.train() can recursively put a frozen backbone's BatchNorm layers back into training mode; restore the intended backbone mode afterwards.

for p in backbone.parameters():
    p.requires_grad_(False)
backbone.eval()
head.train()
with torch.no_grad():
    features = backbone(images)
loss = F.cross_entropy(head(features), labels)

# Later: fine-tuning, with fresh optimizer groups.
for p in backbone.parameters():
    p.requires_grad_(True)
optimizer = torch.optim.AdamW([
    {'params': backbone.parameters(), 'lr': 1e-4},
    {'params': head.parameters(), 'lr': 1e-3},
])

LESSON 5.7

A defensible computer-vision study

Compare a small CNN, a residual model, and a transfer strategy using a declared validation budget. Apply the same target split and evaluation preprocessing. Report source pretraining, trainable and total parameter counts, runtime, and error types, not only the winning accuracy.

Augmentations must preserve task labels. A horizontal flip may be sensible for some object categories but invalid for text direction or medical laterality. Strong cropping can remove the labeled object, and color changes can erase the signal in a color-dependent task. Inspect actual augmented examples before training.

Add an ablation that isolates your explanation: remove the shortcut while holding widths fixed, compare a frozen backbone with fine-tuning, or vary one augmentation at a time. A larger model earning a better score does not by itself explain why.

WHEN THE MODEL SURPRISES YOU

Mistakes worth catching early

The shortcut fixes all gradients

Identity routes help, but cancellation, scaling, normalization, and nonlinear gates still affect gradient behavior.

Frozen parameters imply frozen BatchNorm

Running statistics are buffers updated by training-mode forwards. Set the mode intentionally.

An unfreezing change has no effect

Check optimizer parameter groups and actual gradient norms, not only requires_grad flags.

Transfer must improve the result

Source features can be a poor match. Keep a scratch baseline and report negative transfer when observed.

FROM EQUATIONS TO A RUNNING MODEL

Transfer inside a small residual network

Pretrain a tiny residual backbone on clean synthetic stripe orientations, then compare a frozen head, fine-tuning, and training from scratch on a smaller noisy target dataset. Pretraining happens locally; no external checkpoint is downloaded. The script restores the best validation checkpoint separately for each strategy.

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-5.py
# Fast execution check (not a convergence result):
python dl-week-5.py --smoke
Download Week 5 Python ↓
Read the complete, runnable experiment
"""DL Week 5: Residual Networks and Transfer Learning.

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

Pretrains a tiny residual backbone on a clean synthetic source task, then
compares three strategies on a smaller, noisier target task: a frozen backbone
with a new head, full fine-tuning, and training from scratch. Everything is
generated and trained locally; no 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


def make_stripes(count, seed, noise, period=4):
    """Half horizontal, half vertical stripes at a random phase, plus noise."""
    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
        phase = int(torch.randint(0, period, (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 ResidualBlock(nn.Module):
    """y = relu(F(x) + shortcut(x)), with a 1x1 projection when shapes differ."""

    def __init__(self, c_in, c_out, stride=1):
        super().__init__()
        self.branch = nn.Sequential(
            nn.Conv2d(c_in, c_out, 3, stride=stride, padding=1, bias=False),
            nn.BatchNorm2d(c_out), nn.ReLU(),
            nn.Conv2d(c_out, c_out, 3, padding=1, bias=False),
            nn.BatchNorm2d(c_out),
        )
        same = stride == 1 and c_in == c_out
        self.shortcut = nn.Identity() if same else nn.Sequential(
            nn.Conv2d(c_in, c_out, 1, stride=stride, bias=False), nn.BatchNorm2d(c_out)
        )

    def forward(self, x):
        return nn.functional.relu(self.branch(x) + self.shortcut(x))


def make_backbone():
    return nn.Sequential(
        nn.Conv2d(1, 8, 3, padding=1, bias=False), nn.BatchNorm2d(8), nn.ReLU(),
        ResidualBlock(8, 8),
        ResidualBlock(8, 16, stride=2),
        nn.AdaptiveAvgPool2d(1), nn.Flatten(),
    )


def make_head():
    return nn.Linear(16, 2)


def evaluate(backbone, head, loader):
    backbone.eval(); head.eval()
    loss_sum = correct = total = 0
    with torch.no_grad():
        for images, labels in loader:
            logits = head(backbone(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(backbone, head, train_loader, val_loader, epochs, groups, freeze_backbone):
    """Fit, restoring whichever parameters gave the lowest validation loss."""
    optimizer = torch.optim.AdamW(groups)
    best = float("inf")
    best_state = (copy.deepcopy(backbone.state_dict()), copy.deepcopy(head.state_dict()))
    for _ in range(epochs):
        # model.train() would put a frozen backbone's BatchNorm back into
        # training mode and let its running statistics drift, so set the modes
        # explicitly every epoch rather than once at the start.
        head.train()
        backbone.eval() if freeze_backbone else backbone.train()
        for images, labels in train_loader:
            optimizer.zero_grad()
            if freeze_backbone:
                with torch.no_grad():
                    features = backbone(images)
            else:
                features = backbone(images)
            loss = nn.functional.cross_entropy(head(features), labels)
            loss.backward()
            optimizer.step()
        val_loss, _ = evaluate(backbone, head, val_loader)
        if val_loss < best:
            best = val_loss
            best_state = (copy.deepcopy(backbone.state_dict()), copy.deepcopy(head.state_dict()))
    backbone.load_state_dict(best_state[0]); head.load_state_dict(best_state[1])
    return best


def buffer_signature(module):
    """A single number summarising every BatchNorm running statistic."""
    return sum(float(b.sum()) for b in module.buffers())


def counts(backbone, head):
    total = sum(p.numel() for p in list(backbone.parameters()) + list(head.parameters()))
    trainable = sum(p.numel() for p in list(backbone.parameters()) + list(head.parameters()) if p.requires_grad)
    return total, trainable


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)
    source_epochs = 2 if args.smoke else 14
    target_epochs = 2 if args.smoke else 24
    source_n = 128 if args.smoke else 1024
    target_n = 16 if args.smoke else 16

    # Source: many examples, and imagery that resembles the target. Target: very
    # few labels. That combination is the case where reusing features should pay.
    # Lower SOURCE_NOISE towards 0.25 to widen the domain gap and watch the
    # frozen backbone lose its advantage.
    SOURCE_NOISE, TARGET_NOISE = 0.9, 1.6
    source_train = DataLoader(make_stripes(source_n, 1, noise=SOURCE_NOISE), batch_size=32, shuffle=True)
    source_val = DataLoader(make_stripes(256, 2, noise=SOURCE_NOISE), batch_size=128)
    target_train = DataLoader(make_stripes(target_n, 3, noise=TARGET_NOISE), batch_size=8, shuffle=True)
    target_val = DataLoader(make_stripes(256, 4, noise=TARGET_NOISE), batch_size=128)
    target_test = DataLoader(make_stripes(512, 5, noise=TARGET_NOISE), batch_size=128)

    print("Shape check: the residual branch and its projection must agree")
    block = ResidualBlock(8, 16, stride=2)
    probe = torch.zeros(2, 8, 16, 16)
    with torch.no_grad():
        branch_out, short_out = block.branch(probe), block.shortcut(probe)
    print(f"  input     {tuple(probe.shape)}")
    print(f"  branch    {tuple(branch_out.shape)}")
    print(f"  shortcut  {tuple(short_out.shape)}")
    print(f"  addable   {branch_out.shape == short_out.shape}")

    print(f"\nPretraining the backbone on the clean source task ({source_n} examples)")
    backbone, head = make_backbone(), make_head()
    train(backbone, head, source_train, source_val, source_epochs,
          [{"params": list(backbone.parameters()) + list(head.parameters()), "lr": 3e-3}], False)
    _, source_acc = evaluate(backbone, head, source_val)
    print(f"  source validation accuracy {source_acc:.3f}")
    pretrained = copy.deepcopy(backbone.state_dict())

    print(f"\nTarget task: {target_n} noisy examples, three strategies")
    results = {}

    # 1. Frozen backbone, new head.
    b, h = make_backbone(), make_head()
    b.load_state_dict(pretrained)
    for p in b.parameters():
        p.requires_grad_(False)
    before = buffer_signature(b)
    total, trainable = counts(b, h)
    train(b, h, target_train, target_val, target_epochs, [{"params": h.parameters(), "lr": 5e-3}], True)
    after = buffer_signature(b)
    _, acc = evaluate(b, h, target_test)
    results["Frozen backbone"] = (total, trainable, acc)
    print(f"  frozen    buffers before {before:.6f} after {after:.6f} -> "
          f"{'unchanged' if abs(before - after) < 1e-9 else 'CHANGED'}")

    # 2. Fine-tune everything, with a smaller rate on the pretrained weights.
    b, h = make_backbone(), make_head()
    b.load_state_dict(pretrained)
    total, trainable = counts(b, h)
    train(b, h, target_train, target_val, target_epochs,
          [{"params": b.parameters(), "lr": 3e-4}, {"params": h.parameters(), "lr": 3e-3}], False)
    _, acc = evaluate(b, h, target_test)
    results["Fine-tuned"] = (total, trainable, acc)

    # 3. No source data at all.
    b, h = make_backbone(), make_head()
    total, trainable = counts(b, h)
    train(b, h, target_train, target_val, target_epochs,
          [{"params": list(b.parameters()) + list(h.parameters()), "lr": 3e-3}], False)
    _, acc = evaluate(b, h, target_test)
    results["From scratch"] = (total, trainable, acc)

    print(f"\n  {'strategy':<18}{'total':>9}{'trainable':>11}{'test acc':>10}")
    for name, (total, trainable, acc) in results.items():
        print(f"  {name:<18}{total:>9,}{trainable:>11,}{acc:>10.3f}")

    print("\nThe two transfer rows have already seen 1024 source examples; the scratch")
    print("row has only the 16 target labels. That is a difference in data budget, not")
    print("only in method, and it is the main reason the gap is large.")
    print("Try SOURCE_NOISE = 0.25 to make the source look unlike the target: the")
    print("frozen backbone can then fall to chance while fine-tuning still adapts.")
    print("One run on one synthetic pair — vary the seed before ranking anything.")
    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

  • Confirm the residual downsampling branch and projection produce identical shapes.
  • Compare total and trainable parameters for the frozen and fine-tuned cases.
  • Check that frozen-backbone buffers remain unchanged during target training.
  • Interpret the three test results as one small study, not a guaranteed ranking. Repeat seeds and vary the domain shift.

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. Project [N,32,28,28] to [N,64,14,14]. How many shortcut weights are needed?

Check your reasoning

Use Conv2d(32,64,1,stride=2,bias=False). It has 32×64 = 2,048 weights. A 3×3 stride-2 padded residual branch produces the matching shape.

2. A deeper model has higher training loss. What should you try first?

Check your reasoning

Check implementation, learning-rate behavior, gradient statistics, and optimization budget. Add a residual comparison with matched widths. More regularization is not the first explanation for inability to fit training data.

3. Why do frozen features change between epochs?

Check your reasoning

A BatchNorm backbone may still be in training mode, or stochastic transforms/dropout may alter its inputs or outputs. Freezing parameters alone does not define a deterministic feature pipeline.

4. Write a transfer result you would trust.

Check your reasoning

State source data and checkpoint, target splits, input preprocessing, frozen layers, BatchNorm policy, tuning budget, validation selection, and final held-out performance. Include a scratch baseline and compute costs.

EXPLAIN IT WITHOUT THE CODE

Week 5 review questions

01What is the residual function learning?

A correction F(x) to the shortcut representation, with the sum representing the desired transformation.

02Can a residual block represent identity?

With a shape-preserving identity shortcut and zero residual branch, the sum is x; a post-sum activation can still modify negative coordinates.

03Why use a 1×1 projection?

It mixes channels and can subsample with stride to make the shortcut's dimensions match the residual branch.

04What distinguishes eval() from no_grad()?

eval() changes mode-dependent layers. no_grad() disables recording operations for gradient computation. They serve different purposes.

05Why give the new head a larger learning rate?

It begins without task-specific weights, while pretrained features may need smaller adjustments. Validate the choice.

06What is negative transfer?

Reusing source training hurts the target outcome relative to an appropriate baseline under the stated evaluation protocol.

07What makes a residual ablation convincing?

It isolates the shortcut while controlling other architectural and training differences, then examines both training and held-out behavior.

08When should augmentation be rejected?

When it changes the label, removes essential evidence, or creates unrealistic examples that undermine the target task.

RESEARCH CONNECTIONS

Follow the original ideas

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

CONNECT THE LAYERS

Residual learning improves the path through depth. Sequence models introduce another repeated path: through time. Next we study how information survives—or disappears—along it.

See the complete learning map →