TAI BUI
Back to curriculum
CapstonePython (torch, numpy)

Capstone Lesson 38: Classifier Fine-Tuning by Head Swap

Track B's first capstone. A head swap replaces a pretrained model's output layer with a new one and keeps the body's weights. A pretrained language model is a stack of self-attention blocks ending in a token-prediction head. For spam vs ham the head is wrong but the body is mostly right, so you rip the head off, glue a two-class linear layer onto the pooled representation, and train the classifier two ways: final-layer only, and full fine-tuning. The eval is precision, recall, and F1 on a held-out split, which shows you what each strategy buys and what it costs.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 30-37 (NLP LLM track: tokenizer, embedding table, attention block, transformer body, pre-training loop, checkpointing, generation, perplexity)

Learning Objectives

  • Replace a language-model head with a classification head without re-initialising the body.
  • Implement two training regimes: frozen body (head-only) and full fine-tuning, sharing one training loop.
  • Build a tokeniser-aware data pipeline that pads, masks padding, and pools attention output.
  • Compute precision, recall, F1, and a confusion matrix from raw logits.
  • Reason about the trade-off between parameter count, training time, and head-room.

The Problem

A head swap takes a pretrained model, discards its output layer, and attaches a new one for a different task while keeping the body's weights. Here is the case for it. You pre-trained a small transformer on a generic corpus, and its output head projects the last hidden state to the pretraining vocabulary (say 1000 tokens, for a hypothetical pretrained model; the demo's own body uses a 260-id byte vocabulary). Now you have 800 SMS messages labelled spam or ham and you want a binary classifier. Three options exist.

The wrong option is to train a fresh classifier from scratch on 800 examples. The body of the pretrained model already encodes useful structure: word identity, position, simple co-occurrence. Throwing it away wastes the compute that built it.

The two right options are head swap with the body frozen, and head swap with the body trainable. Head-only training is fast, almost free in memory, and rarely overfits with this little data. Full fine-tuning is slower and can overfit on small data, but it reaches higher accuracy when the downstream domain drifts from the pretraining corpus.

We build both and compare them on the same fixture.

Warm-up0/3
1. A batch of short messages is padded to a common length before going through an attention block. What should the attention mask do?Warm-up
2. You train a large network on only 800 labelled examples, letting every weight change freely. What is the main risk?Warm-up
3. Why is a test set kept out of training when you measure a classifier?Warm-up
Check: The Problem0/4
1. What is a head swap?Define
2. Why is head-only training a good fit for 800 examples?Explain
3. What is the main reason to pay for full fine-tuning instead of head-only training?Explain
4. A pretrained model has a 5M-parameter body and an output head projecting to a 30K vocabulary. You need a 2-class classifier from 800 labelled examples. Which is the most defensible first move?Design

The Concept

Mermaid diagram

Fine-tuning by head swap means keeping the pretrained body and replacing the function that sits on top of it. The body is a function f_theta(tokens) -> hidden_states. The head is a function g_phi(hidden) -> logits. Swapping heads keeps theta and replaces g_phi. The body's parameters are the expensive part, while the head is a single linear layer.

Two trainable parameter sets matter:

  • theta (the body): tens of thousands of weights per attention block.
  • phi (the head): hidden_dim * num_classes weights plus a bias.

Head-only training computes gradients against phi and none against theta. In PyTorch you get this by setting requires_grad=False on the body parameters, so the optimiser sees only the head and the body stays frozen.

Full fine-tuning lets gradients flow back through the whole stack, and the body's weights drift to fit the classification objective. The risk on small data is catastrophic forgetting: the body's pretraining gets washed out as it overfits noise.

Check: The Concept0/5
1. In the lesson's notation, the body is f_theta(tokens) and the head is g_phi(hidden). What does g_phi do in a classifier?Define
2. What is head-only training?Define
3. What is full fine-tuning?Define
4. What is catastrophic forgetting?Define
5. You retrain with full fine-tuning at the same learning rate as head-only training. F1 drops far below what head-only training reached, well outside the small gap the lesson says to expect between the regimes. What is the most likely cause?Analyze

The Pooling Question

A classifier needs one vector per sequence, not one vector per token, and pooling is the step that collapses the per-token hidden states into that single vector. Three common choices:

  • Mean pool: average the hidden states across the sequence, weighted by the attention mask.
  • CLS pool: prepend a special token and use only its output. This is what BERT does.
  • Last-token pool: use the last non-padding token. This is what GPT-class classifiers do.

We use mean pooling with explicit attention-mask weighting. It is the simplest option, gives a stable signal across sequence lengths, and does not require pretraining a CLS token.

Mermaid diagram

Check: The Pooling Question0/7
1. What is pooling in a sequence classifier?Define
2. What is CLS pooling?Define
3. Why does the lesson use mean pooling weighted by the attention mask instead of a plain mean across the sequence axis?Explain
4. Why does the lesson prefer mean pooling over CLS pooling for this build?Explain
5. `mean_pool` receives hidden values [2.0, 4.0, 100.0] for one dimension of a 3-token sequence, with mask [1, 1, 0]. What does it return for that dimension?Apply
6. `mean_pool` is called with a mask of all zeros, so no token is real. What does the code do?Apply
7. After replacing `mean_pool` with a plain `hidden.mean(dim=1)`, accuracy falls mostly on very short messages. What explains that pattern?Analyze

The Data

The data is a synthetic SMS fixture of eight hundred messages, balanced 400 spam and 400 ham, generated deterministically in code/main.py. The generator uses a fixed seed, picks templates, substitutes slot fillers, and emits short messages. The byte-level tokenizer encodes one id per byte and truncates or pads each message to a max length of 32. Real datasets have noise this fixture does not, but reproducibility is the point here.

The split is 80/20: 640 train, 160 test. It is stratified, so the test set keeps the 50/50 balance. Because the balance of the held-out set is known, precision and recall can be read as honest numbers.

Check: The Data0/3
1. What does a stratified split do?Define
2. Why does the lesson say a known 50/50 balance in the test set lets precision and recall be read as honest numbers?Explain
3. The fixture has 800 messages, balanced 400 and 400, and `stratified_split` uses test_frac 0.2. How many spam messages land in the test set?Apply

The Metrics

These are binary classification metrics with class 1 (spam) as the positive class. Counts are:

  • TP: predicted spam, was spam.
  • FP: predicted spam, was ham.
  • FN: predicted ham, was spam.
  • TN: predicted ham, was ham.

The three headline metrics:

  • precision = TP / (TP + FP). Of the messages flagged spam, what fraction actually are?
  • recall = TP / (TP + FN). Of the actual spam, what fraction did the model flag?
  • F1 = 2 * P * R / (P + R). The harmonic mean of the two.

A confusion matrix prints the four counts as a 2x2 grid. The demo writes one to stdout for each training regime.

Check: The Metrics0/8
1. What does a confusion matrix show for a binary classifier?Define
2. With spam as the positive class, what does precision measure?Define
3. With spam as the positive class, what does recall measure?Define
4. Why can reading the confusion matrix tell you more than the F1 number alone?Explain
5. On the test set a model has TP = 40, FP = 10, FN = 20. What are precision and recall?Apply
6. Precision is 0.9 and recall is 0.6 on the spam class. What is the F1?Apply
7. A `Metrics` object has tn = 70, fp = 10, fn = 20, tp = 60. In the grid printed by `confusion()`, which number sits in the row 'actual spam' and the column 'pred ham'?Apply
8. A run has precision 0.95 and recall 0.55 on spam. What does the confusion matrix most likely show?Analyze

Architecture

Mermaid diagram

The body is a deliberately tiny transformer: vocab 260, hidden 64, 4 heads, 2 blocks, max sequence 32. It is small enough to train both regimes on a CPU. It is not pretrained in the lesson. Instead, the pretrain_quick helper runs five epochs of LM training on the train split's text (never the test split) to give the body a non-trivial starting point, which keeps the lesson self-contained.

Check: Architecture0/1
1. The demo runs five epochs of language-model training on the train split's text before classifier training. Why never use the test split for this step?Explain

What you will build

The implementation is one main.py plus one test module (code/tests/test_main.py).

  1. ByteTokenizer: maps bytes to ids, reserves a pad id.
  2. Block: a transformer block with multi-head attention and a feed-forward layer. Pre-norm.
  3. LMBody: token + position embeddings plus a stack of blocks. Returns hidden states.
  4. mean_pool: a function (not a class) that takes the mask-weighted average over the sequence axis.
  5. Classifier: body, pool, linear head. Both regimes start from the same pretrained weights: _clone_body deep-copies the body so each regime trains its own copy.
  6. freeze_body and unfreeze_body: toggle requires_grad on body parameters.
  7. train_classifier(model, train_loader, epochs, lr, seed): one shared loop. It builds an Adam optimiser over the parameters with requires_grad set, and raises ValueError if none are trainable.
  8. evaluate: runs the test set and returns Metrics(precision, recall, f1, tp, fp, fn, tn), whose confusion() method formats the 2x2 grid.
  9. run_demo: pretrains the body briefly on the train split texts, then trains and evaluates head-only, then full, prints both reports, and returns 0. If either regime's F1 is not above 0.5 it prints an error to stderr and returns 1.
Check: What you will build0/9
1. Why does `_clone_body` deep-copy the pretrained body once for each regime?Explain
2. In `train_classifier`, the optimiser is built from `[p for p in model.parameters() if p.requires_grad]`. What does this idiom do?Explain
3. With the lesson's `ByteTokenizer`, what does `encode("ok", 4)` return as (ids, attention_mask)? ('o' is byte 111, 'k' is byte 107, PAD_ID is 256.)Apply
4. A 40-byte message is encoded with `max_len` 32. What does the tokenizer return?Apply
5. A classifier has a 64-dimensional hidden size and 2 classes. How many trainable parameters does head-only training report?Apply
6. The demo's body has an embedding table, a position table, two blocks and a final LayerNorm. What does `freeze_body` return when called on its classifier?Apply
7. You call `freeze_body`, and then also set `requires_grad=False` on the linear head, before calling `train_classifier`. What happens?Apply
8. A rerun of the demo finishes with exit status 1 and an error on stderr, but no exception was raised. What does that tell you?Analyze
9. Your team wants to deploy the classifier and asks how to record which regime produced the production checkpoint. Which record answers that reproducibly?Design

Why the comparison matters

Head-only training usually trains faster and underfits more gracefully. The demo's only hard check is that both regimes beat random, meaning F1 above 0.5. Beyond that, typical runs land at strong precision and recall for both regimes, with exact numbers depending on the seed. Full fine-tuning costs more per epoch because gradients flow through the whole body, and it typically lands close to head-only on this fixture.

I don't declare a winner here. What matters is reading the numbers next to the cost. On 800 examples and a tiny body, head-only is the right call. On 80,000 examples and a bigger body, full fine-tuning starts to pay off. The contract to take away is the API: the same train_classifier function handles both regimes, and the toggle is one call.

Check: Why the comparison matters0/4
1. Why is it useful that one `train_classifier` function handles both regimes?Explain
2. On 800 examples and a tiny body, the lesson calls head-only the right call. What changes the answer toward full fine-tuning?Explain
3. Full fine-tuning reaches a lower training loss than head-only on 800 messages, but its test F1 is no better. What does this suggest?Analyze
4. You have 80,000 labelled messages from a domain far from the pretraining corpus, and a larger body. Which plan fits the lesson's reasoning?Design
Author's Note

I'd read the confusion matrix before the F1 number. Precision is built from FP and recall from FN, so each metric hides one of the two error cells, and the grid shows you which kind of mistake a regime is making.

My bet is that the useful experiment after this one is the partial fine-tuning regime from the stretch goals. Head-only and full fine-tuning sit at the two ends of the cost scale, and unfreezing only the last block lands between them. Because both regimes share train_classifier and differ by a freeze toggle, that third run is a small change to the code you already have.

Stretch goals

  • Add a third regime that unfreezes only the last block. This is sometimes called partial fine-tuning. It costs less than full FT and learns more than head-only.
  • Add a learning-rate scheduler. A cosine schedule on the head plus a smaller constant rate on the body is a common production setup.
  • Replace mean pooling with a learned attention pool: a small attention layer with one learned query. This often beats mean pool on longer sequences.

The implementation gives you the hooks. The tests pin the contract. The numbers are yours to push.

Check: Stretch goals0/1
1. You want a regime that costs less than full fine-tuning but learns more than head-only. Using the lesson's code, what is the smallest change?Design

Code