Supervised fine-tuning
Teach behavior from example conversations.
Create a free account Try part of it here
Why it matters
Supervised fine-tuning, or SFT, is the first step of post-training. It's also the most common way companies customize a model. You show the model examples of the conversations you want, and it learns to produce them. That's how a base model becomes an assistant. It's also how an assistant learns your output format, your brand voice or your support workflow. And it's the easiest stage to get subtly wrong.
What you'll be able to do
- Build SFT training examples from chats, with the loss masked to the assistant's reply
- Fine-tune a base model on demonstrations and measure the change on unseen prompts
- Choose sensible data, learning rate and number of steps, and spot overfitting
- Recognize what SFT looks like with real tools (Hugging Face TRL)
What it covers
- Learning from examples
- Only the reply counts
- Worked example: averaging only the reply
- Fine-tuning the base model
- Getting SFT right
- With real tools
Learning from examples
Think about how a new café worker learns to take orders. Nobody hands them a theory of service. They watch real orders and copy what the best staff say. After a while they answer the way the café wants, in the café's own words.
Supervised fine-tuning (SFT) teaches a model the same way. You collect demonstrations: example chats that end in the reply you want. The chat template from the last lesson turns each chat into one string of tokens. Then you train the model on those strings.
The training goal doesn't change. It's the one from pretraining: guess the next token, scored with cross-entropy. (Cross-entropy is the loss from Phase 2. It's small when the model gives the right next token a high probability.) What changes is the text. Pretraining used a huge pile of raw text. SFT uses a small set of carefully chosen chats.
Learn the reply, not the question
One change matters. The loss is counted only on the assistant's tokens. Picture a student studying old exam papers with worked answers. They read every question, but they practice writing only the answers. In the same way, the model should learn what to say after a user's message. It shouldn't learn to write the user's message.
Each position in a training example has a label: the token the model should produce there. Positions outside the reply get the label -100 instead. PyTorch's cross-entropy skips any position labeled -100. The number itself means nothing. It's an agreed signal for "don't learn this position".
The cell builds one training example and shows which tokens the model learns from:
from tinygpt import load_pretrained _, tok = load_pretrained("tiny-gpt") prompt = tok.encode("<user> Describe the Kestrel tent . <assistant>") answer = tok.encode("Our Kestrel tent is made from silnylon .") + [tok.token_id("<end>")] ids = prompt + answer labels = [-100] * len(prompt) + answer # -100 = "don't learn this position" for token_id, label in zip(ids, labels): print(f"{tok.vocab[token_id]:<12} {'learn' if label != -100 else '-'}") print(f"\n{len(ids)} tokens, {sum(l != -100 for l in labels)} with a training signal")
Prompt tokens print a dash, and answer tokens print "learn". The last line counts the positions that give the model something to learn.
The final <end> token is part of the reply on purpose. Learning to produce it is how the model learns to stop. One more detail: during training, the labels also move along by one place, so each position predicts the next token. That's the same shift you used in Phase 5.
In plain words: an SFT example feeds the whole chat in as input. The labels mark only the reply as "learn this". Every other position gets -100, which means "skip".
Show the answer
So the loss only teaches the model what to say in the reply, not how to write user messages
The model still reads the prompt as its input. The -100 label only takes those positions out of the loss.
Only the reply counts
Training works out a loss at every position. It's a number that's big when the model guessed badly and small when it guessed well. The loss for the whole example is the average of those numbers.
With SFT, that average covers the reply positions only. Positions labeled -100 drop out before you average. So the prompt can't pull the score up or down, and no training effort goes into imitating users.
Go deeper The masked loss as a formula
Here is each piece, one at a time.
- is the loss for one example.
- is one position in the example: the first token, the second, and so on.
- is the set of reply positions, the ones whose label isn't -100.
- reads " is in ". It means is one of the reply positions.
- is how many reply positions there are.
- means "add up over every reply position". In code it's a
forloop. - is the correct token at position .
- is the probability the model gave that correct token.
The term is the cross-entropy at one position, as in Phase 2.
In plain words: for each reply position, take minus the log of the probability the model gave the right token. Add those up and divide by the number of reply positions. Prompt positions never enter the sum.
This is a slight simplification. The training cell below works on 16 chats at a time, so its average runs over all the reply positions in those 16 chats together.
The worked example below does this with six made-up numbers.
The only new idea is the mask: don't train it to write the question.
Worked example: averaging only the reply
A made-up chat has six positions, each with its own loss. The first three belong to the prompt, so their labels are -100. The last three belong to the reply.
Average all six and you get (3.2 + 2.9 + 3.5 + 1.2 + 0.8 + 0.5) ÷ 6 = 12.1 ÷ 6 ≈ 2.017. Average only the reply and you get (1.2 + 0.8 + 0.5) ÷ 3 = 2.5 ÷ 3 ≈ 0.833. SFT uses the second number.
losses = [3.2, 2.9, 3.5, 1.2, 0.8, 0.5] # one loss per position (made-up numbers) labels = [-100, -100, -100, 41, 17, 2] # -100 = prompt position, skip it kept = [loss for loss, label in zip(losses, labels) if label != -100] print(kept) print(round(sum(losses) / len(losses), 3)) # average of everything print(round(sum(kept) / len(kept), 3)) # average of the reply only
The list keeps only the three reply losses, [1.2, 0.8, 0.5]. Their average is 0.833, while the average of all six is 2.017. In this example the prompt losses are high, and without the mask they would dominate the score. Writing ignore_index=-100 in PyTorch's cross-entropy does this same filtering for you. It's the default, so the cells in this lesson don't even need to write it.
losses = [4.0, 4.0, 1.0, 3.0] labels = [-100, -100, 7, 12] kept = [x for x, lab in zip(losses, labels) if lab != -100] print(sum(kept) / len(kept))
Show the answer
2.0
Only 1.0 and 3.0 count, and (1.0 + 3.0) ÷ 2 = 2.0. Averaging all four would give 3.0.
Fine-tuning the base model
Now the real thing. The cell below downloads tiny-gpt, the base model from the last lesson. It fine-tunes it on 300 chats, one per product in the catalog. Each chat asks the model to describe a product. The answer is the first sentence of that product's description.
Then it tests the model on 20 products that aren't in the catalog. Each one pairs a name the model knows with a kind of product it never saw under that name. An answer passes if it's one sentence, names the right product, and ends with <end>. The cell scores the model before and after training.
The comments split the cell into three parts.
- The data. Build one chat per product, with the prompt labeled -100. Chats have different lengths, so shorter ones get extra
<end>tokens to bring them all to one length. That padding is labeled -100 too, so it doesn't count. - The test. Pick the unseen products and write a function that scores the model's answers.
- Fine-tune. This is the training loop you know. Pick 16 chats at random, work out the masked loss, and nudge the weights. Repeat for about 300 steps, printing the loss every 100.
It takes a while to run. The Run button counts the seconds.
import re import requests import torch import torch.nn as nn from tinygpt import load_pretrained model, tok = load_pretrained("tiny-gpt") # the base model end = tok.token_id("<end>") docs = requests.get("https://api.gradient.test/v1/corpus").json()["text"].strip().split("\n") product = re.compile(r"^(?:Meet the|Our|The) ([A-Z][a-z]+) (.+?)(?:,| is| are) made") # 1. the data: one chat per catalog product, answered with the description's first sentence rows = [] for d in docs: name, kind = product.match(d).groups() prompt = tok.encode(f"<user> Describe the {name} {kind} . <assistant>") answer = tok.encode(d.split(". ")[0] + ".") + [end] rows.append((prompt + answer, [-100] * len(prompt) + answer)) # learn the answer only T = max(len(ids) for ids, _ in rows) X = torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in rows]) # pad to one length Y = torch.tensor([lab + [-100] * (T - len(lab)) for _, lab in rows]) print(f"{len(rows)} training chats, up to {T} tokens each") # 2. the test: products that aren't in the catalog seen = {product.match(d).groups() for d in docs} names, kinds = sorted({n for n, _ in seen}), sorted({k for _, k in seen}) unseen = [(n, k) for n in names for k in kinds if (n, k) not in seen][::8] @torch.no_grad() def evaluate(model): good, replies = 0, [] for n, k in unseen: ids = torch.tensor([tok.encode(f"<user> Describe the {n} {k} . <assistant>")]) reply = tok.decode(model.generate(ids, 30, temperature=0.7, stop_id=end)[0, ids.shape[1]:].tolist()) good += f"{n} {k}" in reply and reply.endswith("<end>") and reply.count(".") == 1 replies.append(f"{n} {k}: {reply}") return good / len(unseen), replies torch.manual_seed(0) score, replies = evaluate(model) print(f"\nbefore: {score:.0%} of {len(unseen)} new products get a one-sentence answer that names them") print(" ", replies[0]) # 3. fine-tune: ordinary next-token training, with the prompt positions ignored model.train() opt = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.01) torch.manual_seed(1) for step in range(301): b = torch.randint(0, len(X), (16,)) logits = model(X[b][:, :-1]) loss = nn.functional.cross_entropy(logits.reshape(-1, logits.shape[-1]), Y[b][:, 1:].reshape(-1)) opt.zero_grad() loss.backward() opt.step() if step % 100 == 0: print(f"step {step:>3} loss {loss.item():.3f}") model.eval() torch.manual_seed(0) score, replies = evaluate(model) print(f"\nafter: {score:.0%}") for r in replies[:4]: print(" ", r)
Before fine-tuning, the base model treats the chat as more catalog text and rambles on. After about 300 steps of 16 chats, it has seen 4,800 chats. That's about 16 passes over the 300 chats. A tiny model needs many more passes than a large one.
Now it answers in the new format for most new products: one sentence that stops. That includes products it never saw described.
The results also show the limits of SFT. Asked about the Aspen fleece, the model describes an "Aspen headlamp, made from a light fleece". It mixed up the product it was asked about with a familiar one, and it invented a material. SFT taught the format. The knowledge, and its gaps, came from pretraining. (torch here is the course's in-browser PyTorch subset.)
Show the answer
SFT taught the answer format, but the model's product knowledge, and its gaps, come from pretraining
The reply has the right shape: one sentence that stops. What's wrong is the content. A few hundred short chats teach format much more reliably than facts.
Getting SFT right
Data matters most. A few hundred to a few thousand good, consistent demonstrations often beat tens of thousands of mediocre ones. Every example teaches something. If a tenth of your examples are sloppy, the model learns to be sloppy a tenth of the time. Cover the range of requests you expect. Include ones the model should decline or answer briefly.
Watch for overfitting. Overfitting means memorizing the training examples instead of learning the general pattern. Fine-tuning datasets are small, so models memorize them fast. Hold out some examples that you don't train on, and track the loss on them. This is the validation loss. Stop when it starts to rise, which is typically after one to three passes over the data. An overfit model repeats training answers word for word and gets worse on anything new.
Use a small learning rate for big models. The learning rate sets how big each training nudge is. Full fine-tuning of a large model typically uses a learning rate around 0.00001, written . That's big enough to change behavior, but small enough not to wreck what pretraining learned. Our tiny model needs a much larger one to change in a few hundred steps. The cell used lr=3e-3, which is Python's way of writing 0.003.
In plain words: means 1 divided by 10 five times, which is 0.00001. Python writes it as
1e-05. The tiny model's rate, 0.003, is 0.003 ÷ 0.00001 = 300 times bigger.
print(10 ** -5) # Python's short way of writing 0.00001 print(round(3e-3 / 1e-5)) # how many times bigger the tiny model's rate is
The first line prints 1e-05, and the second prints 300.
Mind what you might break. Training only on your narrow task can wear away other skills. This is called catastrophic forgetting. Two things limit the damage. You can mix some general chat data into the fine-tuning set. Or you can use parameter-efficient methods like LoRA, which come later in this phase.
Test on held-out prompts that look like real use, as the cell did with products outside the catalog. Compare the result with the base model. Also compare it with plain prompting of a stronger model, which is often good enough.
Show the answer
The model will sometimes answer carelessly too, so fix or remove those chats before training
Every example teaches, including the bad ones. If a tenth of the data is sloppy, expect sloppy replies some of the time. Cleaning the data is the fix.
With real tools
On your own computer or a rented GPU, the usual tool is Hugging Face's TRL library. Its SFTTrainer takes a dataset of chats in the standard messages format. It applies the model's chat template for you. Then it runs the same loop you just ran:
from datasets import load_dataset from trl import SFTConfig, SFTTrainer dataset = load_dataset("json", data_files="brand_chats.jsonl", split="train") # {"messages": [...]} per line trainer = SFTTrainer( model="Qwen/Qwen2.5-0.5B-Instruct", # any causal LM on the Hugging Face Hub train_dataset=dataset, args=SFTConfig(output_dir="brand-model", num_train_epochs=2, learning_rate=2e-5), ) trainer.train()
A few settings are worth reading. The file brand_chats.jsonl is a JSONL file, which means one JSON record per line, here one chat per line. num_train_epochs=2 asks for two passes over the data. (One full pass is called an epoch.) learning_rate=2e-5 is 0.00002, in the small range big models need.
Hosted APIs offer the same thing without managing hardware. Some model providers let you upload a JSONL file of chats. You get back a fine-tuned version of one of their models.
For marketers: before fine-tuning for brand voice, try a strong model first. Give it a style guide and five good examples in the prompt. Fine-tune when that isn't consistent enough at your volume, or when a small fine-tuned model would cut costs. Then start with a few hundred excellent examples.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Your SFT run's training loss keeps falling, but loss on held-out chats started rising after the second pass over the data. What should you do?