Supervised fine-tuning is the workhorse of applied LLM adaptation. You show the model many (prompt, ideal response) pairs and nudge next-token probabilities toward those responses. No reward model yet—just imitation of good demonstrations.
Pretraining taught general language. SFT says: "In our product, answers look like this." It is apprenticeship by example.
If your gold demos are excellent and cover the task, SFT alone often ships. If demos are "okay" but humans can rank better vs worse, preference methods refine further.
For a tokenized sequence x_1..x_T built from the chat template, causal SFT minimizes:
L_SFT = - sum_{t in assistant_tokens} log p_theta(x_t | x_<t)
User/system tokens are usually present as context but excluded from the sum (masked).
Conceptually both are "SFT"; they differ in which parameters receive gradients.
A minimal loop that shows the idea: maximize log-prob of target tokens (toy bigram model).
import math
from collections import defaultdict
def normalize(logits: dict[str, float]) -> dict[str, float]:
m = max(logits.values())
exps = {k: math.exp(v - m) for k, v in logits.items()}
z = sum(exps.values())
return {k: v / z for k, v in exps.items()}
# Toy "model": logits for next token given previous
logits = defaultdict(lambda: defaultdict(float))
vocab_next = {
"reset": ["password", "mfa", "please"],
"password": ["link", "now", "}"],
}
def ensure_keys(prev: str):
for w in vocab_next[prev]:
logits[prev].setdefault(w, 0.0)
# One SFT example: assistant should say "password" then "link"
pairs = [("reset", "password"), ("password", "link")]
lr = 1.0 # exaggerated for demo
for prev, target in pairs:
ensure_keys(prev)
probs = normalize(logits[prev])
# Gradient of -log p(target): increase target logit, decrease others
for w in logits[prev]:
grad = (1.0 if w == target else 0.0) - probs[w]
logits[prev][w] += lr * grad
for prev, target in pairs:
p = normalize(logits[prev])[target]
print(f"p({target}|{prev}) = {p:.3f}")
HuggingFace-style sketch (illustrative):
# model, tokenizer = load_checkpoint("base-instruct")
# for batch in dataloader:
# tokens = tokenizer.apply_chat_template(batch["messages"], return_tensors="pt")
# labels = tokens.clone()
# labels[batch["prompt_mask"]] = -100 # ignore user/system in loss
# loss = model(input_ids=tokens, labels=labels).loss
# loss.backward()
# optimizer.step(); optimizer.zero_grad()
No GPU required to understand the loop: forward → loss on assistant tokens → backward → step.
Suppose baseline intent accuracy is 55% with frequent markdown wrappers. You collect 150 gold chat rows where every assistant message is bare JSON. After one epoch of LoRA SFT, schema validity jumps and intent accuracy follows—if the labels are consistent. If ten rows still wrap JSON in prose, those ten rows fight the other 140 in gradient space. Clean the minority pattern before you add epochs.
When the task is narrow, mix 10–30% general instruction rows (public or internal "replay") so the model remembers how to follow ordinary requests. This is a cheap hedge against forgetting without jumping to full RLHF.
SFT adapts a pretrained LM by minimizing next-token loss on curated assistant responses so the model imitates your task format and style.