Skip to content
Residual Stream
Go back

Fine-Tuning, and What It Destroys

Updated:
Tan Le
ENVI

Build a Tiny GPT, End to End, and Train It on This Blog trained a model from nothing, and Small and Specialized Models used models someone else had trained. Most real training sits between the two: start from a pretrained model and keep training it on new data. That is fine-tuning. It is cheap compared with pretraining, because the model already knows language, and it is dangerous for the same reason: the weights that hold what it knows are the weights fine-tuning changes.

Fine-tuning is a short move in weight space; how far decides what is learned and lost weight space (schematic) knows English, Vietnamese fits the blog pretrained 10⁻⁵ + replay 10⁻⁴ change by step 400, bits per byte learn forget blog WikiText Vietnamese lr 10⁻⁵ -0.087 +0.068 +0.050 lr 10⁻⁵ + replay -0.099 +0.016 +0.004 lr 10⁻⁴ +0.171 +0.506 +0.534 lower is better; means of two seeds, which agree to within about 0.02

Fine-tuning gains on the new text and loses on everything else. A small learning rate keeps the move short, replay holds it near general text, and ten times the learning rate makes everything worse. The left panel is a schematic.

Data: the experiment's table below, step 400 minus step 0, means of two seeds
At a glance
  • Fine-tuning is next-token training continued on new data. SFT trains only the answer’s tokens; the prompt’s labels are masked, so in the worked example 18 of 53 targets count.
  • The learning rate decides what happens: at 10−510^{-5} the model learned this blog in about 100 steps; at 10−410^{-4} it got worse at everything.
  • It forgets what it doesn’t train on, even unrelated things like Vietnamese, and keeps forgetting after it stops learning: by step 400, 0.07 bits per byte lost on English and 0.05 on Vietnamese.
  • Replay, a quarter of each batch from general text, cut the forgetting to a quarter on English and nearly zero on Vietnamese, at no cost to the final gain.

Table of contents

Open Table of contents

Three kinds of fine-tuning

All three use the same next-token cross-entropy as pretraining (Cross-Entropy and the Softmax Gradient). They differ in the data and in which tokens count:

  • Continued pretraining: raw text from a new domain (legal filings, code in a new language, this blog), every token trained. The model learns the domain’s vocabulary, facts and style.
  • Supervised fine-tuning (SFT), or instruction tuning: pairs of a prompt and a good answer, trained only on the answer. The model learns a behavior: to answer questions, follow a format, take a role. FLAN (Wei et al., 2022) showed that tuning on many tasks phrased as instructions makes a model better at instructions it never saw.
  • Preference tuning: pairs of a better and a worse answer, which RLHF, DPO and GRPO and Reinforcement Learning from Verifiable Rewards cover, after Reinforcement Learning for Language Models sets up reinforcement learning.

ULMFiT (Howard and Ruder, 2018) set out the recipe that BERT and GPT made standard: pretrain on general text, fine-tune on the target domain, then on the target task. It protected the general knowledge with learning rates that differ by layer and rise then fall over training, and by unfreezing the layers one at a time from the top.

The SFT loss, with the prompt masked

A chat model never sees “a prompt” and “an answer” as separate things: a chat template turns the conversation into one token sequence with special tokens marking each turn. The SFT loss is ordinary next-token cross-entropy over that sequence, except that the positions whose target belongs to the prompt get the label −100-100, which PyTorch’s cross-entropy ignores. Worked on one example, with Qwen2.5’s template (loaded from the 1.5B Instruct model’s tokenizer; the 0.5B base model used below has the same vocabulary and template):

# Supervised fine-tuning (SFT) on one chat example, through Qwen2.5's chat
# template. The model reads the whole conversation, but only the assistant's
# tokens are trained: every other position gets the label -100, which
# PyTorch's cross_entropy skips (ignore_index=-100).
tok = AutoTokenizer.from_pretrained(snapshot_download("Qwen/Qwen2.5-1.5B-Instruct",
                                                      revision="989aa7980e4cf806f80c7fef2b1adb7bc71aa306"))
chat = [{"role": "user", "content": "What does the KV cache store?"},
        {"role": "assistant", "content": "The keys and values of every earlier token, so they aren't recomputed."}]
prompt = tok.apply_chat_template(chat[:1], tokenize=False, add_generation_prompt=True)
full = tok.apply_chat_template(chat, tokenize=False)
print(full)
p_ids, f_ids = tok(prompt).input_ids, tok(full).input_ids
assert f_ids[:len(p_ids)] == p_ids                        # the prompt is a prefix of the full text
labels = [-100] * len(p_ids) + f_ids[len(p_ids):]
print(f"{len(f_ids)} tokens: {len(p_ids)} of prompt (system, user, assistant header), masked; "
      f"{len(f_ids) - len(p_ids)} trained:")
print(repr(tok.decode(f_ids[len(p_ids):])))

# In the loss, input positions 0..n-2 predict targets 1..n-1, and targets
# of -100 drop out of the average: F.cross_entropy(logits, y, ignore_index=-100).
y = torch.tensor(labels[1:])
print(f"targets in the loss: {(y != -100).sum().item()} of {len(y)}")

Full script: sft_loss.py

<|im_start|>system
You are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>
<|im_start|>user
What does the KV cache store?<|im_end|>
<|im_start|>assistant
The keys and values of every earlier token, so they aren't recomputed.<|im_end|>

54 tokens: 36 of prompt (system, user, assistant header), masked; 18 trained:
"The keys and values of every earlier token, so they aren't recomputed.<|im_end|>\n"
targets in the loss: 18 of 53
prompt: 36 tokens, label −100, ignored by the loss answer: 18 tokens, trained system · user · assistant header …recomputed.<|im_end|>⏎ Qwen2.5’s chat template on one example: 53 next-token targets, 18 of them in the loss the trained end-of-turn token is how the model learns to stop

The SFT loss on one example. Only the answer is trained, including the end-of-turn token that teaches the model to stop.

Data: fine-tuning-and-forgetting/sft_loss.py

Three details are worth noticing. The template adds a system prompt nobody wrote; train with it and serve with it, or the model sees at serving time a sequence it wasn’t trained on. Of 53 targets, 18 are trained: the model isn’t taught to predict the user’s question, which it should never generate. And the trained tokens include the end-of-turn token <|im_end|> and the newline after it: that is how the model learns to stop.

How far to move: learning rate, steps and memory

Pretraining a model of this size uses peak learning rates around 10−410^{-4} to 10−310^{-3}. Fine-tuning usually uses 10 to 100 times less, for one to a few passes over the data, because the starting point is already good: the goal is a short move to a nearby point that also fits the new data. The experiment below measures what happens at 10−510^{-5} and 10−410^{-4}.

Memory is the same as for pretraining. Updating every weight with AdamW in mixed precision costs 16 bytes per parameter before any activations (Initialization and Optimizers): 8 GB for the 0.5B model used here, 128 GB for an 8B model, more than one 80 GB GPU. That cost is what Parameter-Efficient Fine-Tuning’s LoRA removes. On the 16 GB Apple M4 laptop used here that left too little room for activations and the 151,936-wide logits, so the experiment freezes the tied embedding table (136 million of the 494 million weights), saving its gradients and AdamW state, 1.6 GB. Gradient checkpointing, used here, trades the activations’ memory for recomputing them in the backward pass.

An experiment: fine-tuning on this blog

Qwen2.5-0.5B, the base model, trained further on this blog’s posts: the training split of the blog corpus as the data script of Sequences Before Attention rebuilt it on 2026-10-04 (1,747,277 characters), about 530,000 tokens, with two posts held out. Each step takes 4 windows of 512 tokens at random, for 400 steps, about 1.5 passes over the data, with AdamW and a 20-step warmup. Three measurements, in bits per byte, at each checkpoint:

  • the held-out blog posts, what fine-tuning should improve;
  • WikiText-2’s test set, its first 120,000 characters (7 articles), general English the model knew before;
  • Vietnamese Wikipedia, something else it knew, which no run trains on.

Two learning rates, 10−510^{-5} and 10−410^{-4}, each with and without replay: one window in four drawn from English Wikipedia instead of the blog. Two seeds each.

The replay text needs care. A first version of this experiment replayed WikiText’s own training split, and on WikiText’s test set the replaying model ended better than it started, 0.981 down to 0.905 bits per byte: it was learning WikiText’s peculiar formatting (” @-@ ” for hyphens, ”= = Heading = =” for sections), which the test shares, not remembering English. Here replay draws from raw Wikipedia, excluding any article with a test-set title (none were in this shard). The two Wikipedia files are single shards of the wikimedia/wikipedia dataset’s November 2023 dump, 20231101.en/train-00001-of-00041.parquet and 20231101.vi/train-00002-of-00004.parquet, which fetch_wiki.py downloads as wiki/en.parquet and wiki/vi.parquet.

Full script: fetch_wiki.py

wiki_data.py drops the test’s articles from the English shard and cuts the replay text and the Vietnamese test from the two files. WIKI is the folder of WikiText-2 (raw), downloaded at a pinned revision, and the first line reads its test set:

test = "".join(pd.read_parquet(WIKI + "test-00000-of-00001.parquet")["text"])[:120_000]
titles = {re.sub(r" @(.)@ ", r"\1", t.strip()).replace("( ", "(").replace(" )", ")")   # undo WikiText's spacing
          for t in re.findall(r"^ = ([^=].*?) = $", test, flags=re.M)}
en = pd.read_parquet("wiki/en.parquet", columns=["title", "text"])
dropped = int(en["title"].isin(titles).sum())
en = en[~en["title"].isin(titles)].sample(frac=1.0, random_state=0)
replay, n = [], 0
for t in en["text"]:
    replay.append(t); n += len(t)
    if n > 4_000_000:
        break
open("wiki_replay.txt", "w").write("\n\n".join(replay))
vi = pd.read_parquet("wiki/vi.parquet", columns=["text"])
vi = vi[vi["text"].str.len() > 3000].sample(frac=1.0, random_state=0)      # skip the many one-line stubs
out, n = [], 0
for t in vi["text"]:
    out.append(t); n += len(t)
    if n > 120_000:
        break
open("vi_test.txt", "w").write("\n\n".join(out)[:120_000])
print(f"WikiText test articles: {len(titles)}, of which {dropped} are in this shard and dropped; replay text {sum(map(len, replay)):,} chars from {len(replay)} articles")

Full script: wiki_data.py

WikiText test articles: 7, of which 0 are in this shard and dropped; replay text 4,001,877 chars from 1101 articles

ft.py loads the model with its embedding table frozen and gradient checkpointing on, and scores the three texts in bits per byte at each checkpoint. Its batches and training loop:

def batch():
    n_wiki = round(B * replay)
    rows = []
    for k in range(B):
        src = wiki_ids if k < n_wiki else blog_ids
        i = torch.randint(0, len(src) - T - 1, (1,), generator=g).item()
        rows.append(src[i:i + T + 1])
    return torch.stack(rows).to(DEVICE)

log = {}
t0 = time.perf_counter()
for step in range(steps + 1):
    if step in checkpoints:
        log[step] = {k: round(bits_per_byte(*v), 4) for k, v in evals.items()}
    if step == steps:
        break
    x = batch()
    for group in opt.param_groups:                                  # 20-step warmup, then constant
        group["lr"] = lr * min(1.0, (step + 1) / 20)
    opt.zero_grad(set_to_none=True)
    for half in (x[:B // 2], x[B // 2:]):                            # two micro-batches of 2: the same batch of 4,
        with torch.autocast(DEVICE, dtype=torch.bfloat16):          # in half the memory for activations and logits
            logits = model(half[:, :-1]).logits
        loss = F.cross_entropy(logits.float().reshape(-1, logits.shape[-1]), half[:, 1:].reshape(-1)) / 2
        loss.backward()
        del logits, loss
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()

Full script: ft.py

ft.py runs as python ft.py <lr> <replay> 400 <seed> and prints one summary line per run. finetune_all.py runs all eight (four settings, two seeds) and keeps those lines in ft.out, and ft_table.py turns them into this table:

Full script: finetune_all.py

Full script: ft_table.py

before training: blog 1.163, WikiText 0.981, Vietnamese 1.022 bits per byte

bits per byte after each step, seed 0 then seed 1
lr      replay         step 25       step 50       step 100      step 200      step 400
1e-05     0%  blog     1.112 1.113   1.086 1.092   1.072 1.072   1.069 1.065   1.068 1.084
1e-05     0%  wiki     0.990 0.990   1.002 1.004   1.012 1.011   1.031 1.026   1.047 1.050
1e-05     0%  vi       1.028 1.029   1.035 1.035   1.042 1.042   1.054 1.052   1.071 1.072
1e-05    25%  blog     1.118 1.117   1.094 1.097   1.077 1.079   1.072 1.066   1.060 1.068
1e-05    25%  wiki     0.985 0.982   0.988 0.988   0.989 0.989   0.996 0.993   0.997 0.997
1e-05    25%  vi       1.022 1.022   1.021 1.020   1.021 1.021   1.024 1.022   1.026 1.025
0.0001    0%  blog     1.197 1.214   1.219 1.217   1.247 1.241   1.256 1.259   1.311 1.357
0.0001    0%  wiki     1.124 1.101   1.159 1.151   1.253 1.232   1.371 1.338   1.460 1.513
0.0001    0%  vi       1.114 1.105   1.149 1.136   1.251 1.223   1.400 1.506   1.502 1.609
0.0001   25%  blog     1.193 1.210   1.228 1.230   1.254 1.228   1.263 1.238   1.294 1.292
0.0001   25%  wiki     1.082 1.111   1.165 1.165   1.239 1.219   1.291 1.284   1.320 1.351
0.0001   25%  vi       1.088 1.088   1.146 1.178   1.239 1.226   1.572 1.293   1.455 1.400
1.05 1.10 1.15 0 100 200 400 steps held-out blog (learn) 0.98 1.00 1.02 1.04 1.06 0 100 200 400 steps WikiText-2 (forget) 1.02 1.04 1.06 1.08 0 100 200 400 steps Vietnamese (forget) lr 10⁻⁵, no replay (colored per panel) with 25% replay

Learning and forgetting at 10−510^{-5}. Without replay the model forgets English and Vietnamese as it learns the blog; with a quarter of each batch from Wikipedia it learns the blog just as well and barely forgets.

Data: the table above, means of two seeds

At 10−510^{-5} the model learns the blog, and forgets as it goes. The held-out posts improve from 1.163 to about 1.07 bits per byte, most of it in the first 100 steps. WikiText gets steadily worse, by 0.03 at step 100 and 0.07 at step 400, and so does Vietnamese, by 0.02 and 0.05, though nothing in training is Vietnamese or touches it on purpose. After step 200 the gain stops (one seed even loses some, 1.065 to 1.084, once it has seen about one pass’s worth of tokens and windows increasingly repeat) while the forgetting keeps growing. Stopping at step 100 would have kept nearly all of the gain and under half of the forgetting. (Step 100 is picked here by looking at the same held-out posts; in practice it would be chosen on a separate validation split.)

At 10−410^{-4} it learns nothing and breaks. The blog gets worse from the first checkpoint, 1.163 to 1.20 by step 25 and 1.31 to 1.36 by step 400, and WikiText and Vietnamese lose about half a bit per byte. 10−410^{-4} is at the low end of the peak learning rates used to pretrain models of this size, and even that throws the model out of the region where its knowledge lives. Two reasons it’s too large here, besides the starting point already being good: pretraining schedules end with the learning rate decayed far below its peak, so the model was last trained with much smaller steps, and these batches are 2,048 tokens, against millions in pretraining, so each step’s gradient is much noisier, while Adam still moves each weight by up to about the learning rate per step. The short warmup, 20 steps, doesn’t help.

1.0 1.2 1.4 1.6 0 100 200 400 steps bits per byte blog WikiText Vietnamese

Ten times the learning rate, and everything gets worse from the first checkpoint, including the text it is training on.

Data: the table above, means of two seeds

Replay at 10−510^{-5} removes most of the forgetting, and by step 400 costs nothing on the blog. With one window in four from Wikipedia, WikiText loses 0.016 at step 400 instead of 0.07, and Vietnamese loses 0.004 instead of 0.05. The blog learns a little more slowly at first, seeing a quarter fewer blog windows, but ends at 1.060 and 1.068, as good as without replay. That isn’t just the smaller share of blog text: a run without replay had already lost 0.03 on Vietnamese by step 200, after fewer blog windows than the 300 steps’ worth the replay run sees in 400.

Vietnamese was protected too, though none was replayed. So replay isn’t only rehearsing the facts it replays: the general text in each batch pulls back against the changes that would hurt general text, and those changes hurt Vietnamese as well. Both tests are Wikipedia, like the replay, so part of this may be rehearsing the genre (encyclopedic prose, names, dates); a test outside Wikipedia would tell the two apart.

Replay can’t rescue a learning rate that is too high. At 10−410^{-4} it helps a little (WikiText 1.32 and 1.35 instead of 1.46 and 1.51 at step 400) but every measurement still ends far worse than it started, and erratically: one seed’s Vietnamese score reaches 1.57 at step 200 before coming back to 1.46.

Two caveats. The tied embedding table, 136 million of the 494 million weights, was frozen to fit training on this laptop, so these are full fine-tunes of everything else. And this is one small model, one small dataset, a held-out blog of two posts and two seeds per setting; the seeds agree to within about 0.02 at 10−510^{-5} and disagree more at 10−410^{-4}, where training is unstable.

Why it forgets

McCloskey and Cohen (1989) named it catastrophic interference: a network trained on task A and then only on task B loses A, often abruptly, because the same weights store both and B’s gradient has no reason to protect what A needed. Fine-tuning is exactly that setup. Nothing in the loss mentions WikiText; every step moves weights toward the blog, and some of those weights were doing other work.

Three families of remedies:

  • Replay: keep training on some of the old distribution. It is the simplest and, as measured above, it works, at the price of having data like the original training set and spending part of each batch on it.
  • Regularization: penalize moving the weights that mattered for the old task. EWC below is the classic form; the simplest form is a small learning rate and few steps.
  • Parameter isolation: freeze the original weights and train new ones next to them. LoRA and adapters (Parameter-Efficient Fine-Tuning) work this way, and the original model is always one step away: drop the new weights.
replay keep training on some old data e.g. ¼ of each batch regularization penalize moving the weights that mattered EWC, or a small learning rate parameter isolation freeze the old weights, train new ones LoRA, adapters measured here: replay at lr 10⁻⁵ cut forgetting to a quarter on English, near zero on Vietnamese

Three ways to fine-tune without forgetting. This post measures the first and derives the second; Parameter-Efficient Fine-Tuning measures the third.

EWC, derived

Elastic weight consolidation (Kirkpatrick et al., 2017) asks which weights mattered for task A, and pulls those back. Start from Bayes’ rule for weights θ\theta after seeing data AA and then BB:

log⁡p(θ∣A,B)=log⁡p(B∣θ)+log⁡p(θ∣A)−log⁡p(B∣A).\log p(\theta \mid A, B) = \log p(B \mid \theta) + \log p(\theta \mid A) - \log p(B \mid A).

The first term is the new task’s log-likelihood, the negative of its loss. The second holds everything task AA taught. It can’t be computed exactly, so approximate it by a second-order Taylor expansion around θA∗\theta^*_A, the weights after training on AA (the Laplace approximation: the posterior as a Gaussian around its peak; with a flat prior, that peak is the minimum of AA‘s loss). At that minimum the first derivative is zero, so

log⁡p(θ∣A)≈const−12(θ−θA∗)⊤H(θ−θA∗),\log p(\theta \mid A) \approx \text{const} - \tfrac{1}{2} (\theta - \theta^*_A)^\top H (\theta - \theta^*_A),

with HH the curvature of −log⁡p(θ∣A)-\log p(\theta \mid A) at θA∗\theta^*_A. For a model trained by maximum likelihood, HH is approximated by the Fisher information FF, the expected outer product of the gradient of the log-probability, which only needs gradients (H≈NFH \approx N F for NN examples; the NN folds into λ\lambda below). Keeping only its diagonal, one number per weight, gives the EWC loss for training on BB:

L(θ)=LB(θ)+λ2∑iFi(θi−θA,i∗)2.\mathcal{L}(\theta) = \mathcal{L}_B(\theta) + \frac{\lambda}{2} \sum_i F_i \left(\theta_i - \theta^*_{A,i}\right)^2 .

FiF_i is the average, over task-AA examples, of the squared gradient of log⁡q(y∣x)\log q(y \mid x) with respect to weight ii, for one example at a time (not the square of a batch’s average gradient), with yy sampled from the model. Using the data’s own labels for yy instead gives the “empirical Fisher”, the common cheap substitute, and a worse approximation of HH. A weight whose change barely moved task AA‘s likelihood has Fi≈0F_i \approx 0 and is free to move; a weight task AA depended on is held near its old value by a spring of stiffness λFi\lambda F_i. λ\lambda sets how much of the old task to keep. For a pretrained language model, “task AA” is pretraining, so FF has to be estimated from per-example gradients on a sample of general text, and stored along with a copy of the original weights: two more numbers per parameter.

θ*A​ weight i: task A depended on it stiff spring: λF large θ*A​ weight j: task A barely used it loose spring: λF ≈ 0, free to move

EWC’s penalty, one weight at a time. The Fisher information sets each spring’s stiffness.

When fine-tuning is the wrong tool

Fine-tuning changes how a model behaves: its format, its style, its domain vocabulary, the kind of answer it gives. It is a poor way to give a model facts it must state reliably. A fact seen a few times in fine-tuning data is learned weakly, it can’t be updated without training again, and the model can’t say where it came from. Ovadia et al. (2024) compared the two on knowledge-heavy questions and found retrieval beat unsupervised fine-tuning, both for facts the model had seen and for new ones. For facts, put them in the context: retrieval, from Vector Search on. For behavior, fine-tune.

Where this leads

Takeaways

  • Fine-tuning is next-token training continued on new data; SFT trains only the answer’s tokens, the prompt’s labels masked to −100-100, and the end-of-turn token is what teaches the model to stop.
  • The learning rate decides what fine-tuning does. At 10−510^{-5} this model learned the blog in about 100 steps; at 10−410^{-4}, near pretraining’s peak, it got worse at everything from the start.
  • Fine-tuning forgets what it doesn’t train on, including things unrelated to the new data (Vietnamese, here), and keeps forgetting after it stops learning, so stop early, choosing when on a validation split.
  • Replay, a quarter of each batch from general text, cut the forgetting to a quarter on English and to almost nothing on Vietnamese, which it never replayed, at no cost to the final gain: it holds back more than just what it rehearses.
  • Check that replay data doesn’t share the test set’s distribution: a first version replayed WikiText’s training split and made WikiText look better than before fine-tuning.
  • EWC is the principled alternative: a quadratic penalty on moving each weight, weighted by the diagonal Fisher information from the old task.
  • Fine-tune for behavior and style; for facts the model must state and update, retrieval works better.

References

Start with Kirkpatrick et al. for EWC, then Ovadia et al. on fine-tuning against retrieval for facts. Howard and Ruder’s ULMFiT paper is the origin of the pretrain-then-fine-tune recipe.


Written by

Tan Le

AI engineer. I write these notes to learn LLM internals and AI engineering in depth, and to keep what I learn where I can find it again.