Index Ai Academy
Phase 6 · Modern LLM engineering · Post-training

Supervised fine-tuning

Teach behavior from example conversations.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Learning from examples
  2. Only the reply counts
  3. Worked example: averaging only the reply
  4. Fine-tuning the base model
  5. Getting SFT right
  6. 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")
It runs right here in your browser, with nothing to install.

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".

Quick check
Why are the prompt tokens given the label -100 during SFT?
  1. Because user messages are never part of the input
  2. Because -100 makes the model ignore the prompt when answering
  3. To make the prompt shorter
  4. So the loss only teaches the model what to say in the reply, not how to write user messages
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
L=1∣R∣∑t∈R−log⁡p(yt)L = \frac{1}{|R|} \sum_{t \in R} -\log p(y_t)

Here is each piece, one at a time.

  • LL is the loss for one example.
  • tt is one position in the example: the first token, the second, and so on.
  • RR is the set of reply positions, the ones whose label isn't -100.
  • t∈Rt \in R reads "tt is in RR". It means tt is one of the reply positions.
  • ∣R∣|R| is how many reply positions there are.
  • ∑t∈R\sum_{t \in R} means "add up over every reply position". In code it's a for loop.
  • yty_t is the correct token at position tt.
  • p(yt)p(y_t) is the probability the model gave that correct token.

The term −log⁡p(yt)-\log p(y_t) 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.

Try it now

The only new idea is the mask: don't train it to write the question.

Worked example

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
In the lesson you can change this and run it.

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.

Quick check
Predict the output. The first two positions are prompt, the last two are reply.
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))
  1. 2.0
  2. 4.0
  3. 3.0
  4. 1.0
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.

  1. 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.
  2. The test. Pick the unseen products and write a function that scores the model's answers.
  3. 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.)

Quick check
After SFT, the model was asked about the Aspen fleece and described an "Aspen headlamp, made from a light fleece". What does that tell you?
  1. The reply tokens were labeled -100 by mistake
  2. Fine-tuning broke the model, so the whole run is useless
  3. The model needs a longer system prompt
  4. SFT taught the answer format, but the model's product knowledge, and its gaps, come from pretraining
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 10−510^{-5}. 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: 10−510^{-5} 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.

Quick check
You have 1,000 demonstration chats for a support assistant. About 100 of them answer in a careless, sloppy way. What should you expect after SFT?
  1. The model will refuse to answer support questions
  2. Nothing: the 900 good chats cancel out the bad ones
  3. The bad chats only change the training loss, not the replies
  4. The model will sometimes answer carelessly too, so fix or remove those chats before training
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

Create a free account

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?

  1. Stop around where validation loss was lowest; the model is starting to memorize the training chats
  2. Train for more passes until training loss reaches zero, since held-out loss will come back down once it does
  3. Raise the learning rate, so the model gets past the point where held-out loss starts to rise more quickly
  4. Remove the held-out chats from your checks, since a rising score there is only noise from such a small set

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson