Part 3 — Full Fine-Tuning Versus a Frozen Encoder

Adapt a pretrained miniature router and inspect exactly which parameters changed.

Project: Workshop Assistant · Audience: basic Python developers · Run: CPU; optional external training only in Part 10.

A small model with two understandable pieces

Our router has an encoder and a head. The encoder turns a word-count vector into 16 hidden numbers using a linear layer and tanh. The head turns those hidden numbers into three scores. These scores are called logits. The largest score gives the predicted class.

Before adapting it, prepare() trains the network on 18 generic source guides, one per topic keyword. That is our educational pretraining stage. Fine-tuning then uses the 36 target questions from Part 2. The model is a small neural classifier, not a transformer and not an externally pretrained LLM.

The source task and target task are intentionally similar. An already-correct validation result is possible and useful: it means a fine-tuning run may have no benefit. Never promise an improvement simply because an optimizer ran.

Full fine-tuning updates the encoder and head. Head-only training freezes the encoder and updates the classifier at the end. Head-only training is often called feature extraction or a linear probe; some people reserve the term fine-tuning for updating the backbone itself. We use precise names rather than hiding that distinction.

What does freezing actually mean?

Freezing means setting requires_grad=False on parameters and excluding them from the optimizer. The forward calculation still uses those values. Freezing does not remove a layer from the network. Also, model.eval() controls behaviors such as dropout; it is not a substitute for freezing weights.

Part 3 explained visually

import copy
import torch
from ft_lab import prepare, method, encode, train, counts, accuracy
base, vocab, rows = prepare()
train_x, train_y = encode([r for r in rows if r['split']=='train'], vocab)
val_x, val_y = encode([r for r in rows if r['split']=='validation'], vocab)
results = {}
for name in ['full','head']:
    model = method(base, name)
    before = {key: value.detach().clone() for key,value in model.named_parameters()}
    history = train(model, train_x, train_y, steps=80, lr=.01)
    changed = [key for key,value in model.named_parameters()
               if not torch.equal(before[key], value.detach())]
    results[name] = {'counts': counts(model), 'changed': changed,
                     'validation': accuracy(model,val_x,val_y), 'history': history}
    print(name, results[name]['counts'], changed, results[name]['validation'])
    if name=='head': assert all(key.startswith('head.') for key in changed)

Executed output (CPU teaching experiment):

full {'trainable': 483, 'total': 483} ['encoder.weight', 'encoder.bias', 'head.weight', 'head.bias'] 1.0
head {'trainable': 51, 'total': 483} ['head.weight', 'head.bias'] 1.0

Follow the cell in order. Both methods receive the same pretrained base, the same training examples, and the same fixed number of steps. method() copies the base, so running the first method cannot silently change the second method's starting point. We snapshot each parameter before training and compare it afterward.

A linear layer with 16 outputs and d inputs contains 16*d weights plus 16 bias numbers. The three-class head contains 3*16 + 3 = 51 parameters. Head-only training therefore updates 51 numbers even when the encoder contains many more. Count the actual values in the output rather than relying on a diagram.

Read the learning curves

import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7,3))
for name,result in results.items(): ax.plot(result['history'], label=name)
ax.set(xlabel='Optimizer step', ylabel='Training cross-entropy', title='Training fit is not test quality')
ax.legend(); plt.show()
Measured output from the Part 3 notebook
Measured output from the executed Part 3 CPU notebook. These synthetic experiments do not establish production quality.

Cross-entropy penalizes low probability for the correct class. If the model assigns the correct label probability 0.25, its single-example loss is -ln(0.25) ≈ 1.386. At probability 0.8, the loss is about 0.223. A falling training curve means the model fits the training examples better. It does not prove performance on new questions.

In a larger experiment, try a few learning rates and use validation results to choose them. Do not assume the same rate is ideal for both methods. A pretrained backbone often needs a smaller rate than a newly initialized head. Our equal-step comparison isolates a mechanism; it is not a tuned benchmark.

Choosing between the two

Head-only training is a useful first experiment when the frozen representation already separates your classes. It saves gradient and optimizer storage for the encoder, but the forward pass still has a cost. Full fine-tuning offers more freedom when the representation needs to change, at the price of more trainable state and a greater opportunity to damage useful behavior.

The most flexible method is not automatically the best. With very little or noisy data, it may memorize the examples. Compare against the unchanged base and keep old-task examples to check forgetting. Our tiny, balanced synthetic data cannot settle which method will win on your production tickets.

Check your understanding

Print requires_grad for every named parameter. Then freeze the head and train only the encoder as a separate experiment. Which names should change? Make the prediction before running it. If a frozen value changes, inspect parameter sharing and the optimizer's parameter list.

Next, instead of changing the whole encoder, we will insert a small trainable path while keeping the original network fixed.

Further reading: PyTorch's transfer learning example.

← Part 2 · Part 4 →