At a glance
Key takeaways
- Decide with an eval: build a held-out set first; try prompting, then retrieval, and fine-tune only for behaviour a prompt can't pin down. It pays off after C / (c_prompt − c_tuned) requests.
- Data: use the model's chat template and train only on assistant turns; deduplicate (Jaccard on word shingles); freeze the held-out set before training and remove near-copies of it; audit labels, because wrong labels cap what the eval can show. Quality beats quantity.
- Forgetting: every weight is shared, so learning a new task erodes old ones, in proportion to how far the weights move and how much the tasks conflict. Fewer steps, a lower learning rate and LoRA shorten the trip; replaying a little old data is what keeps a contradicted skill.
- Overfitting: on small data, validation loss bottoms out early; ship the best checkpoint, not the last.
- Merging: a task vector is θ_ft − θ_base. Adding task vectors combines skills without training; averaging is λ = 1/T and dilutes them; vectors that point in opposite directions interfere.
Level 2
How it works, from scratch
Picture a skilled cook who joins your restaurant. They already know how to cook (that is the pretrained model). You want them to cook your menu, your way. You can hand them a note with every order (a prompt), give them the recipe binder to look things up in (retrieval, RAG), or send them on a course about your kitchen (fine-tuning). The course is the only option that changes the cook, and it comes with the risks every teacher knows: the lessons can be badly written, the cook can cram the practice exam instead of learning, and a month of drilling one cuisine can make them rusty at everything else.
Everything below is one of those risks, measured on a model small enough to train in a fraction of a second.
Chapter 1
Should you fine-tune at all?
Everyday picture Giving the cook a note with every order costs a little each time. The course costs a lot once, and it has to be repeated whenever you hire a new cook (switch to a newer base model). The course pays off only when the notes would otherwise be long, sent millions of times, or simply not enough to make the cook consistent.
Tiny worked example A support bot sends a 3,000-token prompt full of instructions and examples on every request. A fine-tuned model has learned that behaviour and needs only 300 tokens of prompt. Take these illustrative prices (real ones vary by provider and change often): $2 per million input tokens for the general model, $4 per million for the tuned one, because hosting a custom model usually costs more per token.
| Tokens per request | Price per million | Cost per request | |
|---|---|---|---|
| prompted | 3,000 | $2 | 3,000 × 2 / 1,000,000 = $0.0060 |
| fine-tuned | 300 | $4 | 300 × 4 / 1,000,000 = $0.0012 |
Each request saves $0.0048. Writing and checking 1,000 training examples plus the training run costs, say, $600 once. After 600 / 0.0048 = 125,000 requests the course has paid for itself: 25 days at 5,000 requests a day.
Figure 1 · Diagram
flowchart TD
E[Build the eval set first] --> P[Best prompt you can write]
P --> M1{Good enough<br/>on the eval?}
M1 -->|yes| SHIP[Ship the prompt]
M1 -->|"no: missing facts"| R[Add retrieval, RAG]
R --> M2{Good enough<br/>on the eval?}
M2 -->|yes| SHIP
M2 -->|"no: wrong behaviour, format or style"| FT[Fine-tune, often with LoRA]
FT --> M3{Beats the prompt<br/>on the SAME eval?}
M3 -->|yes, and pays off| SHIPFT[Ship the fine-tune]
M3 -->|no| P
primer.ml.training_stages has the same decision as a
flowchart of approaches; this one adds the measurement at every step.Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| "N-star": the break-even number of requests | 125,000 | |
| one-off cost: writing and checking data, the training run | $600 | |
| cost of one request | ||
| cost of one request with the long prompt, and with the fine-tuned model | $0.0060, $0.0012 | |
| input tokens sent per request | 3,000 and 300 | |
| price in dollars per million input tokens | $2 and $4 | |
| one million: prices are quoted per million tokens |
In words: "divide what the course costs once by what it saves on each request; that is how many requests it takes to earn the cost back."
With the numbers: = 3,000 × 2 / 10⁶ = 0.006, = 300 × 4 / 10⁶ = 0.0012, so = 600 / 0.0048 = 125,000.
Level 3: in Python
# c = t·p / 1,000,000 for each option
c_prompt = 3000 * 2.0 / 1_000_000
c_tuned = 300 * 4.0 / 1_000_000
c_prompt, c_tuned # → (0.006, 0.0012)
C_once = 600
# N* = C_once / (c_prompt − c_tuned)
N_star = C_once / (c_prompt - c_tuned)
round(N_star) # → 125000
# days to break even at 5,000 requests a day
round(N_star / 5000, 1) # → 25.0
Figure 2 · Chart
The prompted line starts at zero and climbs steeply; the fine-tuned line starts at 600 dollars and climbs slowly; they cross at 125,000 requests
In code: per_request_cost prices one request and
break_even_requests returns , or infinity when the tuned model
saves nothing per request.
Why it matters in practice. Three costs hide outside this formula.
Every new base model means repeating the fine-tune, so
recurs. Facts learned by fine-tuning go stale and are hard to update, which
is why knowledge belongs in retrieval (primer.agents.rag). And the
engineering time to build an eval is spent whichever way you go, which is
why it comes first.
Chapter 2
Preparing the data
Everyday picture Before the course, someone writes the course book. Every recipe must be written in the same layout, no recipe may appear five times, some recipes are locked in a drawer for the final exam before the cook ever sees the book, and the recipes must actually be right. Most fine-tuning failures are course-book failures.
Figure 3 · Diagram
flowchart LR RAW[Raw examples<br/>logs, experts, drafts] --> FMT[Format as chat<br/>messages] FMT --> VAL[Validate<br/>roles, empty turns] VAL --> DD[Deduplicate<br/>near-copies] DD --> SPLIT[Set aside the<br/>held-out set, frozen] SPLIT --> LEAK[Drop training examples<br/>that copy held-out ones] LEAK --> AUDIT[Audit a sample<br/>of the labels] AUDIT --> TRAIN[Training set] SPLIT --> EVAL[Held-out set]
2a. Formatting chat examples
Everyday picture A play script: every line starts with who speaks it. The model learns the play by reading scripts, and it is graded only on the lines of the character it will play, the assistant.
Tiny worked example One example as a list of role-tagged messages:
| Turn | Role | Content | Trained on? |
|---|---|---|---|
| 1 | system | Be brief. | no |
| 2 | user | Capital of France? | no |
| 3 | assistant | Paris. | yes |
Rendered into text, it becomes
<|system|>Be brief.<|end|><|user|>Capital of France?<|end|><|assistant|>Paris.<|end|>,
and only the last segment counts towards the loss.
Figure 4 · Diagram
flowchart LR S["system: Be brief."] --> U["user: Capital of France?"] --> A["assistant: Paris."] S -.->|"context only, no loss"| L[Loss] U -.->|"context only, no loss"| L A ==>|"every token scored"| L
primer.ml.training_stages, section 2.The rules that matter. Use the exact chat template the base model was trained with; the role markers above are illustrative, and each model family has its own. Train with the same system prompt you will deploy with. Reject examples whose last turn is not the assistant's (nothing to learn), whose turns are empty, or whose roles are unknown.
In code: chat_example builds the message list, render_chat flattens
it into (segment, trained?) pairs, and validate_chat lists every problem
with an example.
Why it matters in practice. A template mismatch is silent: training succeeds, the loss falls, and the deployed model sees markers it never learned, so it behaves like the base model or worse.
2b. Deduplication
Everyday picture A flashcard deck with the same card in it five times. You study that card five times as often, and you start answering every question with it.
Tiny worked example Compare questions by their word pairs (two neighbouring words; a window of k words is called a shingle), after lowercasing and dropping punctuation:
| Text | Word pairs |
|---|---|
| "How do I reset my password?" | how do, do i, i reset, reset my, my password |
| "how do I reset my password, please" | the same 5, plus password please |
| "How do I change my email?" | how do, do i, i change, change my, my email |
The first two share 5 of the 6 distinct pairs between them: a near-duplicate. The first and third share 2 of 8: different questions.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the sets of word pairs of two texts | 5 pairs and 6 pairs | |
| intersection: pairs in both sets | 5 shared pairs | |
| union: pairs in either set, each counted once | 6 distinct pairs | |
| the number of items in a set | ||
| Jaccard similarity, from 0 (nothing shared) to 1 (identical) | 0.83 |
In words: "count the pairs the two texts share, and divide by the number of distinct pairs they have between them."
With the numbers: J = 5 / 6 = 0.83 for the two password questions and 2 / 8 = 0.25 for password against email. A threshold of 0.7 keeps the first password question, drops its rewording, and keeps the email question.
Level 3: in Python
def pairs(text):
words = text.lower().replace("?", "").replace(",", "").split()
return {(a, b) for a, b in zip(words, words[1:])}
A = pairs("How do I reset my password?")
B = pairs("how do I reset my password, please")
C = pairs("How do I change my email?")
# |A ∩ B| and |A ∪ B|
len(A & B), len(A | B) # → (5, 6)
# J(A, B)
round(len(A & B) / len(A | B), 2) # → 0.83
# J(A, C)
len(A & C) / len(A | C) # → 0.25
Figure 5 · Diagram
flowchart LR
T[Next example] --> CMP{Jaccard with any<br/>kept example ≥ 0.7?}
CMP -->|yes| DROP[Drop it:<br/>a near-copy]
CMP -->|no| KEEP[Keep it]
KEEP --> T
DROP --> T
In code: normalize lowercases and strips punctuation, shingles
collects the word pairs, jaccard scores two texts and deduplicate
returns the indices worth keeping. Comparing every pair is fine for
thousands of examples; at web scale, MinHash estimates the same Jaccard
without comparing every pair.
Why it matters in practice. Duplicates overweight a few examples, so the model parrots them. Worse, a near-copy of an eval question in the training set lets the model recite the answer, and the eval score becomes a memory test. Lee et al. found training sets with thousands of near-duplicates, and removing them made models memorise less.
2c. The held-out set comes first
Everyday picture A good teacher writes the final exam before teaching the course and locks it in a drawer. If the exam were written afterwards, it would drift towards what the class happened to practise.
Tiny worked example Fifty support questions are deduplicated, shuffled with a fixed seed, and 10 (20%) go in the drawer. Only then are the other 40 used, and any of the 40 that nearly copies one of the 10 is removed. Every later choice (prompt wording, learning rate, which checkpoint to ship) is scored on those 10. But how much can 10, or even 100, questions tell you?
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the accuracy measured on the held-out set | 0.8 | |
| the number of held-out examples | 100 | |
| the standard error: how much the measured accuracy would wobble if you drew a different held-out set of the same size | 0.04 | |
| how many standard errors to allow; 1.96 covers 95% of the wobble | 1.96 | |
| margin | the true accuracy is probably within ± this of the measured one | 0.078 |
In words: "the measured accuracy is uncertain by about two standard errors, and the standard error shrinks with the square root of the number of examples."
With the numbers: with 100 examples at 80%, the margin is 1.96 × √(0.8 × 0.2 / 100) = 1.96 × 0.04 = 0.078, so "80%" means "somewhere from about 72% to 88%". A fine-tune that scores 83% has not been shown to beat a prompt that scores 80%. With 400 examples the margin halves to 0.039.
Level 3: in Python
import math
a, n, z = 0.8, 100, 1.96
# the standard error sqrt(a(1 − a)/n)
round(math.sqrt(a * (1 - a) / n), 4) # → 0.04
# the 95% margin
round(z * math.sqrt(a * (1 - a) / n), 4) # → 0.0784
# four times as many examples halves it
round(z * math.sqrt(a * (1 - a) / 400), 4) # → 0.0392
Figure 6 · Diagram
flowchart LR
ALL[All examples,<br/>deduplicated] --> SH[Shuffle with<br/>a fixed seed]
SH --> EV[20% held out<br/>frozen, never trained on]
SH --> TR[80% training]
EV --> CHK{Training example<br/>near-copies one?}
TR --> CHK
CHK -->|yes| X[Remove from training]
CHK -->|no| OK[Keep for training]
Figure 7 · Chart
The margin of error falls from about 16 points at 25 examples to 1.4 points at 3,200; each fourfold increase halves it
In code: split_before_training deduplicates, shuffles with a seed,
freezes the held-out set and removes leaks with remove_leaks;
margin_of_error gives the ± for any accuracy and size.
Why it matters in practice. Every time you look at held-out scores and
change something, the held-out set leaks a little into your decisions. A
held-out set built first and used sparingly is the only honest measure of
whether the fine-tune helped. See primer.ml.regularization for train,
validation and test splits, and primer.agents.evals for building evals.
2d. Label quality
Everyday picture An answer key with typos. A student who gets every question right is marked wrong wherever the key is wrong, and a student who makes the same mistake as the key is marked right.
Tiny worked example A model is truly right 90% of the time, and 10% of the reference labels are wrong. It scores a point when it is right on a right label (0.9 × 0.9 = 0.81), or when it is wrong on a wrong label and the two mistakes cancel (0.1 × 0.1 = 0.01). The eval reports 82%, not 90%.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the model's true accuracy | 0.9 | |
| epsilon: the share of reference labels that are wrong | 0.1 | |
| right answer, right label | 0.81 | |
| wrong answer on a wrong label (yes/no labels only, so the two errors agree) | 0.01 | |
| what the eval reports | 0.82 |
In words: "the eval gives credit for being right on a correct label, and by accident for being wrong on a wrong one."
With the numbers: 0.9 × 0.9 + 0.1 × 0.1 = 0.82. A perfect model () scores only 1 − ε = 0.9: noisy labels put a ceiling on what the eval can show.
Level 3: in Python
a, eps = 0.9, 0.1
# right on a right label, plus wrong on a wrong label
round(a * (1 - eps) + (1 - a) * eps, 2) # → 0.82
# even a perfect model scores only 1 − ε
round(1.0 * (1 - eps), 2) # → 0.9
# two labelers, five items: how often do they agree?
labeler_1 = ["yes", "no", "yes", "yes", "no"]
labeler_2 = ["yes", "no", "no", "yes", "no"]
sum(p == q for p, q in zip(labeler_1, labeler_2)) / len(labeler_1) # → 0.8
Figure 8 · Diagram
flowchart TD
X[One eval item] --> R{Model right?}
R -->|"yes, 0.9"| LR{Label right?}
R -->|"no, 0.1"| LW{Label right?}
LR -->|"yes, 0.9"| P1["scored right: 0.81"]
LR -->|"no, 0.1"| P2["scored wrong: 0.09"]
LW -->|"yes, 0.9"| P3["scored wrong: 0.09"]
LW -->|"no, 0.1"| P4["scored right: 0.01"]
How to check label quality. Have two people label the same sample independently and measure how often they agree. Above, they agree on 4 of 5 items, 80%: one item in five is ambiguous or mislabelled, and your eval can't resolve differences smaller than that noise. Read every disagreement; they are usually unclear instructions, not careless labelers.
In code: measured_accuracy applies the formula and label_agreement
compares two labelers.
Why it matters in practice. Bad training labels teach the model the mistakes (section 4 shows a small model memorising three of them). Bad eval labels hide real improvements. Zhou et al. (LIMA) fine-tuned a large model on just 1,000 carefully chosen examples and got a strong assistant: quality of examples beats quantity.
2e. How many examples?
Everyday picture Teaching a house style is not teaching a language. A new cook learns how you plate a dish from a few dozen good examples; they don't need ten thousand.
Tiny worked example Fine-tuning teaches a behaviour the base model can almost do already, so the useful sizes are small: a few dozen examples to show a format, hundreds for a consistent style or a narrow task, thousands for a harder specialised skill. Section 4 fine-tunes on 16 examples and shows the other side: with so few, the model soon memorises them, noise included.
Figure 9 · Diagram
flowchart LR
S[Start with 50 to 100<br/>high-quality examples] --> T[Fine-tune]
T --> E[Score on the<br/>held-out set]
E --> Q{Gained more than<br/>the margin of error?}
Q -->|yes| D[Double the data] --> T
Q -->|no| STOP[Stop adding data:<br/>fix quality or approach]
Why it matters in practice. Labelled data is the expensive part of fine-tuning. Doubling until the gains flatten spends it where it helps, and the held-out set tells you when to stop.
Chapter 3
Catastrophic forgetting
Everyday picture Someone who learned to drive in Britain, keeping left, moves to the United States and practises keeping right every day for a month. The new habit wins, and on a trip home they drift to the wrong side of the road. Nobody told them to forget the old rule; the new practice simply rewrote the reflex both rules use. Neural networks do this to an extreme: train on a new task alone and an old one can vanish. This is catastrophic forgetting.
The model we'll fine-tune. To watch it happen, we need a model small enough to train instantly. Each example is four numbers, and the answer is yes or no:
| Task | Inputs it uses | Rule | Plays the role of |
|---|---|---|---|
| general | all four, anywhere in [−3, 3] | yes when x₁ + x₃ > 0 | the base model's pretraining |
| A | x₁ in [−3, −1], x₂ in [−2, 2] | yes when x₂ > 0 | keep left in Britain |
| B | x₁ in [1, 3], x₂ in [−2, 2] | yes when x₂ < 0 | keep right in the US |
| C | x₃, x₄ in [−2, 2] | yes when x₃ + x₄ > 0 | an unrelated skill |
A and B read the same two inputs and apply opposite rules in different regions, so one model can learn both, but only by paying attention to the region. C reads inputs that A and B never touch.
Figure 10 · Diagram
flowchart LR X["4 inputs<br/>x1 x2 x3 x4"] --> H["16 hidden units<br/>tanh"] H --> O["1 output<br/>probability of yes"] TH["θ: all 97 weights<br/>in one vector"] -.-> H TH -.-> O
primer.ml.neural_net builds this kind of network from scratch.The base model is this network trained on the general task (99% accuracy on held-out examples). Every fine-tune below starts from it and runs plain gradient descent on 200 examples.
In code: TinyNet holds θ and computes predictions, loss and
TinyNet.gradient; make_task draws examples for each of the TASKS;
base_model pretrains the base and fine_tune trains a copy, leaving the
starting model untouched.
3a. The general skill fades while you teach a new one
Everyday picture A month of drilling one cuisine makes the cook a little rusty at everything else, and a second month of drilling it makes them rustier still, even though they had mastered the cuisine in the first week.
Tiny worked example Fine-tune the base on task A (learning rate 0.5) and check both skills on held-out examples:
| After step | Accuracy on A | General skill | Distance from base |
|---|---|---|---|
| 0 (the base) | 0.53 | 0.99 | 0 |
| 5 | 0.975 | 0.825 | 2.60 |
| 10 | 0.995 | 0.79 | 2.83 |
| 300 | 0.98 | 0.655 | 5.19 |
Task A is learned in 5 steps. The next 295 steps teach nothing new about A but keep eroding the general skill, from 0.825 to 0.655. The last column explains why: the weights keep travelling away from the base.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| theta: every weight of the base model, as one vector | 97 numbers | |
| every weight after fine-tuning steps | ||
| the number of weights | 97 (3 in the hand example) | |
| a counter over the weights | 1 … P | |
| the -th weight after steps | ||
| the length of vector : square each entry, add, take the square root | ||
| how far fine-tuning has moved the model | 5.19 after 300 steps |
In words: "the distance travelled is the length of the change in the weights: square every weight's change, add them up, take the square root."
With the numbers: for a three-weight model moving from (1, 0, 2) to (1.5, −1, 2), the changes are (0.5, −1, 0), and d = √(0.25 + 1 + 0) = √1.25 = 1.118.
Level 3: in Python
import math
theta_base = [1.0, 0.0, 2.0]
theta_t = [1.5, -1.0, 2.0]
# each weight's change
[t - b for t, b in zip(theta_t, theta_base)] # → [0.5, -1.0, 0.0]
# ‖θ_t − θ_base‖
round(math.sqrt(sum((t - b) ** 2 for t, b in zip(theta_t, theta_base))), 3) # → 1.118
Figure 11 · Diagram
flowchart LR DA[Task A examples] -->|gradient| W["Shared weights θ"] W --> SA[Answers on task A] W --> SG[Answers on the<br/>general skill] SG -.->|"no examples,<br/>no gradient"| W
Figure 12 · Chart
Left: accuracy on A jumps to 1 within a few steps while the general skill slides from 0.99 to 0.66 at learning rate 0.5 and only to 0.79 at 0.02. Right: the general skill falls as distance from the base grows
Mitigations that shorten the trip. Three knobs limit the distance, and the toy measures two of them:
- Fewer steps. Stop once the held-out score on the new task stops improving. Here, stopping at step 10 keeps the general skill at 0.79 instead of 0.655, with A at 0.995.
- A lower learning rate. Smaller steps travel less far for the same result: 0.79 instead of 0.655 after 300 steps.
- A smaller update. LoRA (
primer.ml.training_stages, section 4) freezes the base weights and allows only a low-rank change. Biderman et al. measured this on real language models and summed it up in their title: LoRA learns less and forgets less.
In code: general_skill_run fine-tunes on A and records, after every
step, accuracy on A, the general skill and the distance from the base.
Why it matters in practice. A fine-tuned assistant that has become worse at everything outside its narrow task is the most common fine-tuning disappointment. Always score the general skills you care about alongside the new task, and prefer the earliest checkpoint that has learned the task.
3b. A new task that contradicts an old one
Everyday picture Back to the driver. Practising "keep right" does not merely add a skill; it pushes directly against "keep left", because both use the same reflex.
Tiny worked example Take the model fine-tuned on A (98% on A), then fine-tune it on B alone for 300 steps:
| Second fine-tune | A before | A after | New task after |
|---|---|---|---|
| on B (contradicts A) | 0.98 | 0.025 | 1.00 |
| on C (separate inputs) | 0.98 | 0.97 | 0.98 |
After B, the model does not merely forget A; it answers A's questions backwards, because it learned "yes when x₂ < 0" everywhere. After C, A is barely touched: C's inputs never flowed through the weights A relies on most.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| held-out accuracy on the old task before the new fine-tune | 0.98 | |
| the same, after the new fine-tune | 0.025 | |
| forgetting: accuracy lost on the old task | 0.955 |
In words: "forgetting is how much accuracy the old task lost."
With the numbers: F = 0.98 − 0.025 = 0.955 after B, and 0.98 − 0.97 = 0.01 after C.
Level 3: in Python
acc_before, acc_after_B, acc_after_C = 0.98, 0.025, 0.97
# F after the contradicting task B
round(acc_before - acc_after_B, 3) # → 0.955
# F after task C, on separate inputs
round(acc_before - acc_after_C, 3) # → 0.01
Figure 13 · Diagram
flowchart LR BASE[Base] -->|fine-tune on A| MA["Model knows A<br/>A: 0.98"] MA -->|fine-tune on B only| MB["Model knows B<br/>A: 0.025, B: 1.00"] MA -->|fine-tune on C only| MC["Model knows A and C<br/>A: 0.97, C: 0.98"]
Figure 14 · Chart
Fine-tuning on B after A: accuracy on A falls from 0.98 to near 0 within a few steps while B rises to 1; with 10 replayed A examples, A dips and then recovers to 0.945
Does a lower learning rate help here? Only in the sense of slowing the slide. At learning rate 0.02, or stopping after 10 steps, B reaches 0.975 and A still drops to 0.355.
Figure 15 · Chart
Accuracy on A against accuracy on B during the second fine-tune: every run on B alone traces the same curve whatever the learning rate and ends near zero on A, while the replay run climbs the right edge to the top-right corner
In code: sequential_run starts from the A fine-tune, trains on a new
task (optionally with replay), and records both accuracies at every step;
forgetting computes F.
Why it matters in practice. Real fine-tunes contradict the base model more often than you'd think: "always answer in JSON" contradicts "chat naturally"; "be terse" contradicts "explain in detail". Expect the old behaviour to vanish wherever your data overrides it, and test for it.
3c. Replay: keep practising the old skill
Everyday picture A pianist learning a new piece plays one old piece at the start of every practice session. It costs a few minutes and keeps the old repertoire alive.
Tiny worked example Add just 10 of task A's 200 training examples to B's 200: under 5% of the mix. The result: A 0.945, B 0.99 (up from A 0.025 without replay). Why can so few examples do so much? Look at the loss. Suppose that early in training the model already scores B well (loss 0.05 per example) but has started forgetting A (loss 3.0 on each replayed example):
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| examples of the new task | 200 | |
| replayed examples of the old task | 10 | |
| average loss on the new examples | 0.05 | |
| average loss on the replayed examples | 3.0 | |
| the loss training actually minimises: the average over every example in the mix | 0.19 |
In words: "the loss on a mixed dataset is the average over all its examples, so each group counts in proportion to its size times its loss."
With the numbers: (200 × 0.05 + 10 × 3.0) / 210 = (10 + 30) / 210 = 0.19. The 10 replayed examples are under 5% of the data but contribute 0.143 of the 0.19: three quarters of the loss, and so most of the gradient. The old examples shout loudest exactly when they are being forgotten.
Level 3: in Python
n_new, L_new = 200, 0.05
n_old, L_old = 10, 3.0
# the replayed share of the data
round(n_old / (n_new + n_old), 3) # → 0.048
# each group's contribution to the average loss
round(n_new * L_new / 210, 3), round(n_old * L_old / 210, 3) # → (0.048, 0.143)
# L_mix
round((n_new * L_new + n_old * L_old) / (n_new + n_old), 2) # → 0.19
Figure 16 · Diagram
flowchart LR NB["New task B<br/>200 examples"] --> MIX[Shuffle together<br/>210 examples] OA["Old task A<br/>10 kept examples"] --> MIX MIX --> FT[Fine-tune] FT --> BOTH["Knows B: 0.99<br/>and A: 0.945"]
In code: replay_mix appends the old examples, mixed_loss is the
formula, and sequential_run with n_replay=10 runs the experiment.
Why it matters in practice. When fine-tuning a language model, mix some general instruction-following data into your task data, so the model keeps being a good assistant while it learns your task. When you can't replay (the old data is gone or private), methods such as elastic weight consolidation (Kirkpatrick et al.) instead penalise changes to the weights the old task relied on most.
Chapter 4
Overfitting a small dataset
Everyday picture A student with only 16 flashcards, three of which
have the wrong answer on the back. For a while, studying teaches the
pattern. Keep drilling and the student memorises every card word for word,
including the three wrong answers, and gets worse on new questions.
primer.ml.regularization builds this idea from scratch; here it is in a
fine-tune.
Tiny worked example Fine-tune the base on only 16 examples of task A, 3 of them deliberately mislabelled, for 1,500 epochs. (With full-batch training, one step is one pass over the data, one epoch.)
| At the best epoch (71) | At the end (1,500 epochs) | |
|---|---|---|
| training loss | 0.373 | 0.005 |
| validation loss | 0.319 | 1.277 |
At the best epoch, training loss is higher than validation loss: the model is refusing to fit the three wrong labels, which is exactly right. By the end, training loss is nearly zero, so the wrong labels have been memorised, and validation loss has quadrupled.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| the epoch | 71, then 1,500 | |
| average loss on the 16 training examples | 0.373, then 0.005 | |
| average loss on 200 held-out examples | 0.319, then 1.277 | |
| the generalisation gap: how much worse the model does on data it hasn't seen | −0.054, then 1.272 |
In words: "the gap is held-out loss minus training loss; a gap that keeps growing means the model is memorising rather than learning."
With the numbers: 0.319 − 0.373 = −0.054 at epoch 71; 1.277 − 0.005 = 1.272 at the end.
Level 3: in Python
L_train = {"best": 0.373, "end": 0.005}
L_val = {"best": 0.319, "end": 1.277}
# g = L_val − L_train at each point
{t: round(L_val[t] - L_train[t], 3) for t in L_val} # → {'best': -0.054, 'end': 1.272}
Figure 17 · Chart
Training loss falls steadily to near zero while validation loss bottoms out at epoch 71 and then climbs to four times its best
Figure 18 · Diagram
flowchart LR
EP[Train one epoch] --> SV[Save a checkpoint]
SV --> SC[Score it on the<br/>held-out set]
SC --> Q{Best so far?}
Q -->|yes| MARK[Mark it best] --> EP
Q -->|"no, patience used up"| SHIP[Ship the best checkpoint,<br/>not the last]
Q -->|"no, patience left"| EP
In code: overfitting_run fine-tunes on the 16 examples and records
both losses every epoch; primer.ml.regularization.early_stopping replays
the validation curve and returns the best epoch and the stopping epoch.
Why it matters in practice. Fine-tuning datasets are small compared to pretraining, and big models memorise quickly, so fine-tunes typically run for only a few epochs. Save checkpoints, score each on the held-out set, and ship the best one.
Chapter 5
Model merging
5a. Weight averaging and task arithmetic
Everyday picture Two editors each take a copy of the same draft and make tracked changes: one fixes the grammar, the other tightens the argument. You can apply both sets of changes to the original. If instead you "average" the two edited copies, each change is applied at half strength: half the grammar fixed, half the argument tightened.
Tiny worked example A three-weight base (1, 0, 2). One fine-tune moves it to (1.5, 0, 2); another to (1, −1, 2).
| Weights | Change from the base | |
|---|---|---|
| base | (1, 0, 2) | |
| fine-tune on A | (1.5, 0, 2) | τ_A = (0.5, 0, 0) |
| fine-tune on C | (1, −1, 2) | τ_C = (0, −1, 0) |
| base + τ_A + τ_C | (1.5, −1, 2) | both changes in full |
| average of the two fine-tunes | (1.25, −0.5, 2) | both changes at half strength |
The change a fine-tune made, θ_ft − θ_base, is its task vector. Adding task vectors to the base is task arithmetic.
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| which task: a counter over the fine-tunes | A, C | |
| how many fine-tunes are merged | 2 | |
| all the weights of the model fine-tuned on task | (1.5, 0, 2) | |
| the weights they all started from | (1, 0, 2) | |
| tau: task 's task vector, everything its fine-tune changed | (0.5, 0, 0) | |
| add up the task vectors | τ_A + τ_C = (0.5, −1, 0) | |
| lambda: how strongly to apply the combined changes | 1 | |
| the merged model's weights | (1.5, −1, 2) |
In words: "each task vector is what its fine-tune changed; add the changes up, scale them by λ, and apply them to the base."
With the numbers: θ_merged = (1, 0, 2) + 1 × (0.5, −1, 0) = (1.5, −1, 2). With λ = 1/2: (1, 0, 2) + 0.5 × (0.5, −1, 0) = (1.25, −0.5, 2), which is exactly the average of the two fine-tunes. Averaging T fine-tunes is task arithmetic with λ = 1/T.
Level 3: in Python
theta_base = [1.0, 0.0, 2.0]
theta_A = [1.5, 0.0, 2.0]
theta_C = [1.0, -1.0, 2.0]
# τ_t = θ_t − θ_base
tau_A = [a - b for a, b in zip(theta_A, theta_base)]
tau_C = [c - b for c, b in zip(theta_C, theta_base)]
tau_A, tau_C # → ([0.5, 0.0, 0.0], [0.0, -1.0, 0.0])
# θ_merged at λ = 1
[b + 1.0 * (a + c) for b, a, c in zip(theta_base, tau_A, tau_C)] # → [1.5, -1.0, 2.0]
# λ = 1/2 ...
[b + 0.5 * (a + c) for b, a, c in zip(theta_base, tau_A, tau_C)] # → [1.25, -0.5, 2.0]
# ... is the plain average of the two fine-tunes
[(a + c) / 2 for a, c in zip(theta_A, theta_C)] # → [1.25, -0.5, 2.0]
Figure 19 · Diagram
flowchart LR B[Base θ] -->|fine-tune on A| FA[θ_A] B -->|fine-tune on C| FC[θ_C] FA --> TA["τ_A = θ_A − θ_base"] FC --> TC["τ_C = θ_C − θ_base"] TA --> SUM["λ × (τ_A + τ_C)"] TC --> SUM B --> ADD((+)) SUM --> ADD ADD --> M[Merged model:<br/>no extra training]
Figure 20 · Chart
Merging the fine-tunes on A and C: at lambda 1 the merged model scores 0.97 on both, while plain averaging (lambda one half) scores only 0.635 on A
In code: task_vector subtracts the base, merge adds scaled task
vectors back, and merge_run merges two fine-tunes at several λ and scores
the result.
Why it matters in practice. Merging combines skills trained separately, by different teams or on data that can't be pooled, without any further training and at no extra inference cost. Averaging several fine-tunes of the same task ("model soups", Wortsman et al.) often beats the best single one. Task vectors can also be subtracted: Ilharco et al. negated a task vector learned from toxic text to make a model less toxic.
5b. Interference: when task vectors collide
Everyday picture Two editors rewrote the same sentence in opposite directions, one making it warmer and one making it colder. Applying both sets of tracked changes gives a sentence neither intended.
Tiny worked example Our three-weight changes again, plus a new one: τ_A = (0.5, 0, 0), τ_C = (0, −1, 0) and τ_B = (−0.4, 0, 0.3). τ_A and τ_C touch different weights: no conflict. τ_B pulls the first weight the other way from τ_A. The cosine measures how aligned two changes are:
Level 3: the formula and its symbols
Symbols
| Symbol | Meaning here | In the example |
|---|---|---|
| dot product: multiply matching entries, then add | 0.5 × (−0.4) = −0.2 | |
| a vector's length | 0.5 and 0.5 | |
| the cosine of the angle between the two changes: 1 same direction, 0 unrelated, −1 opposite | −0.8 |
In words: "multiply the changes weight by weight and add, then divide by both lengths, so only the direction counts."
With the numbers: τ_A · τ_B = 0.5 × (−0.4) + 0 + 0 = −0.2; ‖τ_A‖ = 0.5,
‖τ_B‖ = √(0.16 + 0.09) = 0.5; cos = −0.2 / 0.25 = −0.8: strongly
opposed. cos(τ_A, τ_C) = 0: independent. See primer.ml.embeddings.similarity
for the cosine from scratch.
Level 3: in Python
import math
tau_A = [0.5, 0.0, 0.0]
tau_B = [-0.4, 0.0, 0.3]
tau_C = [0.0, -1.0, 0.0]
def cos(u, v):
dot = sum(a * b for a, b in zip(u, v))
return dot / (math.sqrt(sum(a * a for a in u)) * math.sqrt(sum(b * b for b in v)))
# opposed changes
round(cos(tau_A, tau_B), 2) # → -0.8
# changes to different weights
cos(tau_A, tau_C) # → 0.0
In the toy, the fine-tunes on A and on B (which reverses A's rule) have task vectors with cosine −0.25, against −0.04 for A and C. Each fine-tune learned its rule everywhere, not just in its own region, so the two vectors rewrite the same weights in opposite directions.
Figure 21 · Chart
Merging the fine-tunes on A and B: at every lambda at least one task stays at or below a coin flip, and the best the merge manages on both at once is 0.525
Figure 22 · Diagram
flowchart TD
TV[Task vectors from<br/>the same base] --> COS{Cosine between them}
COS -->|"near 0: separate weights"| ADD[Add them:<br/>task arithmetic]
COS -->|"clearly negative: conflict"| FIX{Can you retrain?}
FIX -->|yes| JOINT[Train one model on<br/>both datasets, or replay]
FIX -->|no| TIES["Resolve conflicts:<br/>trim small changes,<br/>agree on a sign per weight"]
ADD --> EV[Score the merge on<br/>every task's held-out set]
TIES --> EV
JOINT --> EV
In code: cosine measures the angle between two task vectors, and
merge_run reports it alongside the merged accuracies.
Why it matters in practice. Merges are free to try, which makes them tempting to trust. A merged model can quietly lose a skill both parents had, so it is evaluated like any new model. The cosine tells you in advance which merges to be nervous about.
Test yourself
9 questions
Answer each one out loud or on paper before you open it. If you can explain it, you know it.
Question 1When is fine-tuning the wrong tool, and what should you try first?Think it through, then reveal
When the model lacks facts, or the facts change: retrieval supplies them and is easy to update. When a clearer prompt with a few examples fixes the behaviour: that is cheaper and survives a base-model upgrade. Fine-tuning earns its cost when behaviour stays inconsistent under the best prompt, or when a long prompt sent millions of times costs more than the fine-tune.
Question 2Why build the held-out set before training, and why check it against the training set?Think it through, then reveal
So that no choice (prompt, learning rate, checkpoint) is made by looking at it, and it stays an honest measure. A training example that nearly copies a held-out one lets the model recite the answer, turning the eval into a memory test; deduplicating across the split prevents it.
Question 3A held-out set has 100 examples and the fine-tune scores 83% against the prompt's 80%. Has it won?Think it through, then reveal
Not yet. At 80% on 100 examples the 95% margin is about ±7.8 points, so a 3-point difference is well inside the noise. You need a larger held-out set (400 examples halve the margin) or a bigger difference.
Question 4If 10% of the eval's labels are wrong, what is the best score a perfect model can get?Think it through, then reveal
90%, because it is marked wrong on every mislabelled item. A 90%-accurate model would score 0.9 × 0.9 + 0.1 × 0.1 = 82%. Noisy labels shrink and blur the differences you are trying to measure.
Question 5What is catastrophic forgetting, and why does it happen?Think it through, then reveal
Training on a new task alone erodes, or wipes out, skills the model had. Every weight is shared between tasks, and only the new task's examples produce gradients, so nothing pushes back when a change that helps the new task hurts an old one. It grows with how far the weights move and with how much the new task conflicts with the old.
Question 6Lowering the learning rate did not stop task A being forgotten. Why not, and what works?Think it through, then reveal
Task B contradicts A on the same inputs, so any progress on B costs A; a lower rate only walks the same trade-off more slowly. Replay works: mixing even 5% of A's examples into B's data gives the model a reason to keep A, and those few examples carry most of the loss exactly when A is slipping.
Question 7Training loss keeps falling, but validation loss has risen since epoch 71. What is happening, and which checkpoint do you ship?Think it through, then reveal
The model has stopped learning the general rule and is memorising the training set, including its mislabelled examples. Ship the checkpoint from epoch 71, the best on the held-out set; early stopping automates exactly this.
Question 8What is a task vector, and why is averaging two fine-tunes the same as task arithmetic with λ = 1/2?Think it through, then reveal
A task vector is everything a fine-tune changed: θ_ft − θ_base. The average of two fine-tunes is (θ_base + τ_1 + θ_base + τ_2) / 2 = θ_base + ½(τ_1 + τ_2), which is task arithmetic with λ = 1/2, so each skill arrives at half strength.
Question 9When does merging fail, and how can you see it coming?Think it through, then reveal
When the task vectors change the same weights in opposite directions, so adding them cancels both skills. A clearly negative cosine between task vectors is the warning; the remedy is joint training or replay, or a conflict-resolving merge such as TIES, and a held-out check on every task either way.
Primary sources
The papers behind this lesson
Defined task vectors as fine-tuned minus pretrained weights and showed that adding them combines skills, and negating them removes a behaviour.
Read the annotated companion →The paper ↗Showed that averaging the weights of several fine-tunes of one base often beats the best single fine-tune.
The paper ↗Traced failed merges to small redundant changes and sign conflicts, and fixed both by trimming and electing a sign per weight.
The paper ↗Introduced elastic weight consolidation, which slows learning on the weights most important to earlier tasks.
The paper ↗Measured on real language models that LoRA keeps more of the base model's abilities than full fine-tuning, at the price of learning the new task less completely.
The paper ↗Fine-tuned a large base model on 1,000 carefully curated examples and got a strong assistant, evidence that example quality matters more than quantity.
Read the annotated companion →The paper ↗Found widespread near-duplicates in standard datasets, including between training and test sets, and showed that removing them reduces memorisation.
The paper ↗Researcher's shelf
Further reading
- Goodfellow et al., An Empirical Investigation of Catastrophic Forgetting in Gradient-Based Neural Networks (2013): https://arxiv.org/abs/1312.6211
- Hu et al., LoRA: Low-Rank Adaptation of Large Language Models (2021): https://arxiv.org/abs/2106.09685
- Ilharco et al., Editing Models with Task Arithmetic (2022): https://arxiv.org/abs/2212.04089
- Yadav et al., TIES-Merging (2023): https://arxiv.org/abs/2306.01708
- Hugging Face TRL, supervised fine-tuning trainer: https://huggingface.co/docs/trl/sft_trainer
- Hugging Face PEFT, parameter-efficient fine-tuning (LoRA and friends): https://huggingface.co/docs/peft/index
- mergekit, an open-source toolkit for merging models: https://github.com/arcee-ai/mergekit
About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit c8d5c21, so the two always agree: the explanation, the code that builds it and the tests that prove it.