Supervised fine-tuning
Supervised Fine-Tuning (SFT), the first and most universal post-training method. Take a pre-trained base model, fine-tune on instruction-response pairs with response-only loss masking, and you have a usable chatbot. This chapter covers the SFT recipe, chat templates (ChatML, Llama-3, Mistral, Gemma), multi-turn conversation formatting, system prompts, the LIMA insight that 1000 high-quality examples can suffice, synthetic data generation (Alpaca, Vicuna, UltraChat), and the capability tax, how SFT can degrade pre-training capabilities. The first chapter of Part V (post-training); the foundation of every modern chatbot.
Chapters 1–10 taught how to train a pre-trained language model. Chapters 11 and 12 covered architectural alternatives, Mixture of Experts and state-space models, for when the standard dense transformer does not fit the constraints of the deployment. What you have at the end of either path is the same kind of object: a model that completes text. Type “The capital of France is” and it produces “Paris.” Type a question and it may or may not answer.
That object is not a chatbot. It does not know to respond when asked. It does not follow instructions, refuse harmful requests, or adopt a persona. Part V (Post-training) covers how to turn raw next-token prediction into something useful: SFT (this chapter), preference optimization (Chapter 14), parameter-efficient methods (Chapter 15), and distillation (Chapter 16). Every modern chat model, GPT-4, Claude, Llama-3-Instruct, Mistral-Instruct, Gemini, went through SFT at the start of that pipeline.
Supervised Fine-Tuning is the foundation. The recipe is mechanically simple: take a base model, fine-tune on a dataset of (instruction, response) pairs with the loss computed only on response tokens. That is it. The math is unchanged from Chapter 8’s training loop; what differs is the data and a mask. SFT is universal because it works and is cheap, not because it is clever. The interesting questions are operational: what chat template, what data, how much, what hyperparameters. This chapter covers those.
The setup: pre-trained ≠ useful
A base language model is a polymath who has read most of the internet and can produce text on any topic, but has never been to school for chatbots. Give it the prefix “Q: What is the capital of France? A:” and it answers correctly: pre-training taught it the Q/A pattern from web text. Give it just “What is the capital of France?” and the next-token completion is probably another question, because what follows a question in unfiltered web text is often another question, not the answer. Base models can do many tasks if you prompt them carefully with in-context examples, but they have no built-in conception that they are the helpful side of a conversation.
What is missing from the base model is short. It does not know it should respond when asked a question. It does not know to follow instructions without few-shot demonstrations. It has no consistent persona. It does not refuse anything. It does not know which special tokens mark the boundary between user turn and assistant turn. None of these are deep capability gaps; they are format gaps. The model knows the content; it does not know the shape.
SFT fills the format gap. The post-training pipeline assembles in a fixed order: pre-trained model first, SFT second, preference optimization third (Chapter 14), parameter-efficient methods (Chapter 15) and distillation (Chapter 16) optional. SFT is the universal step. Some labs skip preference optimization for small models or specific use cases; none skip SFT, because the alternatives are unstable. Going straight from pre-training to RLHF without an SFT prior produces a reward model that has no idea what “good chatbot behavior” looks like. SFT establishes the baseline that everything else refines.
This chapter restricts to pure full-parameter SFT for pedagogical clarity. LoRA-based SFT is more common in practice and is the subject of Chapter 15; preference optimization is Chapter 14; distillation as a standalone technique is Chapter 16. Pure SFT first; refinements after.
The SFT recipe: instructions in, responses out
The recipe in one paragraph
Take a pre-trained base model. Construct a dataset of (instruction, response) pairs, somewhere between and a million depending on budget. Format each pair according to a chat template (the next section’s topic). Fine-tune with standard next-token-prediction loss, but mask the loss on the instruction tokens so only the response contributes to the gradient. Train for to epochs at a learning rate roughly smaller than pre-training, typically to . The resulting model knows it is a chatbot. That is it. No new architecture, no new loss function (the cross-entropy is the same; only the reduction is masked), no new optimizer. SFT really is fine tuning, the same machinery from Chapter 8, run gently.
What changes from pre-training
Five things change, all operational.
Data changes shape. Pre-training data is raw web text, billions of unstructured tokens. SFT data is a curated set of instruction-response pairs, formatted as conversations. The volume drops by orders of magnitude (millions of tokens, not trillions) but the per-example quality is dramatically higher.
The loss is masked. Cross-entropy is computed at every position in pre-training; in SFT, only response tokens contribute. This is the chapter’s central technical detail.
The learning rate is roughly smaller. Pre-training peaks around for modern transformers; SFT runs at to . Too high and the model forgets its pre-training; too low and the SFT signal does not transfer.
Epochs drop to –. Pre-training trains for “as many tokens as Chinchilla says,” which can be trillions of tokens. SFT covers its small dataset a handful of times. More epochs typically hurt: the model overfits to the format and loses generalization.
Sequence length and batch size shrink. Conversations are short relative to web documents. SFT typically runs at K to K context with batches of K to M tokens, against pre-training’s many millions of tokens per batch.
What stays the same
Almost everything else. The transformer architecture is byte-identical, same attention, same FFN, same embedding, same layer norms, same residuals. AdamW is the optimizer with similar , , and weight decay. Mixed precision (BF16 weights, FP32 master weights) is the standard. The distributed-training stack from Chapter 10, FSDP, possibly with tensor parallelism, runs SFT exactly the same way it runs pre-training, just at smaller scale. And the math itself is identical: cross-entropy loss, gradient descent, backprop. The unmasked computation at every position is the same computation; the mask only selects which positions matter.
The loss mask is the one new piece, and it is worth walking through in detail.
Response-only loss masking
Why mask the loss
Pre-training computes the loss at every token: each token in the sequence is a target, and the model is trained to predict each one from its prefix. That made sense when the data was raw web text, every token was something the model would eventually need to be able to generate.
In SFT, the data has a structure pre-training did not have. Half of every example is the user’s input, an instruction we show the model so it can produce a response. We do not want the model to learn to generate that input. We want the model to learn to generate the response, conditioned on having seen the input. If we leave the loss unmasked, three bad things happen. The model memorizes specific user prompts, wasting capacity that should be spent on response quality. It learns weird mid-conversation completions, because user-side tokens followed by other user-side tokens is not a generation pattern that generalizes. And response quality slightly degrades, because gradient updates are diluted by signal that does not point in a useful direction.
The fix is one line of code. Compute the cross-entropy at every position as before, then multiply by a binary mask that is on assistant tokens and everywhere else, and average over the masked positions only. User-prompt and system-prompt tokens are still in the context, the model conditions on them at every assistant position, but they do not contribute to the loss. Only the response tokens are training targets.
How the mask works
The mask is a tensor the same shape as the input sequence. Constructing it is a tokenization-time job: tokenize the full conversation, walk through and identify which token positions correspond to assistant turns, set those positions to and the rest to . HuggingFace’s DataCollatorForCompletionOnlyLM does this automatically using the chat template’s role markers, most teams use it rather than rolling their own. The mathematics is:
where is the response mask, is the standard per-position cross-entropy from Chapter 8’s training loss, and the sum runs over all positions in the sequence. Equation (13.sft) is the entire SFT loss. The only difference from Chapter 8’s training loss is the factor and the matching denominator.
The runnable cell below builds the mask by hand on an eight-token toy sequence, four tokens of user prompt, four tokens of assistant response, and compares the unmasked loss against the masked one. The numbers differ because the masked version averages over four positions, not eight, and discards whatever signal the user-prompt positions happened to contribute.
With the loss handled, the next question is mechanical: how do we format a conversation so the model knows what is user, what is system, and what is assistant?
Chat templates and special tokens
What a chat template does
A language model takes a flat sequence of tokens. A conversation has structure, multiple roles (system, user, assistant), turn boundaries, sometimes tool calls. The chat template is the function that flattens the structured conversation into a single token sequence the model can ingest, using special tokens to mark the role boundaries.
The special tokens are not arbitrary. Each model family bakes them into the tokenizer during tokenizer training, with specific token IDs that the model is later trained to recognize. ChatML’s <|im_start|> is one token in OpenAI’s tokenizer, not the multi-character sequence it looks like. Llama-3’s <|begin_of_text|> is similarly one token, and it is Llama-3’s BOS token, not a separate marker. The format characters that look like markup are actually atomic vocabulary items. Mixing them up across model families is a common bug: the receiving model has never seen the other family’s special tokens and treats them as ordinary text, breaking the role parsing.
Four major templates
ChatML, used by OpenAI and adopted by many open models via tokenizer config, wraps each turn with <|im_start|>role and <|im_end|>. It is clean and supports the system role natively:
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is the capital of France?<|im_end|>
<|im_start|>assistant
The capital of France is Paris.<|im_end|>
Llama-3 is verbose and explicit. A document-level <|begin_of_text|>, then per-turn <|start_header_id|>role<|end_header_id|> brackets around the role, then <|eot_id|> (end-of-turn) at the close. Meta distinguishes end-of-turn (this assistant turn is done; the user may continue) from end-of-sequence (this document is over):
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a helpful assistant.<|eot_id|><|start_header_id|>user<|end_header_id|>
What is the capital of France?<|eot_id|>
Mistral’s native template is simpler, [INST] ... [/INST] brackets around user instructions, with the response following. Mistral has no native system role; system instructions get prepended to the first user message:
<s>[INST] You are a helpful assistant.
What is the capital of France? [/INST] The capital of France is Paris.</s>
Gemma is ChatML-shaped but with different syntax: <start_of_turn>role and <end_of_turn> mark the boundaries, and like Mistral there is no native system role.
The templates differ in three dimensions (the special tokens themselves, the handling of system prompts, and the end-of-turn signaling), but they all do the same job: take a list of {role, content} messages and flatten them into a token sequence with unambiguous role boundaries.
Using apply_chat_template()
The modern API is HuggingFace’s tokenizer.apply_chat_template(). Pass a list of messages in the canonical form [{"role": "user", "content": "..."}, ...]; the tokenizer applies the template baked into its config and returns the flattened token sequence. This is the function everyone uses. Writing templates by hand is error-prone: the four templates above are a small sample, the special-token IDs are easy to typo, and a model’s correct template is whatever shipped in its tokenizer. The function gets it right; you do not have to think about it.
The runnable cell implements three of the templates by hand on the same conversation. The output is the same logical content rendered three different ways, and a Llama-3 model fed the ChatML output (or vice versa) produces garbage, because the special tokens it was trained to recognize are not there.
Single-turn formatting is straightforward; real conversations have multiple turns and persistent system prompts.
Multi-turn conversations and system prompts
Multi-turn dialog
The chat templates above extend to multi-turn dialog by simple concatenation, append the next user turn, then the next assistant turn, and the model sees the whole conversation history in one flat token sequence. For SFT, the loss is masked on every user turn, not just the first. Only the assistant turns contribute. The model learns to generate the next assistant response given the full conversation history; it does not learn to generate the user’s side of the dialog.
Multi-turn data has a length-distribution problem. Real conversations vary enormously, a one-shot Q/A is a few hundred tokens; a multi-turn coding session can be thousands, and a batch normally has to be a single rectangular tensor. The naive fix is padding: pad every sequence in the batch out to the length of the longest one with a dedicated pad token, and mask those pad positions out of the loss (frameworks typically set the label at a pad position to , a sentinel HuggingFace’s cross-entropy loss is hardcoded to ignore). Padding is correct but wasteful, the GPU still runs a forward and backward pass over every pad position, and a batch with one long conversation and several short ones spends most of its compute on tokens that carry no signal.
Packing removes that waste instead of tolerating it. Rather than pad short conversations up to the batch max, concatenate several of them, separated by boundary tokens, into one dense sequence at the target length, with attention masking or position-id resets so one conversation cannot attend into its neighbor. Every position in every batch is real content; there are no pad tokens to pay for. The cost is implementation complexity, get the boundary masking wrong and the model silently attends across unrelated conversations. Truncation, dropping the oldest turns when a single conversation alone exceeds the context window, is a separate problem that neither padding nor packing solves on its own. Modern open SFT recipes, Tulu 2, Zephyr, Axolotl, default to packing over padding because the throughput gain from eliminating wasted pad computation is large.
System prompts
A system prompt is the first message of a conversation, marked with the system role, that establishes persistent behavior. Common examples: “You are a helpful assistant”; “You are a coding assistant focused on Python”; “You are a medical professional, always recommend the user consult a doctor for actual diagnosis.” The model is trained to weight system instructions more heavily than later user turns: instructions that arrive as the system prompt have stronger steering effect than the same instructions buried in a user message.
For SFT, the training data should include diverse system prompts so the model learns to follow whichever one the user supplies at inference. A model trained only on conversations with “You are a helpful assistant” struggles when given a different persona at inference; a model trained on a wide variety generalizes. A common robustness trick is to include training examples both with and without system prompts (or with a generic default), so the model handles both cases. Some teams always inject a default system prompt at SFT time and again at inference, eliminating the with-or-without distinction entirely.
The chat-template mechanics handle the shape of the data. The harder question is what content goes inside.
Instruction tuning’s academic roots: FLAN, Dolly, and Super-NaturalInstructions
Instruction tuning is the general term for fine-tuning a language model on (instruction, output) pairs so it learns to follow a natural-language task description directly, instead of needing few-shot demonstrations to infer the task. SFT, as covered so far in this chapter, is instruction tuning specialized for open-ended chat, system/user/assistant roles, multi-turn dialog, a persona. The idea is older than the chat framing, and its empirical case was made before ChatGPT existed.
FLAN (Wei et al. 2022, arxiv.org/abs/2109.01652) made that case first. Google researchers took roughly sixty existing NLP datasets, translation, sentiment classification, natural language inference, summarization, grouped them into a dozen-odd task clusters, and wrapped each dataset’s examples in a handful of natural-language instruction templates (“Translate the following sentence to German:”, “Is this movie review positive or negative?”). A pretrained LM fine-tuned on the resulting mixture was then evaluated zero-shot on task clusters held out of training entirely, and it beat zero-shot GPT-3 on most of them, and matched or beat few-shot GPT-3 on several, despite being no larger. The result: instruction tuning does not just help on the training tasks, it transfers to unseen task types the model was never fine-tuned on. That transfer is the empirical foundation the rest of post-training sits on. FLAN itself has no persona and no conversational turns, it is task-in, task-out, closer to Chapter 8’s supervised objectives than to a chatbot, but “fine-tune on instructions, generalize to new instructions” is the same mechanism SFT relies on.
Super-NaturalInstructions (Wang et al. 2022, arxiv.org/abs/2204.07705) scaled the FLAN idea by an order of magnitude, over distinct NLP tasks, each with a declarative instruction, spanning dozens of languages. In practice it is used less as training data for production chat models and more as a generalization benchmark: held-out task clusters measure whether instruction tuning taught the model to parse and follow a new instruction, or just to pattern-match the training distribution.
Not every foundational instruction dataset is templated NLP benchmarks. Dolly (Databricks, 2023) is instruction-response pairs handwritten by Databricks employees across categories like open QA, brainstorming, classification, and summarization, similar in spirit to LIMA’s hand-curation below but released earlier and at larger scale. Its point was licensing, not a quality argument: Alpaca-style data generated from OpenAI’s API inherits OpenAI’s terms of service, which restrict training competing models on the outputs (the “Limitations” discussion in the synthetic-data section below returns to this). Dolly’s responses are human-written and released under a permissive license specifically so downstream fine-tunes carry no such restriction.
FLAN and Super-NaturalInstructions establish that instruction tuning generalizes to new tasks, provided the training mixture is diverse enough. Neither says how much data is needed, or how good each example has to be. That is the question LIMA answers next.
Data quality matters more than quantity
The LIMA claim
In 2023, Zhou et al. published LIMA: Less Is More for Alignment. They fine-tuned Llama-65B on just instruction-response pairs, manually curated, hand-checked, written or selected for diversity and quality. The result was competitive with much larger SFT runs of the era: Alpaca’s synthetic examples, OpenAssistant’s crowd-sourced ones. On head-to-head human preference comparisons, LIMA was preferred or tied a majority of the time against far bigger datasets.
The claim landed as a provocation because the prevailing assumption was “more SFT data is always better.” LIMA showed the curve plateaus fast, diminishing returns kick in by to examples, and beyond that, quality of new examples matters more than quantity. The paper’s title is the thesis: less is more, if the less is good.
Why alignment is shallow
LIMA’s framing for the result is that alignment is shallow. The pre-trained model has already learned almost everything that ends up in the final chatbot: how to generate text, what humans typically write about, the syntactic and stylistic patterns of natural language, factual content. SFT does not have to teach any of this. What SFT teaches is the format of being a helpful assistant, how to greet, how to structure answers, how to refuse, how to format code, how to follow an instruction without needing an in-context example.
Format is fast to learn. A small number of high-quality examples is enough to convert the model’s pre-existing capabilities into chatbot form. What SFT does not teach is new world knowledge. A model that does not know who won the 2024 election from pre-training will not learn it from a thousand SFT examples either, SFT shifts behavior, not the underlying knowledge representation. The model already knew the content; SFT taught it the shape.
Practical implications
Curate, do not accumulate: a thousand carefully written examples often beats a hundred thousand auto-generated ones, especially for first SFT passes. Diversity matters more than volume: fifty task types with a hundred examples each is more useful than a single task type with five thousand. Bad data teaches bad habits: training on noisy, hedging, or low-information responses produces a model that responds noisily, hedges constantly, and gives low-information answers. The model imitates what it sees.
Quality-over-quantity has limits. At industrial scale, the frontier labs (Llama-3-Instruct, Claude, GPT-4) use SFT mixtures in the hundreds of thousands to millions of examples, but those mixtures are also heavily curated, with filtering, deduplication, model-judged quality scoring, and human review on top. The point is not that big SFT datasets are bad; it is that the quality bar on each example matters more than the headline count. Spend the budget on curation, not on volume.
LIMA shows that quality matters; it does not say where the quality comes from. Manual curation is expensive (LIMA used graduate students). How do most open-source projects actually get their data?
Synthetic data and teacher distillation
The teacher-student pattern
Hand-curating examples for LIMA cost graduate-student hours; hand-curating a million examples is prohibitive. The alternative that drove most of the open-source SFT boom is synthetic data from a stronger model. Pay a stronger LM, GPT-3.5, GPT-4, Claude, to generate instruction-response pairs at scale. Filter for quality. Fine-tune your smaller open model on the result. The teacher’s quality is the ceiling; the student tries to approach it.
The pattern was formalized by Self-Instruct (Wang et al. 2022): bootstrap data using the model itself plus a small seed set. Hand-write a hundred or so seed instructions. Use a strong LM to generate more instructions of similar style. Use the same LM to generate responses to those generated instructions. Filter the lot for quality with heuristics or a model judge. The result is tens of thousands of synthetic examples for the cost of API calls.
The canonical application of this pattern is Alpaca (Taori et al. 2023). hand-written seed instructions, GPT-3.5 (text-davinci-003) to generate more, fine-tune Llama-7B on the result. Total API cost: about \600$. Alpaca was the first cheap, replicable, open instruction-tuned model at competitive quality and triggered a cambrian explosion of synthetic SFT recipes through 2023 and 2024.
Major synthetic SFT datasets
A short tour of the canonical ones. Alpaca (K, single-turn, GPT-3.5-davinci) is the cheap-and-cheerful original. Vicuna (K, multi-turn, ShareGPT conversations) used real ChatGPT conversations that users had shared, and demonstrated multi-turn synthetic data beats single-turn. UltraChat (M, multi-turn, GPT-3.5-turbo) pushed the recipe to industrial scale, generating diverse multi-turn conversations programmatically. OpenOrca (a community reproduction) and the Orca-1/2 series (Microsoft) gathered step-by-step reasoning traces from GPT-4, particularly strong on math and reasoning. WizardLM (Microsoft) introduced instruction evolution, iteratively rewriting instructions to be more complex, training on the harder versions.
Each of these is a recipe; each was state-of-the-art for some window in 2023–2024; each has been superseded by curated mixtures and refined teachers. The lineage is the point, synthetic data went from research curiosity (Self-Instruct, 2022) to standard practice (Alpaca, 2023) to industrial-scale infrastructure (UltraChat, 2023; Tulu 2, 2023) in under two years.
Limitations
Synthetic data has hard ceilings. The distillation ceiling: the student cannot exceed the teacher on the teacher’s distribution. Alpaca cannot exceed GPT-3.5-davinci on the kinds of questions GPT-3.5-davinci can answer; an Alpaca-style recipe with GPT-4 as teacher hits GPT-4’s ceiling, not above. Stylistic homogeneity: all synthetic data sounds like the teacher. A student trained on GPT-3.5 outputs writes like GPT-3.5. The diversity of style and approach you get from real human conversations is gone. Hallucination propagation: when the teacher hallucinates, the student learns to hallucinate the same things, with the same confidence. License issues: many commercial APIs’ terms of service prohibit using outputs to train competing models, which has produced ongoing legal grey areas around the open-source datasets built on top of them.
In practice, no team trains a serious SFT recipe on raw synthetic data. The first thing that happens is filtering: drop the empty responses, the meta-references (“As an AI language model…”), the suspiciously short or suspiciously long outputs, the responses that mostly repeat the question. The cell below implements a toy version: a handful of heuristics that flag a noticeable fraction of low-quality synthetic examples.
Synthetic data works, with caveats. The harder reality is that SFT is not free, and it has its own pathologies.
Pitfalls: format brittleness, capability tax
The capability tax
SFT does not preserve every capability the base model had. The phenomenon goes by several names, alignment tax, catastrophic forgetting, capability tax, and it was visible from the first SFT paper. Ouyang et al. (2022), the InstructGPT paper, reported that their fine-tuned model was worse than base GPT-3 on several standard NLP benchmarks (HellaSwag and similar) despite being preferred by humans on the chat task. The model gained instruction-following at the cost of some pure language-modeling capability.
The mechanism is intuitive. Capabilities the model needs in the SFT distribution get reinforced; capabilities it does not need get partially forgotten. Train an SFT recipe heavy on conversation but light on code, and code performance degrades. Train light on math, and math performance degrades. Train on a narrow distribution of factual topics, and other parts of the world knowledge drift. The model is doing what gradient descent always does: moving toward what reduces the current loss, at the cost of whatever the current loss does not measure.
Mitigations are straightforward and well-known: mix diverse data (include code, math, factual, conversational, in roughly the proportions you want preserved); do not over-train (– epochs, never more); use the lower learning rate so updates are gentle; optionally mix in a small fraction of pre-training data to preserve the base distribution. None of these eliminate the tax, they manage it. The tax is real and persistent; the question is how much you are paying.
Format brittleness
A heavily SFT’d model becomes attached to the format of its training. Prompt it with the exact chat template it was trained on and it produces clean output. Prompt it slightly off (wrong special tokens, no system prompt when it was trained with one, an unusual phrasing of the user turn) and quality degrades. The model is overfit to the format. Some recipes mitigate by including diverse templates or template-free examples in training; others accept the brittleness as the cost of strong format adherence.
Bridge to preference optimization
The deepest limitation of SFT is that it teaches format, not quality. A model post-SFT will respond to any question, but the response can be confidently wrong, confidently harmful, or just confidently low-quality. SFT gives no signal about which response is better when many are plausible: every response in the training data was an example of a valid completion, but never compared against alternatives.
Exercises
Four exercises that build on the chapter’s machinery. Each is a self-contained problem with a starting template; hints are collapsed by default; try the problem first.
Exercise 1 (easy): Response-only loss masking
Implement SFT loss with response-only masking. Verify it produces a different value than the unmasked loss.
Hint
The masked loss is:
Implementation:
- Compute per-position cross-entropy (no reduction)
- Multiply by the mask
- Sum and divide by mask sum (avoid divide-by-zero by clamping)
Solution
Compute per-position cross-entropy, zero out everything but the response tokens, then normalize by the number of response tokens (not by T) so padding/prompt length doesn’t dilute the loss:
def sft_loss(logits, labels, response_mask):
ce = cross_entropy(logits, labels)
masked = ce * response_mask
return masked.sum() / np.clip(response_mask.sum(), 1e-8, None)
masked = sft_loss(logits, labels, response_mask)
print(f"Unmasked loss: {unmasked:.3f}") # 5.328
print(f"Masked loss: {masked:.3f}") # 5.369With seed 0, unmasked loss is 5.328 (averaged over all 12 positions) and masked loss is 5.369 (averaged over only the 6 response positions); these are different values, confirming the mask actually changes which tokens the gradient sees.
Exercise 2 (medium): Chat template formatter
Implement a chat template formatter for ChatML. Given a list of messages, produce the formatted prompt string. Then write a helper that identifies which character ranges in the output correspond to assistant content (for downstream loss masking).
Hint
For each message:
- Append the opening special token
<|im_start|>followed by the role - Append a newline + the content
- Append the closing special token
<|im_end|> - Append another newline for readability
To track assistant ranges, record the start index before writing the content and the end index after. The result is a list of (start, end) tuples, these would be used to build the loss mask after tokenization.
Solution
Track a running index into the output string; record (start, end) only around the assistant branch, after appending the opening tag but before appending the closing one:
def format_chatml(messages):
output = ""
ranges = []
for m in messages:
output += f"<|im_start|>{m['role']}\\n"
if m["role"] == "assistant":
start = len(output)
output += m["content"]
end = len(output)
ranges.append((start, end))
else:
output += m["content"]
output += "<|im_end|>\\n"
return output, ranges
formatted, ranges = format_chatml(messages)
# ranges == [(120, 133), (202, 215)]
# formatted[120:133] == '2+2 equals 4.'
# formatted[202:215] == '3+3 equals 6.'Two assistant turns produce two ranges. Each slice of formatted at those indices reproduces the assistant content exactly, which is what downstream tokenization needs to build the loss mask.
Exercise 3 (medium): Multi-turn loss mask construction
Given a tokenized multi-turn conversation, construct the response mask such that all assistant tokens (including end-of-turn markers) are 1 and everything else is 0.
Hint
In a real implementation, you’d use the tokenizer’s special-token IDs to find role boundaries. For this exercise, work with role labels per token (provided in the starter code).
Walk through the tokens. Track the “current role.” Mark all tokens between the assistant’s <|im_start|> and the assistant’s <|im_end|> (inclusive of the <|im_end|>, model must learn to stop). User and system tokens get 0.
Note: the <|im_start|> and assistant role marker tokens at the start of the assistant turn are NOT in loss, those are context telling the model “now respond.” Content + final <|im_end|> are in loss.
Solution
Walk the tokens with a current_role state variable, set whenever a <|im_start|> is seen (by peeking at the role-marker token right after it). Both marker tokens (<|im_start|> and the role name) are always 0; content tokens are 1 iff current_role == 'assistant'; <|im_end|> is 1 iff it’s closing an assistant turn (checked before resetting the state):
def build_response_mask(tokens):
mask = []
current_role = None
for i, (text, role) in enumerate(tokens):
if text == '<|im_start|>':
current_role = tokens[i + 1][0] # peek at the role marker
mask.append(0)
elif role == 'special' and text != '<|im_end|>':
mask.append(0) # role marker itself
elif text == '<|im_end|>':
mask.append(1 if current_role == 'assistant' else 0)
current_role = None
else:
mask.append(1 if role == 'assistant' else 0)
return mask
mask = build_response_mask(tokens)
in_loss = sum(mask)
print(f"Total tokens: {len(mask)}, in loss: {in_loss} ({in_loss / len(mask) * 100:.0f}%)")
# Total tokens: 29, in loss: 8 (28%)8 of 29 tokens are in loss: the 3 content tokens plus the closing <|im_end|> from each of the two assistant turns. Every system/user token and every turn-opening marker (<|im_start|> + role name) is 0.
Exercise 4 (hard): Synthetic data quality filter
Build a multi-stage quality filter for synthetic SFT data. Apply it to a small dataset and report what fraction is retained. Real systems combine length checks, AI-self-reference detection, reward-model scoring, deduplication, and more.
Hint
A practical filter stacks multiple checks. Examples:
- Length: too-short responses (< 20 chars) or too-long (> 2000 chars) are suspect
- AI self-reference: “As an AI language model” is a red flag
- Repetition: highly repetitive responses (padded) are bad
- Empty / placeholder: “I don’t know.”, “N/A” are bad
- Instruction echo: response that contains the instruction verbatim is suspect
- Length ratio: response much shorter or much longer than expected is suspect
Each check returns a 0/1 (or weight). Combine multiplicatively (any check fails → 0) or additively. Tune thresholds empirically.
Solution
The key trap is the repetition check: comparing each 3-word sequence’s share of all trigrams (e.g. “occurs in >30% of windows”) fails on this dataset, because the padded response is a 4-word phrase repeated 50 times, so its trigrams cycle through 4 distinct windows and no single trigram exceeds ~25% share. Checking the raw count of the most common trigram instead catches it cleanly:
from collections import Counter
def filter_length(ex):
n = len(ex["response"])
return 20 <= n <= 2000
def filter_ai_reference(ex):
return "as an ai" not in ex["response"].lower()
def filter_repetition(ex):
words = ex["response"].lower().split()
if len(words) < 3:
return True
trigrams = [tuple(words[i:i + 3]) for i in range(len(words) - 2)]
most_common_count = Counter(trigrams).most_common(1)[0][1]
return most_common_count <= 5
def filter_placeholder(ex):
placeholders = {"i don't know.", "n/a", "ok"}
return ex["response"].strip().lower() not in placeholders
def filter_echo(ex):
return not ex["response"].strip().lower().startswith(ex["instruction"].strip().lower())
def passes_all_filters(ex):
return all(f(ex) for f in [filter_length, filter_ai_reference, filter_repetition, filter_placeholder, filter_echo])
results = [(ex["instruction"][:40], passes_all_filters(ex)) for ex in dataset]
kept = sum(1 for _, k in results if k)
print(f"Kept: {kept}/{len(dataset)} ({kept / len(dataset) * 100:.0f}%)")
# Kept: 4/10 (40%)Kept: capital of France, photosynthesis, “Hi!”, and the water-formula answer. Rejected: “OK” and “2 + 2 equals 4.” (too short, a real weakness of length-only filtering, since the second one is a correct, complete answer), the AI-self-reference poem, the padded moon description (caught by the raw-count repetition check), the Spain instruction-echo, and the “I don’t know.” placeholder.
Chapter 14 covers preference optimization: RLHF (the original three-stage InstructGPT recipe), DPO (the simpler offline variant from Rafailov et al. 2023), RLVR (verifiable-reward reinforcement learning, the recipe behind DeepSeek-R1’s reasoning training), CAI (Constitutional AI, Anthropic’s approach using AI feedback). These methods compare alternatives, pairs of responses, one preferred over the other, and train the model to prefer the better one. SFT teaches the model to respond; preference optimization teaches it to respond well. The modern open-source post-training stack is SFT plus DPO, and Zephyr-7B (Tunstall et al. 2023) is the canonical demonstration that the combination produces ChatGPT-3.5-class quality on a B model.