Deep Learning · Module 14 of 26 · 8 concepts · about 18 min

Transfer Learning

Starting from a representation learned on a broad source task.

At work: Building a strong model from a few thousand of your own examples by starting from one trained on millions.

Feature extraction and fine-tuning

What it is

Transfer learning reuses pretrained weights instead of asking a small target dataset to discover every useful feature from random noise. In feature extraction, the backbone remains frozen and only a new classification or regression head learns; in fine-tuning, some or all backbone layers are unfrozen with a smaller learning rate to adapt representations to the target domain. Which layers to unfreeze depends on data volume and domain distance: generic early visual features often transfer widely, while later task-specific features may need more adaptation. The validation set decides whether additional unfreezing helps rather than merely overfits.

Why it matters

Pretraining supplies useful visual or linguistic features before your small dataset can. It is often faster, cheaper, and more accurate than training from random initialization, particularly when labels are scarce. It also changes the experiment plan: compare a frozen baseline first, then progressively unfreeze while measuring whether the added trainable capacity improves held-out performance enough to justify its cost.

In the real world: Building a defect detector from 2,000 factory photos by starting from a model trained on millions of general images.

Visual example

Deep learning

A pretrained backbone supplies general features; the target task changes only what it needs.

Transfer learning

  1. pretrained backboneweights learned on broad source data
  2. new task headrandomly initialized output layer
  3. freeze or unfreezechoose which parameters can change
  4. target validationdecide when adaptation helps

Fine-tuning starts from learned representations, not a blank model.

import torch.nn as nn
from torchvision import models

model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
for param in model.parameters():
    param.requires_grad = False          # freeze the backbone
model.fc = nn.Linear(model.fc.in_features, n_classes)   # fresh, trainable head

Fine-tuning every layer at the same large learning rate used for a fresh head. It can erase useful pretrained features in a few updates; use discriminative or smaller rates.

Why pretrained weights are a better starting point

What it is

Random initialization (module 10) is chosen for one property only: it keeps activations and gradients at a workable scale. It encodes nothing about the data. Pretrained weights start from a point where the network already computes edges, textures, shapes and object parts — features your 800-image dataset would have to rediscover from its 800 examples, using gradient signal that is both noisy and scarce. The target task then only needs to learn how to combine features that already exist, which is a far smaller learning problem than constructing them.

Why it matters

It explains the shape of the curves: transfer learning is not a small constant improvement but a different regime. On a few hundred labelled images, a frozen backbone plus a linear head routinely beats a from-scratch network by tens of accuracy points, and reaches its best score in a handful of epochs rather than hundreds. The smaller the dataset, the larger the gap — which is exactly the case most real projects are in.

In the real world: Saves months of data collection and most of the GPU cost.

Visual example

Small-dataset regime

1,000 labelled images. The pretrained run starts higher, converges in a few epochs, and ends well above what from-scratch reaches at all.

transfer learning (frozen backbone + head)fine-tuned (unfrozen, low lr)from scratch
05101520253000.20.40.60.81epochvalidation accuracytwo epochs beats 30 from scratchnot enough data to learn features

Why this matters

It shows transfer learning is not a marginal trick. On a small dataset it changes what accuracy is reachable at all, and it does so in minutes rather than hours.

The from-scratch curve is not undertrained — it is out of data. More epochs move it very little, which is the difference between an optimisation problem and a sample-efficiency one.

import torch.nn as nn
from torchvision import models

scratch = models.resnet18(weights=None)                     # random init
pretrained = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

# identical architecture, identical parameter count, different starting point
sum(p.numel() for p in scratch.parameters()) == sum(p.numel() for p in pretrained.parameters())
# True — the only difference is the values, and the values are the point

Using the pretrained weights while normalizing inputs with your own dataset's statistics. The backbone was trained on a specific normalization; feed it differently-scaled inputs and its first layers are operating outside the range they were fitted for. Use the source model's documented mean and standard deviation.

Why early layers transfer and late layers do not

What it is

A CNN's layers form a hierarchy of specificity. The first convolutions learn oriented edges, colour blobs and simple gradients — properties of photographs in general, and near-identical whatever the source dataset was. Middle layers compose those into textures and parts: fur, wheels, corners. The last layers encode combinations specific to the source label set, and the final classifier is entirely about it: a 1000-way ImageNet head's weights are meaningless for a 4-way X-ray task. So the transfer decision is a boundary on that hierarchy — keep everything below it, retrain everything above.

Why it matters

It converts 'which layers should I unfreeze' from guesswork into a principle: unfreeze from the top down, and go further down the more your target domain differs from the source. It also explains the one layer you must always replace — the classifier, whose output width is the source task's class count — and why replacing it is not optional even when the class counts happen to match.

In the real world: General shapes and textures carry over; your specific product categories must be learned fresh.

Visual example

Layer specificity

Transferability falls as you go up the stack. The unfreeze boundary moves down as the domain gap grows.

what each stage has learned, and whether to keep it
stagefeaturestransfers?
conv1oriented edges, colour blobsalmost always
layer1-2textures, corners, simple shapesusually
layer3-4object parts, source-specific combinationssometimes
fcthe source task's 1000 classesnever — replace it

Common misconception

Wrong idea: freezing is about saving compute.

Correct idea: it is a capacity decision. A frozen backbone is a very strong regulariser, which is exactly what you want when the target set has a few hundred labels.

from torchvision import models

model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
for param in model.parameters():
    param.requires_grad = False

for name, module in model.named_children():
    if name in {"layer4", "fc"}:         # top of the hierarchy only
        for param in module.parameters():
            param.requires_grad = True

trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"{trainable:,} of {total:,} trainable")   # 8,396,548 of 11,181,642

Unfreezing from the bottom up — keeping the source task's specific final layers while retraining the generic early ones. That is exactly backwards: it discards the features most likely to transfer and keeps the ones least likely to.

Replacing the head

What it is

A pretrained classifier ends in a layer whose output width is the source task's class count, and that layer must be swapped for one matching your task. The mechanics differ slightly per architecture family — model.fc on ResNets, model.classifier on VGG, DenseNet and EfficientNet, model.heads.head on torchvision's ViT — so read in_features off the existing layer rather than hard-coding it. The new layer is randomly initialized and trainable by default, which is what you want: it is the only part with no useful prior. Note the interaction with freezing: set requires_grad = False on the backbone *before* attaching the new head, or the loop will freeze the head too.

Why it matters

This is the one mandatory edit, and the one whose failure mode is a silent wrong answer rather than an error — a model that trains, converges, and predicts among the wrong set of classes. Reading in_features from the module also means the same code works across backbones, which matters because comparing two backbones is the first experiment worth running.

In the real world: Swapping a general model's last layer for one that predicts your company's 40 product categories.

import torch
import torch.nn as nn
from torchvision import models

model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
for param in model.parameters():
    param.requires_grad = False      # freeze FIRST

model.fc = nn.Sequential(            # then attach the trainable head
    nn.Dropout(0.2),
    nn.Linear(model.fc.in_features, 4),
)

params = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.AdamW(params, lr=1e-3)   # pass ONLY these

Passing model.parameters() to the optimiser instead of the filtered list. Frozen parameters get no gradient, but AdamW's decoupled weight decay still shrinks them every step — so a 'frozen' backbone slowly decays toward zero and the pretrained features degrade over a long run.

Learning-rate discipline for fine-tuning

What it is

A fine-tuning learning rate is typically 10-100× smaller than a from-scratch one — 1e-4 or 1e-5 where you would have used 1e-3. The reason is that the backbone is already near a good solution, so the job is a small adjustment, and a step sized for a random initialization is large enough to leave that region entirely. Discriminative learning rates go further and give each layer group its own rate, rising toward the top: the new head gets the largest rate because it is genuinely random, the deepest backbone stage a smaller one, and the earliest layers the smallest or zero. Warmup (module 8) matters here too, because the first few steps are driven by the untrained head's large gradients.

Why it matters

It is the difference between adaptation and destruction, and it is invisible in the first few steps — the loss falls either way, because a fresh head learning on top of scrambled features still beats chance. The damage shows up as a validation curve that plateaus below the frozen-backbone baseline, which is a confusing result unless you know to suspect the learning rate.

In the real world: Too high a learning rate erases what the pretrained model knew, and results get worse, not better.

import torch

optimizer = torch.optim.AdamW([
    {"params": model.conv1.parameters(), "lr": 1e-6},
    {"params": model.layer3.parameters(), "lr": 1e-5},
    {"params": model.layer4.parameters(), "lr": 5e-5},
    {"params": model.fc.parameters(), "lr": 1e-3},     # random, needs the most
], weight_decay=0.01)

for group in optimizer.param_groups:
    print(group["lr"], sum(p.numel() for p in group["params"]))

Unfreezing the backbone on epoch 0. The head is still random, so its large early gradients propagate straight into the pretrained weights and scramble them before they were ever useful. Train the head alone for a few epochs first, then unfreeze at a small rate.

Catastrophic forgetting

What it is

Fine-tuning too aggressively — too large a learning rate, too many epochs on too little data, or every layer unfrozen at once — overwrites the pretrained representations with ones fitted to your small target set. The model has not simply overfitted in the usual sense; it has discarded the general features that were the reason to start from this checkpoint. The symptom is distinctive: a fine-tuned model that scores *below* the frozen-backbone baseline on validation, despite having strictly more trainable capacity. The remedies are all forms of restraint — smaller rates, fewer unfrozen layers, early stopping, and stronger weight decay anchoring the weights near where they started.

Why it matters

It makes the frozen baseline non-optional. Without a frozen number to compare against, a fine-tuned run that quietly destroyed its backbone looks like a normal mediocre result, and the natural next move — train longer, unfreeze more — makes it worse. The baseline is what turns the failure into a visible, diagnosable regression.

In the real world: A model fine-tuned on medical images forgets how to handle ordinary photos it used to manage.

Visual example

Forgetting

Too large a fine-tuning rate ends below the frozen baseline — with more trainable capacity and a worse model.

frozen baselinefine-tune at 1e-5fine-tune at 1e-2
0246810120.30.40.50.60.70.80.91epochvalidation accuracyfrozen backbone — the number to beat

The red curve does recover slowly: it is relearning from your 1,000 images what the checkpoint already knew from a million. The pretrained weights are gone.

import copy
import torch

baseline = evaluate(frozen_model, val_loader)      # measure this FIRST

reference = copy.deepcopy(
    {k: v.clone() for k, v in model.state_dict().items() if "layer1" in k}
)
fine_tune(model, epochs=5, lr=1e-5)

drift = torch.stack([
    (model.state_dict()[k] - v).norm() / v.norm() for k, v in reference.items()
]).mean()
val_now = evaluate(model, val_loader)
print(f"early-layer drift {float(drift):.1%}; val {val_now:.3f} vs {baseline:.3f}")
# drift of 40% in layer1 with val below baseline -> forgetting, not overfitting

Treating a below-baseline fine-tune as evidence that 'transfer learning does not work for this task'. It is evidence that the *fine-tuning schedule* destroyed the transfer. Re-run frozen, then unfreeze one stage at a time.

When transfer learning helps least

What it is

The benefit depends on the source and target domains sharing low-level structure, and there are real cases where they do not. ImageNet features transfer weakly to grayscale medical scans with different statistics and textures, to satellite imagery at an unfamiliar scale, to audio spectrograms that are only nominally images, and to non-photographic data such as scientific plots. It also helps less when the target dataset is large — with a million labelled examples, from-scratch training catches up, and the initialization matters mostly for how fast you get there. And a mismatch in input *shape* or channel count needs explicit handling, not just a new head.

Why it matters

Naming the limits keeps transfer learning as a technique rather than a reflex. The decision has a cheap empirical test — train the frozen head, compare it against a small from-scratch model — and on a distant domain the answer is sometimes 'unfreeze everything and treat the checkpoint as a merely better-than-random initialization', which is a legitimate use and a different plan.

In the real world: When your data looks nothing like the original, such as X-rays versus internet photos.

import torch
import torch.nn as nn
from torchvision import models

# 1-channel medical scans into a 3-channel backbone: adapt conv1 deliberately
model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
old = model.conv1
model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
with torch.no_grad():
    model.conv1.weight.copy_(old.weight.sum(dim=1, keepdim=True))  # keep the filters

Repeating a grayscale channel three times to fit a 3-channel backbone and calling it done. It works, but it triples the input cost and leaves conv1's colour-opponent filters computing nothing useful — summing the RGB weights into one channel is both cheaper and closer to what the filters were doing.

A transfer-learning experiment plan

What it is

Because the choices interact, the useful order is fixed: (1) train a linear head on a frozen backbone — cheap, fast, and the baseline everything else is measured against; (2) unfreeze the top stage at a small learning rate; (3) unfreeze progressively further down, one stage at a time; (4) only then consider a larger backbone or a different pretraining source. Each step is scored on the same validation split, and each step's cost in trainable parameters is recorded beside its score. Augmentation (module 11) is held fixed across the comparison, because it interacts strongly with how much capacity is unfrozen.

Why it matters

It is the cheapest-first ordering, and it produces a defensible answer to 'why is the model configured this way'. It also protects against the two failure modes this module described: without step 1 there is no baseline to detect forgetting, and without one-stage-at-a-time changes a regression cannot be attributed to the unfreezing rather than to the learning rate that came with it.

In the real world: A step-by-step plan to get a working product model in days, not months.

Visual example

Deep learning

Four runs, in this order, each scored on the same validation split.

Cheapest first

  1. frozen backbone + linear headthe baseline; minutes to train
  2. unfreeze the top stage, lr 1e-4usually the best score per unit of cost
  3. unfreeze further downhelps on distant domains, risks forgetting
  4. bigger backbone or new sourceonly once the cheap options are exhausted

Record trainable parameters beside every score, and touch the test set once at the end.

results = {}
for name, unfreeze in [
    ("frozen", []),
    ("+layer4", ["layer4"]),
    ("+layer3", ["layer3", "layer4"]),
    ("all", None),
]:
    model = build_backbone_with_head(n_classes=4, unfreeze=unfreeze)
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    results[name] = (train_and_validate(model, lr=1e-4), trainable)

for name, (score, n) in results.items():
    print(f"{name:10s} val={score:.3f} trainable={n:,}")

Reporting the best of these four runs as the model's test score. Choosing among four configurations on validation data is model selection, so the test set must be touched once, after the choice — otherwise the reported number includes the luck of the selection.