Index Ai Academy
Phase 4 · Deep learning · Training

Transfer learning

Reuse a trained network: freeze the backbone, train a new head, fine-tune with two learning rates.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Labels are the expensive part of machine learning. A factory might have twenty photos of a new kind of defect, a clinic a few dozen scans of a rare condition, a shop a handful of reviews tagged with a new complaint. Twenty examples are far too few to train a network from scratch. But a network trained on a big, related task has already learned to see edges, strokes and shapes, and most of that knowledge carries over. Transfer learning reuses it: keep the trained layers, replace the last one, and train with the few labels you have. It's how most real image and text models are built, and it's the same idea as using an LLM's embeddings with a small classifier on top.

What you'll be able to do

  • Split a trained network into a backbone that makes features and a head that makes the task's answer
  • Freeze a backbone, attach a new head, and train only the head on a few labeled examples
  • Fine-tune a whole network with a smaller learning rate for the backbone, using parameter groups
  • Judge when transfer helps, from a learning curve, and recognize when it hurts

What it covers

  1. Backbone and head
  2. A new task with ten labels
  3. Freeze all but some layers
  4. Fine-tuning: let the backbone move, slowly
  5. An optimizer with two learning rates
  6. The cheapest transfer: features once, a simple classifier
  7. When transfer hurts
Quick check
A factory has only 20 photos of a new defect. How can it still train a good classifier?
  1. Reuse a network trained on many images and retrain only its last layer
  2. Train a brand-new network from scratch on the 20 photos
  3. Wait until it has collected a million defect photos
  4. Copy each photo 1,000 times to make a bigger dataset
Show the answer

Reuse a network trained on many images and retrain only its last layer

A network trained on millions of images already turns pictures into useful features. Keeping that backbone and training a small new head needs only a few labeled examples.

Backbone and head

Look at a trained classifier as two parts. The backbone is every layer but the last: it turns raw input into features, a short list of numbers that describes the input in the terms the network found useful. The head is the last layer: it turns those features into the answer for this one task, such as ten digit scores. The backbone's features are often useful far beyond the task they were trained for; the head is specific to it.

Here's a digit classifier like the one in the training-loop lesson, with one more layer, built as a backbone (64 pixels in, 32 features out) and a 10-class head, trained on 1,000 of the 1,797 images. The other 797 are kept aside for a new task later.

import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits

digits = load_digits()
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
y = torch.tensor(digits.target)
order = np.random.default_rng(0).permutation(len(y))
old, new = order[:1000], order[1000:]          # train on 1,000 images; keep 797 aside

torch.manual_seed(0)
backbone = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU())
model = nn.Sequential(backbone, nn.Linear(32, 10))        # backbone, then a 10-class head
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(150):
    optimizer.zero_grad()
    nn.functional.cross_entropy(model(X[old]), y[old]).backward()
    optimizer.step()

model.eval()
with torch.no_grad():
    print("digit accuracy on the 797 kept-aside images:", round((model(X[new]).argmax(dim=1) == y[new]).float().mean().item(), 3))
    features = backbone(X[new[:2]])
print("features for two images:", tuple(features.shape))
print([round(v, 2) for v in features[0][:8].tolist()], "...")
saved = {name: t.clone() for name, t in backbone.state_dict().items()}
print(list(saved))
It runs right here in your browser, with nothing to install.

Each image becomes 32 features, and the network reads digits it never saw at about 97%. The last line saves the backbone's weights by name. The .clone() matters in real PyTorch, where state_dict() shares memory with the live weights: without it, training the network further would quietly change your "saved" copy too.

Quick check
A model is nn.Sequential(backbone, nn.Linear(32, 10)), where backbone is the network above. What does this print?
import torch.nn as nn
backbone = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU())
model = nn.Sequential(backbone, nn.Linear(32, 10))
backbone.requires_grad_(False)
print(sum(p.numel() for p in model.parameters() if p.requires_grad))
  1. 330
  2. 6,570
  3. 6,240
  4. 0
Show the answer

330

Freezing the backbone leaves only the head trainable: a 32-by-10 weight matrix (320 numbers) plus 10 biases, 330 in all. The backbone's 6,240 weights and biases stay as they are.

A new task with ten labels

Now a new question about the same kind of images: does the digit have a closed loop? (0, 6, 8 and 9 do.) Pretend we've labeled only ten of the 797 kept-aside images, five with loops and five without, and test on the rest. Three ideas compete:

  • From scratch: a fresh network, trained on the ten examples.
  • Frozen backbone, new head: load the saved backbone, freeze it so its weights can't change (requires_grad_(False)), put a new two-class head on top, and train only the head.
  • Next section: fine-tuning, which lets the backbone move too, gently.

Ten examples is a small, lucky-or-unlucky sample, so the cell repeats the experiment with six different sets of ten and averages.

import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits

digits = load_digits()
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
order = np.random.default_rng(0).permutation(len(X))
old, new = order[:1000], order[1000:]
loops = torch.tensor(np.isin(digits.target, [0, 6, 8, 9]).astype(np.int64))   # 1 if the digit has a loop

def make_backbone():
    return nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU())

def train(model, X, y, epochs=100, lr=0.01):
    optimizer = torch.optim.Adam([p for p in model.parameters() if p.requires_grad], lr=lr)
    for _ in range(epochs):
        optimizer.zero_grad()
        nn.functional.cross_entropy(model(X), y).backward()
        optimizer.step()

def accuracy(model, X, y):
    model.eval()
    with torch.no_grad():
        return (model(X).argmax(dim=1) == y).float().mean().item()

torch.manual_seed(0)                                         # the first cell's backbone, trained again
source = make_backbone()
train(nn.Sequential(source, nn.Linear(32, 10)), X[old], torch.tensor(digits.target[old]), epochs=150)
saved = {name: t.clone() for name, t in source.state_dict().items()}

scores = {"from scratch": [], "frozen backbone": []}
for seed in range(6):
    rng = np.random.default_rng(seed)
    with_loop, without = new[loops[new].numpy() == 1], new[loops[new].numpy() == 0]
    few = np.concatenate([rng.permutation(with_loop)[:5], rng.permutation(without)[:5]])
    test = np.setdiff1d(new, few)
    torch.manual_seed(seed)

    backbone = make_backbone()
    backbone.load_state_dict(saved)
    backbone.requires_grad_(False)                           # freeze: the optimizer won't touch it
    frozen = nn.Sequential(backbone, nn.Linear(32, 2))       # a new head for the new task
    train(frozen, X[few], loops[few])

    scratch = nn.Sequential(make_backbone(), nn.Linear(32, 2))
    train(scratch, X[few], loops[few])

    scores["frozen backbone"].append(accuracy(frozen, X[test], loops[test]))
    scores["from scratch"].append(accuracy(scratch, X[test], loops[test]))

for name, accs in scores.items():
    print(f"{name:<16} mean {np.mean(accs):.3f}  runs {[round(a, 2) for a in accs]}")

With ten labels the frozen backbone gets about 78% right and a network trained from scratch about 68%: transfer is worth ten points here. Look at the runs too. From scratch swings by 16 points (0.59 to 0.75) depending on which ten images were labeled, while the frozen backbone stays between 0.74 and 0.80: steadier results are another benefit of transfer. Either way, one lucky run proves nothing. Notice what the head learned from: not pixels, but the backbone's features, which already separate a 0 from a 7.

Drill

Freeze all but some layers

Write freeze_except(model, keep): set requires_grad to False on every parameter of model whose name doesn't start with one of the prefixes in keep (a tuple of strings), set it to True on those that do, and return how many numbers (p.numel()) are left trainable. Use model.named_parameters(), which yields names like "0.0.weight" and "1.bias".

import torch.nn as nn


def freeze_except(model, keep):
    return sum(p.numel() for p in model.parameters())


backbone = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU())
model = nn.Sequential(backbone, nn.Linear(32, 2))
print(freeze_except(model, ("1.",)))
In the lesson, your code is checked as you write it.

Fine-tuning: let the backbone move, slowly

Freezing is safe but rigid: the features stay exactly as they were for digits. Fine-tuning trains the whole network on the new task, so the backbone can adjust its features too. The risk is that big updates wreck what it learned, especially with only a few examples to learn from. The usual fix is two learning rates: small for the backbone (a gentle nudge), larger for the new head (which starts from random weights and has everything to learn).

PyTorch optimizers take parameter groups for this: a list of dictionaries, each with its own params and lr.

optimizer = torch.optim.Adam([
    {"params": backbone.parameters(), "lr": 0.001},   # pretrained: small steps
    {"params": head.parameters(), "lr": 0.01},        # new: bigger steps
])

Run on twenty labels with the same method as the cell above (six draws each, averaged), the three approaches come out at about:

approachmean accuracy
from scratch0.77
frozen backbone, new head0.81
fine-tuned, two learning rates0.83

Fine-tuning edges ahead because the features can bend toward loops. With only ten labels the order flips: freezing scored 0.78 and fine-tuning 0.76, because there's too little data to move the backbone's weights sensibly. So freeze when labels are very few, and fine-tune when you have more. Two learning rates are a sensible default, not a law: at 150 labels in the same experiment, a single learning rate did slightly better. Measure on your own data.

One more detail when reusing a real network: call model.eval() before computing features you'll keep, and think about it during fine-tuning too. Layers like dropout and batch normalization behave differently in training mode, and batch normalization's running averages would otherwise drift toward your few new examples. Freezing doesn't prevent that: requires_grad_(False) stops gradient updates, but in training mode batch norm still updates its running averages, so put frozen layers in evaluation mode.

Drill

An optimizer with two learning rates

Write fine_tune_optimizer(backbone, head, lr): return a torch.optim.Adam with two parameter groups, the backbone's parameters at lr / 10 and the head's at lr. Before building it, make sure every parameter of both is trainable (requires_grad_(True)), in case the backbone was frozen earlier.

import torch
import torch.nn as nn


def fine_tune_optimizer(backbone, head, lr):
    return torch.optim.Adam(list(backbone.parameters()) + list(head.parameters()), lr=lr)


backbone = nn.Sequential(nn.Linear(64, 32), nn.ReLU())
head = nn.Linear(32, 2)
opt = fine_tune_optimizer(backbone, head, 0.01)
print([g["lr"] for g in opt.param_groups])
In the lesson, your code is checked as you write it.

The cheapest transfer: features once, a simple classifier

If the backbone stays frozen, its features for each image never change, so there's no need to run it inside a training loop at all. Compute the features once, then train any simple classifier on them: logistic regression, from the classification lessons, fits in milliseconds. This is exactly how teams use an LLM's embeddings: send each text through the embedding model once, store the vectors, and fit a small classifier on top.

The cell compares logistic regression on raw pixels with logistic regression on the backbone's 32 features as the number of labels grows: a learning curve.

import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression

digits = load_digits()
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
order = np.random.default_rng(0).permutation(len(X))
old, new = order[:1000], order[1000:]
loops = np.isin(digits.target, [0, 6, 8, 9]).astype(np.int64)

torch.manual_seed(0)
backbone = nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU())
model = nn.Sequential(backbone, nn.Linear(32, 10))
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for _ in range(150):
    optimizer.zero_grad()
    nn.functional.cross_entropy(model(X[old]), torch.tensor(digits.target[old])).backward()
    optimizer.step()

backbone.eval()
with torch.no_grad():
    features = backbone(X).numpy()            # every image's 32 features, computed once
pixels = X.numpy()

print(" labels   pixels   features")
for n in [10, 20, 50, 150]:
    row = []
    for seed in range(6):
        rng = np.random.default_rng(seed)
        few = np.concatenate([rng.permutation(new[loops[new] == 1])[:n // 2], rng.permutation(new[loops[new] == 0])[:n // 2]])
        test = np.setdiff1d(new, few)
        row.append([LogisticRegression(max_iter=3000).fit(data[few], loops[few]).score(data[test], loops[test])
                    for data in (pixels, features)])
    print(f"{n:>7}   {np.mean(row, axis=0)[0]:.3f}    {np.mean(row, axis=0)[1]:.3f}")

Features beat pixels at every size, by the most when labels are scarcest: with ten labels, about 0.80 against 0.72. With more labels the gap shrinks to a few points, because with enough examples a model can learn useful features from the new data itself. That's the shape to expect: transfer matters most when labels are few. Be honest about this example, too: "has a loop" is just a grouping of the digits the backbone already knows, which makes it about the friendliest transfer there is. Real projects are rarely that kind.

Quick check
A team has a strong pretrained image backbone and 40,000 labeled photos for their new task. Which statement fits the learning curve above?
  1. They should freeze the backbone, since fine-tuning only works with few labels
  2. Transfer still helps as a starting point, but the gap over training from scratch is likely small
  3. Learning curves show transfer helps most with the most labels
  4. With that many labels, a pretrained backbone will make results worse
Show the answer

Transfer still helps as a starting point, but the gap over training from scratch is likely small

The gap was largest with 10 labels and nearly gone by 150 here. With plenty of data, fine-tuning from pretrained weights usually still trains faster and ends slightly better, but it's a modest gain rather than the difference between working and not working.

When transfer hurts

Transfer works when the old task taught features the new task needs. When it didn't, reused features can be worse than none. Train the backbone only on digits 0 to 4 until it's perfect at them, then try to transfer it to telling 5, 6, 7, 8 and 9 apart with five labels each:

import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits

digits = load_digits()
X = torch.tensor(digits.data / 16.0, dtype=torch.float32)
y = torch.tensor(digits.target)
low = np.flatnonzero(digits.target < 5)
high = np.random.default_rng(0).permutation(np.flatnonzero(digits.target >= 5))
few = np.concatenate([high[digits.target[high] == d][:5] for d in range(5, 10)])
test = np.setdiff1d(high, few)

def make_backbone():
    return nn.Sequential(nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 32), nn.ReLU())

def train(model, X, y, epochs):
    optimizer = torch.optim.Adam([p for p in model.parameters() if p.requires_grad], lr=0.01)
    for _ in range(epochs):
        optimizer.zero_grad()
        nn.functional.cross_entropy(model(X), y).backward()
        optimizer.step()

def accuracy(model, X, y):
    model.eval()
    with torch.no_grad():
        return round((model(X).argmax(dim=1) == y).float().mean().item(), 3)

torch.manual_seed(0)
backbone = make_backbone()
train(nn.Sequential(backbone, nn.Linear(32, 5)), X[low], y[low], epochs=150)     # digits 0-4 only
backbone.requires_grad_(False)
frozen = nn.Sequential(backbone, nn.Linear(32, 5))
train(frozen, X[few], y[few] - 5, epochs=150)
scratch = nn.Sequential(make_backbone(), nn.Linear(32, 5))
train(scratch, X[few], y[few] - 5, epochs=150)
print("frozen backbone from digits 0-4:", accuracy(frozen, X[test], y[test] - 5))
print("from scratch:                   ", accuracy(scratch, X[test], y[test] - 5))

The transferred backbone loses badly, 0.57 against 0.90. Trained only to tell 0 to 4 apart, and trained until its loss was nearly zero, it kept exactly the features that separate those five digits and threw away the rest. Two different 7s and a 9 can look the same to it. This is negative transfer, and two habits guard against it: always compare with a from-scratch baseline (as the experiments here do), and prefer backbones trained on broad, varied data, which is why big pretrained models are trained on so much of it.

Try it now

Two questions decide it: how many labels you have, and how close the pretrained task is to yours.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What does freezing a backbone with requires_grad_(False) do?

  1. Its weights get no gradients, so only the layers still trainable change
  2. It deletes the backbone's weights to save memory during training
  3. It makes the backbone run faster when the model makes predictions
  4. It stops the backbone from producing features until it's unfrozen

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson