Training Vision Models

Replacing the classifier head

The head is the small final layer that turns a photo description into class scores, and swapping it for one with your own number of classes is the first edit every fine-tune makes.

On this page 5
  1. Why it exists
  2. The part that surprises people
  3. Where the head hides
  4. Remember this
  5. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The head is the last small layer of a vision model. You replace it because it names the wrong things.

Think of a kitchen mixer. One motor, and a box of attachments. The whisk for cream, the dough hook for atta, the blade for chutney. Nobody buys a new motor to make chutney.

A pretrained vision model is that motor. It has learned to turn a photo into a short description. The head is the attachment that turns the description into an answer.

The motor you borrowed came with the wrong attachment. It names one thousand internet categories: goldfish, church, hammer. You want four defect types. So you unclip that attachment and clip on yours.

Why it exists

Look at what actually leaves the model, one step before the end.

   photo  ->  [ backbone: the motor ]  ->  512 numbers describing the photo
                                                     |
                                          [ head: the attachment ]
                                                     |
                              1000 internet categories   <- what came in the box
                                        OR
                              4 of your classes          <- what you clip on

Those 512 numbers are the useful part. They say things like "striped texture here", "round dark region there", "metallic surface". They are not about goldfish. They are about what is visible.

The head is a small piece of arithmetic that takes those 512 numbers and produces one score per class. Changing 1000 scores to 4 scores changes almost nothing about the model. It changes the last thin slice.

The part that surprises people

The new head starts out knowing nothing. Its numbers are random. So a freshly re-headed model is useless until you train it. The expensive part underneath is already perfect.

That is normal, and it is why the previous lesson recommended freezing the backbone at first. A random head sends loud, wrong corrections backwards into a backbone you would rather protect.

Where the head hides

Different model families give the head different names. Some call it fc. Some call it classifier. Some call it heads.head. There is no rule, and guessing wastes an afternoon.

The habit that saves you: print the model, find the last layer, read how many numbers go into it. Never type the number from memory.

Remember this

  • The head turns the backbone's description of a photo into one score per class.
  • You replace it because the borrowed model names the wrong set of things.
  • A new head is random at first, so train or freeze accordingly, and never guess its input size.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install torch torchvision

Written and run against torch 2.13.0 (CPU) and torchvision 0.28.0. Both scripts use weights=None, so nothing is downloaded — the layer names and shapes are identical with real weights.

Step one: find the head, do not guess it

find_head.py
import torch
from torchvision import models

# Every family names its final layer differently. Look, do not guess.
for name in ["resnet18", "efficientnet_b0", "mobilenet_v3_small", "convnext_tiny", "vit_b_16"]:
    m = getattr(models, name)(weights=None)
    last = list(m.named_modules())[-1]
    print(f"{name:<20} last module: {last[0]:<18} {last[1]}")
Output
resnet18             last module: fc                 Linear(in_features=512, out_features=1000, bias=True)
efficientnet_b0      last module: classifier.1       Linear(in_features=1280, out_features=1000, bias=True)
mobilenet_v3_small   last module: classifier.3       Linear(in_features=1024, out_features=1000, bias=True)
convnext_tiny        last module: classifier.2       Linear(in_features=768, out_features=1000, bias=True)
vit_b_16             last module: heads.head         Linear(in_features=768, out_features=1000, bias=True)

Five architectures, five different attribute paths, five different in_features. Any tutorial that hardcodes model.fc and 512 works for exactly one of these five.

Notice classifier.1 and classifier.3. In those models classifier is a Sequential holding dropout, normalisation or reshaping layers ahead of the final Linear:

Output
efficientnet_b0.classifier = Sequential(Dropout(p=0.2), Linear(1280, 1000))
convnext_tiny.classifier   = Sequential(LayerNorm2d(768), Flatten(), Linear(768, 1000))

Assigning model.classifier = nn.Linear(...) replaces the whole block. On EfficientNet that deletes the dropout. On ConvNeXt it deletes a LayerNorm and a Flatten, and the forward pass fails.

Step two: one function that handles all of them

replace_head.py
import torch, torch.nn as nn
from torchvision import models

def replace_head(model, num_classes):
    """Swap the last nn.Linear for a fresh one with num_classes outputs."""
    name, old = [(n, m) for n, m in model.named_modules() if isinstance(m, nn.Linear)][-1]
    parent_name, _, attr = name.rpartition(".")
    parent = model.get_submodule(parent_name) if parent_name else model
    setattr(parent, attr, nn.Linear(old.in_features, num_classes))
    return name, old.in_features

torch.manual_seed(0)
x = torch.randn(2, 3, 224, 224)
for name in ["resnet18", "efficientnet_b0", "convnext_tiny", "vit_b_16"]:
    m = getattr(models, name)(weights=None)
    where, feats = replace_head(m, num_classes=5)
    m.eval()
    with torch.no_grad():
        out = m(x)
    print(f"{name:<18} replaced {where:<14} ({feats} features in) -> output {tuple(out.shape)}")
Output
resnet18           replaced fc             (512 features in) -> output (2, 5)
efficientnet_b0    replaced classifier.1   (1280 features in) -> output (2, 5)
convnext_tiny      replaced classifier.2   (768 features in) -> output (2, 5)
vit_b_16           replaced heads.head     (768 features in) -> output (2, 5)

Line by line, for the parts that are not obvious at a glance

name.rpartition(".") splits "classifier.1" into parent path "classifier" and attribute "1". For "fc" the parent path comes back empty, which is why the next line falls back to model itself.

model.get_submodule(parent_name) walks a dotted path and returns the module. It handles Sequential indices as attribute names, which is why setattr(parent, "1", ...) works on a Sequential.

setattr(parent, attr, nn.Linear(...)) is a real assignment on an nn.Module. PyTorch registers the new layer, drops the old, and updates model.parameters(). See module registration and parameters for why plain attribute assignment is enough.

isinstance(m, nn.Linear) and taking [-1] relies on named_modules() returning definition order. That holds for every torchvision classification model. It would not hold for a model whose head is a convolution, which is normal in segmentation.

Common mistakes

Hardcoding the feature width. nn.Linear(512, 4) on an EfficientNet-B0 raises a shape error at the first forward pass, because 1280 numbers arrive. Fix: read in_features from the layer you are replacing.

Replacing a Sequential classifier wholesale. model.classifier = nn.Linear(1280, 4) on EfficientNet compiles and trains, and quietly removes the dropout that was regularising it. Fix: replace model.classifier[-1] alone.

Labels outside the new class range. Build a 4-class head, feed a label of 4, and cross-entropy raises:

Output
IndexError : Target 4 is out of bounds.

Fix: map your label strings to 0 .. num_classes-1 once, keep the mapping in a file, and assert labels.max() < num_classes before the first epoch.

Putting a softmax inside the head. nn.Sequential(nn.Linear(512, 4), nn.Softmax(dim=1)) then fed to nn.CrossEntropyLoss applies softmax twice. Training still moves, badly. Fix: heads output raw scores. CrossEntropyLoss expects raw scores and does the softmax internally.

Try it yourself

Run replace_head on models.swin_t() and models.regnet_y_400mf() first. You should get head with 768 features and fc with 440 features — two more names, two more widths.

Then run it on models.segmentation.fcn_resnet50(weights=None, weights_backbone=None):

Output
IndexError: list index out of range

There is no nn.Linear anywhere in that model. Segmentation heads are convolutional, because the output is a map rather than a row of scores. Making replace_head handle a Conv2d head is the exercise.

What to learn next

Researcher — Mathematics and papers.

The head is a linear probe you then unfreeze

With backbone features $z = \phi(x) \in \mathbb{R}^{d}$ and head parameters $W \in \mathbb{R}^{C \times d}$, $b \in \mathbb{R}^{C}$, the model computes

$$ \hat{y} = \operatorname{softmax}(Wz + b) $$

  • $d$ — feature width (512 for ResNet-18, 768 for ViT-B/16).
  • $C$ — number of target classes.
  • $\phi$ — the backbone, frozen or not.

The head therefore adds $C(d+1)$ parameters, and only that. All the modelling capacity you are adding is a hyperplane per class in the frozen feature space.

Head initialisation is not a detail

Kumar et al. (2022), Fine-Tuning can Distort Pretrained Features and Underperform Out-of-Distribution (ICLR), give the mechanism. During the first steps of full fine-tuning, gradients from a random head are large. Crucially, they are uninformative about the target task. Their theory shows those steps rotating the feature extractor towards the random head's arbitrary geometry. Out-of-distribution accuracy degrades even as in-distribution accuracy rises.

The remedy is procedural rather than architectural: fit the head first with the backbone frozen (linear probing), then unfreeze (LP-FT). The head is then already near a good solution, so early backbone gradients are small. Across 10 distribution-shift datasets they report LP-FT about 1% better in-distribution and about 10% better out-of-distribution than full fine-tuning.

Alternative head geometries

The plain linear head is not the only option, and two variants matter in practice.

Cosine (normalised) classifiers replace $Wz$ with

$$ s_c = \tau \cdot \frac{w_c^{\top} z}{\lVert w_c \rVert \, \lVert z \rVert} $$

  • $w_c$ — the weight vector for class $c$.
  • $\tau$ — a learned or fixed scale, typically 10 to 30.

Removing the norms removes the head's ability to express class priors through weight magnitude. Gidaris and Komodakis (2018) and Qi et al. (2018) introduced this for few-shot recognition; Kang et al. (2020), Decoupling Representation and Classifier for Long-Tailed Recognition (ICLR), studied long-tailed data. Most of the bias sits in the classifier's weight norms, not in the features. Renormalising those norms alone recovers a large part of the accuracy gap.

Bias initialisation for rare classes. Lin et al. (2017), Focal Loss for Dense Object Detection, initialise the final bias to $b = -\log((1-\pi)/\pi)$, with $\pi$ the expected positive rate. The model then starts by predicting the prior, rather than spending early epochs discovering it. The same trick stabilises the first epochs of any heavily imbalanced classification head.

Papers

What to learn next