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

Pretraining & Fine-Tuning

General-purpose representation learning followed by task- or behaviour-specific adaptation.

At work: Adapting a large general model to a company's own task, like support replies or contract review, at a fraction of the cost.

Pretraining, supervised fine-tuning and parameter-efficient adaptation

What it is

Pretraining learns broad statistical structure from large, diverse corpora using a self-supervised objective such as next-token prediction. Fine-tuning then adapts that base model with curated labelled examples, instructions, or preference data so its outputs suit a narrower task or behaviour target. Parameter-efficient methods such as LoRA add small trainable low-rank updates while keeping most base weights frozen, reducing memory and making several adaptations practical to store. The data format, prompt template, target construction, and split policy are all part of the fine-tuning method—not preprocessing footnotes.

Why it matters

This division explains how capable models can be adapted without reproducing their original compute budget, and why data quality often matters more than a larger fine-tuning run. Fine-tuning can improve a task, but it can also cause catastrophic forgetting, narrow a model's style, or teach benchmark leakage; compare against the untouched base model on held-out and representative evaluations.

In the real world: Taking a general model and adapting it to write in a company's support style.

Visual example

Deep learning

Broad pretraining creates a base model; narrower data changes its behaviour for a specific use.

Adaptation lifecycle

  1. pretraining corpusgeneral language or image structure
  2. base modelbroad reusable representations
  3. fine-tuning examplestarget task or preference signal
  4. adapted modelevaluated on untouched target examples

Evaluation data must remain outside both training stages.

import torch

checkpoint = torch.load('base_model.pt', map_location='cpu')
model.load_state_dict(checkpoint['state_dict'])

# Fine-tuning uses a much smaller learning rate than pretraining did.
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01)

Mixing evaluation examples into fine-tuning data. A polished benchmark score then measures memorisation, not task generalisation.

The two-stage paradigm

What it is

Modern practice splits training into two phases with almost nothing in common. Pretraining runs a self-supervised objective over an enormous, broad, unlabelled corpus for a long time at high learning rate, producing a general base model. Adaptation then runs a small, curated, usually labelled dataset for a short time at a much lower learning rate, producing a model suited to one task or behaviour. The phases differ in data volume by five or six orders of magnitude, and in compute by four or five.

Why it matters

Stating it plainly matters because the two phases have opposite failure modes and opposite instincts. In pretraining, more data is almost always better and overfitting is barely a concern; a run underfits and you scale it up. In fine-tuning, more data is rarely the constraint, overfitting arrives within an epoch or two, and the winning move is usually *fewer, better* examples and an earlier stop. Someone who carries pretraining intuitions into a fine-tuning run — train longer, raise the learning rate, add data — reliably destroys a good base model.

In the real world: An expensive general training done once, then cheap adaptation for each business use.

Visual example

Two stages

The two phases differ by orders of magnitude in every column — which is why they need opposite instincts.

Done once, by a few

(months of GPU fleet time)

Done often, by many

(hours on one machine)

One paradigm, two regimes
datalearning ratemain risk
pretraining~10^11 tokens, unlabelled3e-4underfitting
fine-tuning~10^4 examples, curated2e-5overfitting in 2 epochs

Every instinct that helps in row 0 hurts in row 1

Why this matters

Most fine-tuning disasters are a pretraining habit applied in the wrong regime: too many epochs, too high a rate, too much data of mediocre quality.

Common misconception

Wrong idea: fine-tuning is just training, continued.

Correct idea: it is training with the opposite dominant risk. You are no longer trying to make the model learn; you are trying to change it as little as possible while still changing it enough.

# The same code, two utterly different configurations.
PRETRAIN = dict(tokens=3e11, lr=3e-4, epochs=1, batch_tokens=2e6, warmup=2000)
FINETUNE = dict(tokens=2e6,  lr=2e-5, epochs=3, batch_tokens=3e4, warmup=20)

for name, cfg in [('pretrain', PRETRAIN), ('finetune', FINETUNE)]:
    steps = cfg['tokens'] * cfg['epochs'] / cfg['batch_tokens']
    print(f"{name:>9}: {cfg['tokens']:.1e} tokens, lr={cfg['lr']}, {steps:,.0f} steps")

# pretrain: 3.0e+11 tokens, lr=0.0003, 150,000 steps
# finetune: 2.0e+06 tokens, lr=2e-05, 200 steps

Reusing the pretraining learning rate for fine-tuning. At 3e-4 a few hundred steps can overwrite the representations that took a hundred thousand steps to build; fine-tuning rates are typically ten to fifty times smaller for exactly this reason.

Why pretraining works at all

What it is

The pretraining objective is not the task anyone cares about. Nobody needs a next-token predictor. What makes it valuable is that predicting the next token well *requires* internal machinery that is useful for almost everything else: representations that track syntax, entity identity, factual associations, numeric relations and discourse state. Pretraining is therefore a way of buying general representations with cheap, abundant, unlabelled data instead of expensive, scarce labels.

Why it matters

This reframes the label economy, which is the actual reason the paradigm took over. Labelled data is expensive and domain-specific; raw text and images are effectively free and general. A self-supervised objective converts the free resource into the expensive one, so the scarce labels only have to teach the last, narrow step — how to format an answer, which of the model's existing distinctions matters here — rather than how language works. That is why a hundred well-chosen fine-tuning examples can now outperform a bespoke model trained on fifty thousand labels from scratch.

In the real world: A model that has read vast amounts of text already understands language, so a company needs far less of its own data.

# What the frozen base model already knows, measured before any fine-tuning.
import torch

@torch.no_grad()
def probe(base_model, texts, labels):
    """Train only a linear head on frozen features. If it scores well,
    the representation already separates the classes."""
    base_model.eval()
    features = torch.cat([base_model.encode(t) for t in texts])   # (n, d)
    return features.detach(), labels

# A linear probe at 88% means the base model had already learned the
# distinction. Fine-tuning is then teaching output format, not concepts.

Assuming a poor zero-shot score means the model lacks the knowledge. A linear probe on frozen features often scores far higher, which says the representation is there and only the output mapping is missing — a much cheaper problem to fix.

LoRA and parameter-efficient fine-tuning

What it is

Full fine-tuning updates every weight. LoRA freezes the original weight matrix W entirely and learns a low-rank update instead: two thin matrices A (r × k) and B (d × r) whose product is added at inference, so the effective weight is W + BA. With r far smaller than d and k, the trainable parameter count collapses — a 4096 × 4096 matrix has 16.8M weights, but a rank-8 update has only 65,536, a factor of 256. Adapters take a related approach with small inserted bottleneck layers.

Why it matters

The memory saving is much larger than the parameter saving, and that is the practical point. Optimiser state for AdamW is roughly two extra float32 values per *trainable* parameter, and gradients one more, so full fine-tuning of a 7B model needs tens of gigabytes beyond the weights themselves. LoRA at rank 8 makes that term nearly disappear, which is what moves fine-tuning a large model from a multi-GPU job to a single-GPU one. It also changes the storage story: a LoRA adapter is a few megabytes, so one frozen base model can serve dozens of task-specific adaptations that are swapped at request time rather than deployed as separate multi-gigabyte checkpoints.

In the real world: Customising a large model on one GPU by training a small add-on instead of the whole model.

Visual example

LoRA rank 8

Where the memory actually goes. The parameter saving is 256×; the training memory saving is what makes it possible at all.

Unavoidable either way

(the base weights still have to be resident)

Where the win is

(AdamW keeps 2 fp32 moments per trainable weight)

Fine-tuning a 7B model, memory by component
full fine-tuneLoRA r=8
weights (frozen)14 GB (fp16)14 GB (fp16)
gradients14 GB0.03 GB
optimizer state56 GB0.13 GB
total84 GB14.2 GB

W + B @ A

rank-8 update, 0.39% of the parameters

84 GB (a multi-GPU job) becomes 14.2 GB (one GPU)

The arithmetic, for one 4096 × 4096 matrix

full: 4096 × 4096 = 16,777,216 trainable weights

LoRA r=8: 8 × 4096 + 4096 × 8 = 65,536 trainable weights

ratio: 256× fewer trainable parameters

AdamW: each trainable weight costs 4 B grad + 8 B moments = 12 B of state

So the saving compounds: fewer trainable weights removes gradients and optimizer state together

Why this matters

People often justify LoRA by parameter count, which undersells it. The reason it changed who can fine-tune a large model is that gradients and optimiser state — not weights — are what overflow a single GPU.

Common misconception

Wrong idea: LoRA trains a smaller, weaker model.

Correct idea: it trains the same model with a constrained update. Capacity is limited to a rank-r change per matrix, which for most adaptation tasks turns out to be plenty — and acts as regularisation besides.

import torch
import torch.nn as nn

class LoRALinear(nn.Module):
    def __init__(self, base: nn.Linear, rank=8, alpha=16):
        super().__init__()
        self.base = base
        for p in self.base.parameters():
            p.requires_grad = False                       # W is frozen
        self.a = nn.Parameter(torch.randn(rank, base.in_features) * 0.01)
        self.b = nn.Parameter(torch.zeros(base.out_features, rank))
        self.scale = alpha / rank

    def forward(self, x):
        return self.base(x) + (x @ self.a.T @ self.b.T) * self.scale

layer = LoRALinear(nn.Linear(4096, 4096), rank=8)
trainable = sum(p.numel() for p in layer.parameters() if p.requires_grad)
frozen = sum(p.numel() for p in layer.parameters() if not p.requires_grad)
trainable, frozen        # (65536, 16781312) - 0.39% trainable

Initialising both A and B randomly. B must start at zero so that BA = 0 and the adapted model is *exactly* the base model at step 0; a random B perturbs every layer before training begins and can wreck the pretrained function in the first few steps.

Instruction tuning and RLHF

What it is

A raw pretrained language model continues text; it does not answer questions, because nothing in next-token prediction distinguishes a request from a document to be extended. Instruction tuning fixes this with supervised fine-tuning on (instruction, good response) pairs, teaching the model that a prompt in that format should be *followed*. RLHF goes further: humans rank alternative responses, those rankings train a reward model, and the language model is then optimised to score well under it — learning from comparative preference rather than from a single demonstrated answer.

Why it matters

The shape of the idea is what matters, and it is this: some qualities are much easier to *rank* than to *demonstrate*. Asking an annotator to write the ideal answer is hard, slow and inconsistent; asking them which of two answers is better is fast and fairly reliable. RLHF is a machine for converting that cheap comparative signal into a training gradient. It is also the stage responsible for most of what people perceive as a model's character — helpfulness, hedging, refusals, verbosity, format habits — none of which came from pretraining.

In the real world: How assistants learn to follow instructions and answer helpfully and safely.

Visual example

Deep learning

Three stages, each teaching something the previous one structurally could not.

From next-token predictor to assistant

  1. pretrainingnext-token prediction on broad text
  2. instruction tuning(instruction, response) pairs: follow, do not continue
  3. reward modeltrained on human rankings of response pairs
  4. preference optimisationpolicy improves under reward, KL-anchored to stage 2

Only stage 1 needs the huge corpus; stages 2 and 3 are comparatively tiny and are where behaviour comes from.

# The shape of the preference signal, without the RL machinery.
import torch
import torch.nn.functional as F

def reward_model_loss(reward_chosen, reward_rejected):
    """Bradley-Terry: the chosen response should simply score higher."""
    return -F.logsigmoid(reward_chosen - reward_rejected).mean()

# Stage 1: supervised fine-tuning on demonstrations.
# Stage 2: fit a reward model on human comparisons with the loss above.
# Stage 3: optimise the policy for reward, penalised for drifting
#          too far from the SFT model:
#          objective = reward - beta * kl_divergence(policy, sft_model)

Treating the reward model as ground truth. It is a learned approximation of human preference, and optimising hard against it finds its errors — outputs that score highly and are worse, which is why a KL penalty toward the supervised model is part of the objective rather than an optional extra.

Catastrophic forgetting in large pretrained models

What it is

Fine-tuning moves weights to reduce loss on the new data, and nothing in that objective preserves anything else. With a large pretrained model the loss is already low everywhere, so the gradient from a narrow dataset is free to trade away capabilities that dataset never exercises. A model fine-tuned hard on medical summarisation can lose fluency in other languages, forget how to write code, and stop following instruction formats it handled perfectly before — while its held-out medical summarisation metric improves the whole time.

Why it matters

Module 14 raised forgetting for vision backbones; here the stakes and the detection problem are different. A vision backbone has one downstream task, so a regression shows up in that task's metric. A general language model has effectively unbounded capability surface, and your fine-tuning evaluation covers a vanishing fraction of it. So the regression is invisible by construction: every number on your dashboard goes up. The defences are structural — keep a fixed general-capability evaluation suite beside the task metric, prefer LoRA (which constrains how far the function can move), use low learning rates, stop early, and mix a little general data into the fine-tuning set.

In the real world: Fine-tuning on legal documents can make a model worse at ordinary conversation.

Visual example

Forgetting

The task metric rises for the whole run. The general capability it was built on falls from step 200 onward.

target task accuracygeneral capability suitegeneral capability, LoRA r=8
paying for task gains with capability02004006008001,0000.40.50.60.70.80.91fine-tuning stepsscorestop here+21% task, -1% general+30% task, -35% general

Why this matters

Both solid lines come from the same run. If you only plot the green one — which is what a task-focused training script does by default — the red one is invisible until a user finds it.

The last 3% of task accuracy cost 27 points of everything else. Only the dashed run measured it in time.

# A fine-tuning run is only evaluated honestly if the general suite runs too.
GENERAL_SUITE = ['instruction_following', 'code_completion', 'multilingual', 'arithmetic']

def evaluate_run(model, task_eval, baseline_scores, tolerance=0.03):
    report = {'task': task_eval(model)}
    regressions = []
    for name in GENERAL_SUITE:
        score = run_benchmark(model, name)
        report[name] = score
        if score < baseline_scores[name] - tolerance:
            regressions.append((name, baseline_scores[name] - score))
    if regressions:
        raise RuntimeError(f'task improved but general capability regressed: {regressions}')
    return report

Reporting only the task metric and the task validation loss. Both will look excellent on exactly the run that has quietly cost the model half of what you were paying for.

The cost asymmetry

What it is

Pretraining and adaptation sit at opposite ends of a very steep curve. A frontier pretraining run costs millions of dollars, occupies thousands of accelerators for weeks, and is performed by a handful of organisations a handful of times. A LoRA fine-tune costs single-digit dollars, occupies one GPU for an hour, and is performed by anyone. The ratio between them is roughly six orders of magnitude.

Why it matters

That ratio is the reason the two-stage paradigm reshaped the field, and it is an economic argument rather than a technical one. Before it, every serious model was trained from scratch, so capability was gated on having a large labelled dataset and a large compute budget — which meant very few people could build anything good. Pretraining amortises the expensive phase across everybody who ever adapts the result: the cost is paid once, by someone else, and what remains for you is small enough to iterate on in an afternoon. Capability stopped being something you trained and became something you started from.

In the real world: Pretraining costs millions; fine-tuning can cost a few thousand, which is why most companies fine-tune.

# The ratio, with round but realistic figures.
pretrain = dict(gpu_hours=2_500_000, usd_per_gpu_hour=2.0, runs_per_year=1)
lora     = dict(gpu_hours=2,         usd_per_gpu_hour=2.0, runs_per_year=400)

for name, cfg in [('pretrain', pretrain), ('LoRA finetune', lora)]:
    per_run = cfg['gpu_hours'] * cfg['usd_per_gpu_hour']
    print(f'{name:>14}: ${per_run:>12,.0f} per run, {cfg["runs_per_year"]:>4} runs/year')

print('ratio:', f"{pretrain['gpu_hours'] / lora['gpu_hours']:,.0f}x")

#       pretrain: $   5,000,000 per run,    1 runs/year
#  LoRA finetune: $           4 per run,  400 runs/year
# ratio: 1,250,000x

Concluding that because fine-tuning is cheap it is the answer to every problem. Prompting and retrieval are cheaper still and need no training run at all; fine-tuning earns its keep when you need a behaviour, format or domain style that no prompt reliably produces.

Overfitting a large model on a small dataset

What it is

A model with billions of parameters and a thousand fine-tuning examples can memorise the entire dataset almost immediately. Training loss falls toward zero within one or two epochs while validation loss turns upward, and because the starting model was already highly capable, the memorised version can still produce fluent, confident output — so nothing *looks* broken. What has happened is that the model has learned your examples rather than your task.

Why it matters

The instinct this defeats is the ordinary one: when a model underperforms, train it more. Here, more training is precisely the cause. The symptoms are specific and recognisable — outputs that reproduce phrasing from the fine-tuning set verbatim, sharply degraded behaviour on inputs slightly outside its distribution, and a validation curve that bottoms out before the first epoch ends. The practical protocol is to hold out a real validation split, evaluate several times per epoch rather than once, stop at the minimum, and treat two or three epochs as an upper bound rather than a starting point.

In the real world: A model fine-tuned on 500 examples may parrot them back instead of generalising.

Visual example

Early stopping

With a large base model and a small dataset the validation minimum arrives before the first epoch ends.

training lossvalidation loss
memorising the fine-tuning set0123400.20.40.60.811.21.41.6epochslossbest checkpointepoch 0.7 - the minimumfluent, confident, worse

Why this matters

The x-axis is the point: the useful part of this run is over in under one epoch. A default of ten epochs, inherited from small-model training, spends 90% of its time making the model worse.

Common misconception

Wrong idea: a bigger pretrained model is more robust to a small fine-tuning set.

Correct idea: it overfits faster, because it has the capacity to memorise a thousand examples in a few hundred steps and the fluency to hide having done so.

Training loss keeps improving for the whole run, so it carries no warning at all.

import torch

best, patience, bad_evals = float('inf'), 3, 0
evals_per_epoch = 8                     # not once per epoch - far too coarse

for step, batch in enumerate(loader, start=1):
    train_step(model, batch)
    if step % (len(loader) // evals_per_epoch) == 0:
        val = evaluate(model, val_loader)
        if val < best:
            best, bad_evals = val, 0
            torch.save(model.state_dict(), 'best.pt')
        else:
            bad_evals += 1
            if bad_evals >= patience:
                print(f'early stop at step {step}, best val {best:.4f}')
                break

model.load_state_dict(torch.load('best.pt'))   # never ship the last epoch

Evaluating once per epoch on a 500-example dataset. The minimum can occur a third of the way into epoch one, so a per-epoch schedule never sees it and every checkpoint you saved is already past the point of damage.