TAI BUI
Back to curriculum
BuildPython

Instruction Tuning (SFT)

A base model predicts the next token and nothing else. It doesn't follow instructions, answer questions, or refuse harmful requests. SFT bridges the gap between a token predictor and a useful assistant, and chat models like Claude, GPT, and Llama Chat all went through this step.

TIME: ~90 minutesPREREQUISITES: Phase 10, Lesson 04 (Pre-Training a Mini GPT)

Learning Objectives

  • Implement supervised fine-tuning (SFT) that converts a base language model into an instruction-following assistant
  • Format training data using chat templates with system, user, and assistant roles, and mask loss on non-assistant tokens
  • Explain why SFT is necessary: base models continue text rather than answer questions
  • Evaluate SFT quality by comparing base model vs fine-tuned model responses on a held-out instruction set

The Problem

You trained a model in Lesson 04. Give it a sequence and it predicts the next token. Feed it "The transformer architecture" and it might continue with "has revolutionized natural language processing." Good for a next-token predictor.

Now feed it "What is the capital of France?" A base model doesn't answer "Paris." It continues the pattern, and the pattern might be a list of questions: "What is the capital of Germany? What is the capital of Spain?" Or it might produce "is a question that many people ask," because that is a plausible continuation too. The model has no concept of answering. It only knows continuing.

That's the gap between GPT-3 (base model, released June 2020) and ChatGPT (instruction-tuned, released November 2022). Same architecture, same pre-training. The difference is 20,000 to 100,000 carefully crafted (instruction, response) pairs that taught the model the conversation pattern.

Stanford Alpaca showed you don't need millions of examples. In March 2023 they fine-tuned Llama 7B on 52,000 instruction-response pairs generated by GPT-3.5, for a total cost of $600. The result followed instructions, answered questions, and held conversations. It wasn't as good as ChatGPT, but it was close for $600 and a few hours of training.

Meta's Llama 2 Chat used about 27,000 high-quality examples for its initial SFT stage. Quality matters more than quantity: 27,000 examples from skilled annotators beat 1 million noisy examples scraped from the internet.

Warm-up0/3
1. During pre-training in Lesson 04, which positions in a training sequence contribute to the loss?Warm-up
Six plain-text tokens (the, cat, sat, on, the, mat) with no role markers, under a dashed box asking which positions feed the loss.
Typed example sentence.
2. You fine-tune a pretrained network on a small new dataset. What happens if the learning rate is far too high?Warm-up
A pretrained-weights card and a small-new-dataset card feeding a weight update rule, with a dashed box asking the effect of a far-too-high learning rate.
Generic fine-tuning setup, typed.
3. A model trains for many epochs on a few hundred examples and starts reciting them word for word. What is this called?Warm-up
A training example sentence and the model's output on a new prompt, shown as word-for-word identical, with a dashed box asking what this is called.
Typed illustrative sentence.
Check: The Problem0/3
1. You feed "What is the capital of France?" to your Lesson 04 model, which was trained only on next-token prediction. What should you expect?Explain
A prompt string, What is the capital of France, fed to a model trained only on next-token prediction, with a dashed box asking for the output.
Prompt text from the lesson's Problem section.
2. Llama 2 Chat's initial SFT stage used about 27,000 examples. What does the lesson conclude about SFT data?Explain
A 27,000-example card labelled Llama 2 Chat, skilled annotators, beside a 1,000,000-example card labelled noisy, scraped from the internet.
Figures from the lesson's Problem section.
3. You can afford either 27,000 skilled-annotator examples or 1 million noisy scraped ones. Which do you pick, and why?Design
Option 1 with 27,000 skilled-annotator examples beside option 2 with 1,000,000 noisy scraped examples, with a question asking which to pick and why.
Figures from the lesson's Problem section.

The Concept

What SFT Actually Does

Supervised Fine-Tuning continues the same training loop from pre-training (forward pass, compute loss, backward pass, update weights) but on a different kind of data. Instead of raw text, you train on structured conversations:

json
{
  "system": "You are a helpful assistant.",
  "user": "What is the capital of France?",
  "assistant": "The capital of France is Paris."
}

The model already knows that Paris is the capital of France. It learned this during pre-training on Wikipedia, textbooks, and web pages. SFT doesn't teach the model new facts. It teaches the model a new behavior: when you see a question, produce an answer. When you see an instruction, produce a completion. When you see a harmful request, produce a refusal.

Think of it this way. Pre-training gives the model knowledge. SFT gives the model manners.

Data Formats

Three formats dominate the industry. Each encodes the same information, who said what, with different delimiters.

Alpaca Format (Stanford, March 2023):

json
{
  "instruction": "Summarize the following article in 3 sentences.",
  "input": "The European Central Bank raised interest rates...",
  "output": "The ECB increased rates by 25 basis points..."
}

Simple and widely used. The input field is optional, since many instructions don't need additional context. Stanford released 52,000 examples in this format, generated by GPT-3.5 for $600. This kicked off the open-source instruction tuning movement.

ShareGPT Format (community, 2023):

json
{
  "conversations": [
    {"from": "system", "value": "You are a helpful assistant."},
    {"from": "human", "value": "What causes tides?"},
    {"from": "gpt", "value": "Tides are caused by the gravitational pull of the Moon..."},
    {"from": "human", "value": "How often do they occur?"},
    {"from": "gpt", "value": "Most coastal areas experience two high tides and two low tides per day..."}
  ]
}

Supports multi-turn conversations. The "from" field uses "human" and "gpt" by convention, regardless of the actual model. Vicuna was trained on 70,000 ShareGPT conversations scraped from user-shared ChatGPT transcripts.

ChatML Format (OpenAI, used by many open-source models):

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

Uses special tokens (<|im_start|>, <|im_end|>) to delimit roles. These tokens are added to the tokenizer's vocabulary during fine-tuning. Qwen, Yi, and many other models use ChatML.

All three formats accomplish the same thing: they tell the model "this is the instruction, this is the response, learn this pattern."

Why It Works

The model already knows language from pre-training. It has seen billions of examples of questions followed by answers, instructions followed by completions, and conversations between people. The patterns are already encoded in the weights.

SFT concentrates this latent ability. Instead of the model needing to figure out from context whether it should answer a question or continue a document, SFT explicitly trains on the conversation pattern. After a few thousand examples, the model learns: when you see the assistant role marker, produce a helpful response.

This is why 27,000 examples is enough. You're not teaching the model English. You're not teaching it facts about the world. You're teaching it one simple behavior: respond to instructions. The knowledge was already there.

The Masked Loss

This is the most important technical detail in SFT, and many tutorials skip it.

During pre-training, you compute loss on every token. The model learns to predict every next token in the sequence. During SFT, you only compute loss on the response tokens. The instruction tokens are there for context, but the model is not penalized for "predicting" them incorrectly.

Why? Because you don't want the model to learn to generate instructions. You want it to learn to respond to instructions. If you compute loss on the instruction tokens, you're training the model to predict "What is the capital of France?" as if it's the one asking the question. That wastes gradient signal and can confuse the model about its role.

In practice, you create a loss mask: 1 for response tokens, 0 for instruction tokens. Multiply the per-token loss by this mask before averaging.

Tokens:    [SYS] You are helpful [USER] What is the capital? [ASST] Paris is the capital [EOS]
Loss mask:   0    0    0     0      0     0   0  0     0       1     1    1   1     1      1

Only the tokens after [ASST] contribute to the loss. The model sees the full conversation during the forward pass (it needs the instruction to produce the right response) but only updates its weights based on how well it predicted the response.

Training Hyperparameters

SFT uses very different hyperparameters than pre-training. You're not training from scratch. You're adjusting a model that already works.

ParameterPre-Training (Llama 2 7B)SFT (Llama 2 Chat)
Learning rate3e-4 (peak)2e-5
Epochs1 (single pass over data)2
Batch size4M tokens64 examples
Warmup steps2,0000-100
Weight decay0.10.0-0.1
Data size2T tokens27,000 examples

The learning rate is 15x lower for SFT. This is critical. A high learning rate during fine-tuning destroys the pre-trained knowledge. The model "forgets" what it learned and overfits to the small fine-tuning dataset. This is catastrophic forgetting.

Two epochs means the model sees each training example twice. More than 3 epochs on a small dataset leads to memorization: the model starts reproducing training examples verbatim instead of generalizing.

Catastrophic Forgetting

Fine-tuning can destroy general capabilities. Train too long on instruction-following data and the model loses its ability to write code, do math, or produce creative text. It becomes very good at the specific format of its training data and terrible at everything else.

Three mitigations:

  1. Low learning rate. 1e-5 to 5e-5. Smaller updates mean less destruction of pre-trained features.

  2. Short training. 1-3 epochs. Stop before the model overfits.

  3. Mix in pre-training data. Llama 2 Chat mixed a small percentage (2-5%) of raw pre-training data into the SFT dataset. This "reminds" the model of its general capabilities while learning the new instruction-following behavior.

Real Numbers

Fine-tuning a 7B model on 10,000 high-quality instruction pairs takes approximately 1 hour on a single NVIDIA A100 80GB GPU. Here's the math:

  • 10,000 examples x 512 tokens average = 5.12M tokens
  • 2 epochs = 10.24M tokens total
  • A100 throughput for 7B model fine-tuning: ~3,000 tokens/second
  • 10.24M / 3,000 = ~3,400 seconds = ~57 minutes

For our mini GPT (4 layers, 128 dims), training is nearly instant. The point is understanding the mechanics, not the scale.

Mermaid diagram

Check: The Concept0/23
1. What does SFT training data typically look like?Define
A JSON object with system, user and assistant fields, each holding a short line of text.
Example copied from the lesson's Concept section.
2. Which set correctly names the three dominant SFT data formats?Define
Three unlabelled format cards, each with a structural hint (JSON fields, a role-tagged list, or special delimiter tokens) and a dashed box asking the format's name.
Structural hints from the lesson's Data Formats section.
3. What is supervised fine-tuning (SFT)?Define
A pretrained model and an instruction-response pair both feeding a forward, loss, backward, update box.
Example pair from the lesson's Build It dataset.
4. What is instruction tuning meant to teach a base model?Define
A fact already present in a pretrained model's weights, with a dashed box asking what instruction tuning adds on top of it.
Fact from the lesson's Concept section example.
5. What is loss masking in SFT?Define
A sequence split into an instruction region marked mask = 0 and a response region marked mask = 1, with a note that per-token loss is multiplied by this mask.
Schematic of the lesson's loss mask.
6. What is ChatML?Define
A ChatML transcript: system, user and assistant turns each wrapped in im_start and im_end tokens.
Example copied from the lesson's Data Formats section.
7. What does a record in the Alpaca format look like?Define
A JSON record with instruction, input and output fields, each holding a sentence of text.
Example copied from the lesson's Data Formats section.
8. Which description matches catastrophic forgetting in SFT?Define
A before card listing general skills (code, math, creative text) and an after card saying the model is now only good at its training format, with a question about the name of this pattern.
Symptom described in the lesson's Catastrophic Forgetting section.
9. What is weight tying?Define
A single shared matrix W with arrows pointing to both a token-embedding box and an output-head box.
Illustrative, generic diagram.
10. What is a chat template?Define
A three-line template with SYSTEM, USER and ASSISTANT markers, each followed by an empty slot.
Illustrative, not tied to one named format.
11. Why can a few thousand examples be enough to make a base model follow instructions?Explain
A list of patterns already seen billions of times in pre-training (question to answer, instruction to completion), with a dashed box asking why a few thousand SFT examples are enough.
Patterns named in the lesson's Why It Works section.
12. How does the SFT training loop differ from pre-training?Explain
A four-step loop, forward, loss, backward, update, with a note that pre-training data is raw text, and a dashed box asking what data SFT runs the same loop on.
Schematic of the training loop.
13. Why does SFT mask the loss on system and user tokens?Explain
A sequence split into a masked instruction region and an unmasked response region, with a dashed box asking why the instruction region is masked.
Schematic of the lesson's loss mask.
14. In SFT, why does the forward pass still include the instruction tokens when they carry no loss?Explain
A forward-pass box seeing the full sequence, pointing down to the same mask bar with a masked instruction region, and a dashed box asking why the instruction tokens are kept in the forward pass.
Schematic of the lesson's Masked Loss section.
15. How does the SFT learning rate compare to pre-training in the Llama 2 hyperparameter table, and why?Explain
A two-row table of learning rates, 3e-4 for pre-training and 2e-5 for SFT, with a question about how they compare and why.
Values from the lesson's Training Hyperparameters table.
16. Why does SFT on a small dataset usually stop after 1 to 3 epochs?Explain
Epoch numbers 1 through 5, with 1 through 3 highlighted as the usual stopping range, and a dashed box asking what happens past about epoch 3 on a small dataset.
Epoch range from the lesson's Training Hyperparameters section.
17. Why did Llama 2 Chat mix 2-5% raw pre-training data into its SFT set?Explain
A 95 to 98 percent slice of instruction pairs beside a 2 to 5 percent slice of raw pre-training text, with a dashed box asking the reason for the small slice.
Mix ratio from the lesson's Catastrophic Forgetting section.
18. What is the main difference between the Alpaca and ShareGPT formats?Explain
One Alpaca record with instruction, input and output fields, above a ShareGPT conversations list with four alternating human and gpt turns.
Examples excerpted from the lesson's Data Formats section.
19. You fine-tune on 20,000 examples of 512 tokens for 2 epochs at about 3,000 tokens per second. Roughly how long does it take?Apply
Given values: 20,000 examples, 512 tokens per example, 2 epochs, and 3,000 tokens per second throughput, with a dashed box asking for the total hours.
Formula from the lesson's Real Numbers section.
20. After 8 epochs on 500 pairs, a model recites its training answers but struggles with new phrasing. Which setting caused this?Analyze
A card giving 8 epochs on 500 pairs, beside a symptom card describing recitation of training answers and poor handling of new phrasing, with a question about the cause.
Scenario numbers as given in the question.
21. After SFT your assistant answers politely but states a wrong capital, and the base model made the same error. Which diagnosis fits?Analyze
A base-model card and an SFT-model card, both giving the same wrong capital, the SFT one stated politely, with a question about the diagnosis.
Scenario as given in the question.
22. Your team fine-tunes a 7B model on 20,000 pairs for 6 epochs at a 3e-4 learning rate. It follows the format perfectly but now fails at code and math it handled before. What is the best fix?Analyze
A settings table of 20,000 pairs, 6 epochs and a 3e-4 learning rate, with a result noting perfect format but failing code and math, and a dashed box asking the best fix.
Settings as given in the question.
23. You must tune a support bot on multi-turn transcripts with a system prompt, with role boundaries as explicit tokens. Which design fits?Design
A checklist of three requirements: multi-turn conversations, a system prompt, and explicit role-boundary tokens, with a dashed box asking which data design fits.
Requirements as given in the question.

Build It

Step 1: Instruction Dataset

Create a synthetic instruction dataset. In production, companies like Scale AI and Anthropic employ human annotators to write these. We'll create them programmatically to demonstrate the format.

python
import numpy as np
 
INSTRUCTION_DATA = [
    {
        "instruction": "What is the capital of France?",
        "response": "The capital of France is Paris."
    },
    {
        "instruction": "Explain gravity in one sentence.",
        "response": "Gravity is the force that attracts objects with mass toward each other."
    },
    {
        "instruction": "Write a haiku about the ocean.",
        "response": "Waves crash on the shore, salt and foam beneath the sun, endless blue expanse."
    },
    {
        "instruction": "What is 15 multiplied by 7?",
        "response": "15 multiplied by 7 is 105."
    },
    {
        "instruction": "Name three programming languages.",
        "response": "Three programming languages are Python, Rust, and TypeScript."
    },
    {
        "instruction": "Summarize photosynthesis.",
        "response": "Photosynthesis converts sunlight, water, and carbon dioxide into glucose and oxygen."
    },
    {
        "instruction": "What year did World War II end?",
        "response": "World War II ended in 1945."
    },
    {
        "instruction": "Define machine learning.",
        "response": "Machine learning is a field where algorithms learn patterns from data to make predictions."
    },
]

Eight examples is tiny. Stanford Alpaca used 52,000. But the mechanics are identical whether you have 8 or 52,000: tokenize, mask, compute loss on responses only.

Step 2: Tokenize with Chat Template

Convert instruction-response pairs into token sequences with special role markers. The markers tell the model where the instruction ends and where the response begins.

python
SPECIAL_TOKENS = {
    "INST_START": 253,
    "INST_END": 254,
    "RESP_START": 255,
}
 
 
def tokenize_instruction_pair(instruction, response, vocab_size=256):
    inst_tokens = list(instruction.encode("utf-8"))
    resp_tokens = list(response.encode("utf-8"))
 
    inst_tokens = [min(t, vocab_size - 4) for t in inst_tokens]
    resp_tokens = [min(t, vocab_size - 4) for t in resp_tokens]
 
    tokens = (
        [SPECIAL_TOKENS["INST_START"]]
        + inst_tokens
        + [SPECIAL_TOKENS["INST_END"]]
        + [SPECIAL_TOKENS["RESP_START"]]
        + resp_tokens
    )
 
    return tokens
 
 
def create_loss_mask(tokens):
    mask = np.zeros(len(tokens), dtype=np.float32)
    in_response = False
 
    for i, token in enumerate(tokens):
        if token == SPECIAL_TOKENS["RESP_START"]:
            in_response = True
            continue
        if in_response:
            mask[i] = 1.0
 
    return mask

The loss mask is all zeros for instruction tokens and all ones for response tokens. The RESP_START token itself gets a mask of 0 because it's a delimiter, not part of the response content.

Step 3: Masked Cross-Entropy Loss

Standard cross-entropy, but multiplied by the loss mask. Only response tokens contribute to the gradient.

python
def masked_cross_entropy_loss(logits, targets, loss_mask):
    batch, seq_len, vocab_size = logits.shape
    logits_flat = logits.reshape(-1, vocab_size)
    targets_flat = targets.reshape(-1)
    mask_flat = loss_mask.reshape(-1)
 
    max_logits = logits_flat.max(axis=-1, keepdims=True)
    log_softmax = logits_flat - max_logits - np.log(
        np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
    )
 
    per_token_loss = -log_softmax[np.arange(len(targets_flat)), targets_flat]
 
    masked_loss = per_token_loss * mask_flat
    num_response_tokens = mask_flat.sum()
    if num_response_tokens == 0:
        return 0.0
    loss = masked_loss.sum() / num_response_tokens
 
    return loss

The denominator is num_response_tokens, not seq_len. If you divide by the total sequence length, longer instructions dilute the gradient signal. Dividing by response token count ensures equal weight per response token regardless of instruction length.

Step 4: SFT Training Loop

Reuse the MiniGPT from Lesson 04. The training loop looks almost identical to pre-training, but with instruction formatting and masked loss.

python
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "04-pre-training-mini-gpt", "code"))
from main import MiniGPT, LayerNorm, FeedForward, MultiHeadAttention, TransformerBlock, Embedding
 
 
def sft_train(model, dataset, num_epochs=2, lr=2e-5, seq_len=64):
    formatted_data = []
    for example in dataset:
        tokens = tokenize_instruction_pair(example["instruction"], example["response"])
        mask = create_loss_mask(tokens)
        formatted_data.append((tokens, mask))
 
    print(f"SFT Training: {len(formatted_data)} examples, {num_epochs} epochs, lr={lr}")
    print(f"Total tokens: {sum(len(t) for t, _ in formatted_data):,}")
    print()
 
    losses = []
 
    for epoch in range(num_epochs):
        epoch_loss = 0.0
        num_batches = 0
 
        indices = np.random.permutation(len(formatted_data))
 
        for idx in indices:
            tokens, mask = formatted_data[idx]
 
            if len(tokens) < 3:
                continue
            if len(tokens) > seq_len:
                tokens = tokens[:seq_len]
                mask = mask[:seq_len]
 
            input_ids = np.array(tokens[:-1]).reshape(1, -1)
            target_ids = np.array(tokens[1:]).reshape(1, -1)
            loss_mask = np.array(mask[1:]).reshape(1, -1)
 
            logits = model.forward(input_ids)
            loss = masked_cross_entropy_loss(logits, target_ids, loss_mask)
 
            batch_size, s_len, v_size = logits.shape
            probs = np.exp(logits - logits.max(axis=-1, keepdims=True))
            probs = probs / probs.sum(axis=-1, keepdims=True)
            dlogits = probs.copy()
            dlogits[np.arange(batch_size)[:, None], np.arange(s_len), target_ids] -= 1.0
 
            mask_expanded = loss_mask[:, :, np.newaxis]
            num_resp = loss_mask.sum()
            if num_resp > 0:
                dlogits = dlogits * mask_expanded / num_resp
 
            for block in model.blocks:
                block.ffn.W1 -= lr * np.random.randn(*block.ffn.W1.shape) * 0.01
                block.ffn.W2 -= lr * np.random.randn(*block.ffn.W2.shape) * 0.01
                block.ffn.b1 -= lr * np.random.randn(*block.ffn.b1.shape) * 0.01
                block.ffn.b2 -= lr * np.random.randn(*block.ffn.b2.shape) * 0.01
 
            epoch_loss += loss
            num_batches += 1
            losses.append(loss)
 
        avg_loss = epoch_loss / max(num_batches, 1)
        print(f"Epoch {epoch + 1}/{num_epochs} | Avg Loss: {avg_loss:.4f}")
 
    return model, losses

The learning rate is 2e-5, matching Llama 2 Chat. Pre-training used 3e-4, so this is 15x smaller. The dlogits are masked, so instruction positions contribute zero and only response positions carry signal.

Step 5: Compare Base vs SFT Model

The whole point of SFT is behavioral change. Let's measure it by checking how the model responds to instruction-formatted inputs versus raw text continuations.

python
def generate_response(model, prompt_tokens, max_new_tokens=50, temperature=0.8):
    tokens = list(prompt_tokens)
    seq_len = model.embedding.pos_embed.shape[0]
 
    for _ in range(max_new_tokens):
        context = np.array(tokens[-seq_len:]).reshape(1, -1)
        logits = model.forward(context)
        next_logits = logits[0, -1, :]
 
        next_logits = next_logits / max(temperature, 1e-8)
        probs = np.exp(next_logits - next_logits.max())
        probs = probs / probs.sum()
        probs = np.clip(probs, 1e-10, 1.0)
        probs = probs / probs.sum()
 
        next_token = np.random.choice(len(probs), p=probs)
        tokens.append(int(next_token))
 
    return tokens
 
 
def evaluate_instruction_following(model, instructions):
    print("Evaluating instruction following:")
    print("-" * 50)
 
    for instruction in instructions:
        tokens = (
            [SPECIAL_TOKENS["INST_START"]]
            + [min(t, 252) for t in list(instruction.encode("utf-8"))]
            + [SPECIAL_TOKENS["INST_END"]]
            + [SPECIAL_TOKENS["RESP_START"]]
        )
 
        output = generate_response(model, tokens, max_new_tokens=30, temperature=0.6)
        response_start = len(tokens)
        response_tokens = output[response_start:]
        response_bytes = bytes([t for t in response_tokens if t < 128])
        response_text = response_bytes.decode("utf-8", errors="replace")
 
        print(f"  Q: {instruction}")
        print(f"  A: {response_text[:80]}")
        print()

On a tiny model with 8 examples, the responses won't be meaningful. The weight update here is random noise rather than a gradient step (see the Author's Note), so the output is noise whatever the data. What you're checking is the structure: the prompt ends at the response marker, so everything generated counts as response text. With a real backward pass, this is where a model learns to answer instead of generating more instructions.

Step 6: Measure Catastrophic Forgetting

Compare the model's next-token prediction ability before and after SFT. If SFT damages general capabilities, the loss on raw text will increase.

python
def measure_forgetting(model, test_text, seq_len=64):
    tokens = np.array(list(test_text.encode("utf-8")[:512]))
 
    total_loss = 0.0
    num_windows = 0
 
    for start in range(0, len(tokens) - seq_len - 1, seq_len):
        input_ids = tokens[start:start + seq_len].reshape(1, -1)
        target_ids = tokens[start + 1:start + seq_len + 1].reshape(1, -1)
 
        logits = model.forward(input_ids)
 
        batch, s_len, vocab_size = logits.shape
        logits_flat = logits.reshape(-1, vocab_size)
        targets_flat = target_ids.reshape(-1)
 
        max_logits = logits_flat.max(axis=-1, keepdims=True)
        log_softmax = logits_flat - max_logits - np.log(
            np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
        )
 
        loss = -log_softmax[np.arange(len(targets_flat)), targets_flat].mean()
        total_loss += loss
        num_windows += 1
 
    return total_loss / max(num_windows, 1)

In real fine-tuning, you would track this metric throughout training. If the raw text loss increases by more than 10-15%, your SFT is too aggressive. Lower the learning rate or reduce the number of epochs.

Check: Build It0/13
1. In the masked cross-entropy loss, why divide the summed loss by `num_response_tokens` instead of the full sequence length?Explain
Three lines of the lesson's masked_cross_entropy_loss code, ending in a division by num_response_tokens, with a question about why that denominator is used.
Code copied from the lesson's main.py.
2. In `create_loss_mask`, why does the response-start marker itself get a mask of 0?Explain
The lesson's create_loss_mask loop: when the token equals RESP_START it sets in_response to True and continues, skipping the mask assignment for that index, with a question about why.
Code copied from the lesson's main.py.
3. Why does `tokenize_instruction_pair` clamp byte values with `min(t, vocab_size - 4)`?Explain
The lesson's SPECIAL_TOKENS dict with ids 253, 254, 255, and the line that clamps byte values with min(t, vocab_size - 4), with a question about why 252 is the clamp.
Code copied from the lesson's main.py.
4. In `evaluate_instruction_following`, why does the prompt end with the response-start token?Explain
The lesson's evaluate_instruction_following code: the prompt ends with the RESP_START token, generation runs from there, and response_tokens slices everything after len(tokens), with a question about why the prompt ends there.
Code copied from the lesson's main.py.
5. Why compare the raw-text loss before and after SFT?Explain
A before-SFT and an after-SFT card, both calling measure_forgetting on the same raw test text, with a question about what comparing the two results shows.
Function name from the lesson's Build It, Step 6.
6. `tokenize_instruction_pair("Hi", "Yo")` returns 7 tokens. How many ones does `create_loss_mask` give for them?Apply
Seven tokens from tokenize_instruction_pair for Hi and Yo, labelled with the instruction-start, H, i, instruction-end, response-start, Y, o markers, with a dashed box asking the response-token count.
Tokens printed by the lesson's tokenize_instruction_pair.
7. For tokens [253, 72, 105, 254, 255, 89, 111] with mask [0,0,0,0,0,1,1], what loss_mask does `sft_train` pass to the loss?Apply
The seven tokens with their full mask [0,0,0,0,0,1,1] printed below, and a note that sft_train passes mask[1:] as the loss_mask, with a dashed box asking the resulting array.
Mask computed with the lesson's create_loss_mask; the code line is from sft_train.
8. Per-token losses are [2.0, 1.0, 3.0, 0.5] and the mask is [0, 0, 1, 1]. What does `masked_cross_entropy_loss` return?Apply
A four-row table of per-token losses 2.0, 1.0, 3.0, 0.5 with mask values 0, 0, 1, 1, and a dashed box asking the loss function's result.
Worked by hand with the lesson's masked_cross_entropy_loss formula.
9. A developer passes `mask[:-1]` instead of `mask[1:]` as loss_mask beside the targets `tokens[1:]`. What goes wrong?Analyze
Eight tokens for Hey and Ok, with the seven shifted targets below them offset by one position, and a note comparing loss_mask as mask[1:] versus mask[:-1], asking what goes wrong with the second choice.
Tokens computed with the lesson's tokenize_instruction_pair; a different example than the one in question n21.
10. The demo prints a loss curve and "Minimal forgetting". What can you conclude about SFT quality?Analyze
The lesson's sft_train weight-update line, subtracting a learning-rate-scaled random value from the feed-forward weights, beside a note that the demo prints a loss curve and Minimal forgetting, with a dashed box asking what can be concluded.
Code copied from the lesson's main.py.
11. Two runs use the same responses, but run B has instructions three times longer and divides the loss by sequence length. What happens in run B?Analyze
A run A card with instruction length I and a run B card with instruction length 3 times I, both responses the same length R, with a note that both runs divide by the full sequence length, and a question about the effect on run B's response tokens.
Scenario as given in the question.
12. You finished SFT on your own data. Which evaluation best shows the behavior changed without hidden damage?Design
A goal-1 card about behavior change and a goal-2 card about no hidden damage, with a dashed box asking for the evaluation plan that covers both.
Goals drawn from the lesson's own evaluation objective.
13. A trial run raised raw-text loss by 25%. Which next trial follows the lesson's guidance?Design
A bar showing raw-text loss rising 25 percent, above a dashed threshold line at 15 percent, with a dashed box asking what the next trial should do.
The 25 percent figure and 15 percent threshold as given in the question and the lesson's guidance.

Use It

Full SFT Pipeline Demo

python
if __name__ == "__main__":
    np.random.seed(42)
 
    test_text = """The transformer architecture processes sequences through self-attention.
Each layer applies multi-head attention followed by a feedforward network.
Residual connections and layer normalization stabilize deep networks.
The model learns to predict the next token given all previous tokens."""
 
    print("=" * 70)
    print("INSTRUCTION TUNING (SFT) DEMO")
    print("=" * 70)
    print()
 
    model = MiniGPT(
        vocab_size=256, embed_dim=128, num_heads=4,
        num_layers=4, max_seq_len=128, ff_dim=512
    )
    print(f"Model: {model.count_parameters():,} parameters")
    print(f"Config: 4 layers, 4 heads, 128 dims (mini GPT from Lesson 04)")
    print()
 
    print("PRE-SFT: Measuring base model loss on raw text")
    base_loss = measure_forgetting(model, test_text)
    print(f"  Base model loss: {base_loss:.4f}")
    print()
 
    print("=" * 70)
    print("SFT TRAINING")
    print("=" * 70)
 
    model, losses = sft_train(
        model, INSTRUCTION_DATA, num_epochs=3, lr=2e-5, seq_len=128
    )
 
    print()
    print("POST-SFT: Measuring fine-tuned model loss on raw text")
    sft_loss = measure_forgetting(model, test_text)
    print(f"  SFT model loss: {sft_loss:.4f}")
    print(f"  Change: {((sft_loss - base_loss) / base_loss * 100):+.1f}%")
    if abs(sft_loss - base_loss) / base_loss < 0.15:
        print("  Minimal forgetting (< 15% change)")
    else:
        print("  Significant forgetting detected")
    print()
 
    print("=" * 70)
    print("INSTRUCTION FOLLOWING EVALUATION")
    print("=" * 70)
    print()
 
    test_instructions = [
        "What is the capital of France?",
        "Name a programming language.",
        "Define gravity.",
    ]
    evaluate_instruction_following(model, test_instructions)
 
    print("=" * 70)
    print("DATA FORMAT EXAMPLES")
    print("=" * 70)
    print()
 
    for i, example in enumerate(INSTRUCTION_DATA[:3]):
        tokens = tokenize_instruction_pair(example["instruction"], example["response"])
        mask = create_loss_mask(tokens)
        resp_count = int(mask.sum())
        total_count = len(tokens)
        print(f"  Example {i + 1}: {total_count} tokens, {resp_count} response tokens ({resp_count/total_count:.0%} of sequence)")
        print(f"    Instruction: {example['instruction']}")
        print(f"    Response: {example['response']}")
        print()
 
    print("=" * 70)
    print("TRAINING LOSS CURVE")
    print("=" * 70)
    print()
 
    if losses:
        window = max(1, len(losses) // 5)
        for i in range(0, len(losses), window):
            chunk = losses[i:i + window]
            avg = sum(chunk) / len(chunk)
            print(f"  Steps {i:3d}-{i + len(chunk) - 1:3d}: avg loss = {avg:.4f}")
Check: Use It0/2
1. In the data-format printout for example 1 ("What is the capital of France?" and "The capital of France is Paris."), what is shown?Apply
Example 1's instruction and response text, with a dashed box asking the total token count, response token count and percentage the printout reports.
Example 1 from the lesson's INSTRUCTION_DATA.
2. The base raw-text loss is 5.50 and the SFT loss is 5.90. What does the demo print?Apply
A two-row table of raw-text loss, 5.50 for the base model and 5.90 for the SFT model, with a dashed box asking the percent change and which message the demo prints.
Values given as a worked example, matching the demo's print format.

Ship It

This lesson produces outputs/prompt-sft-data-curator.md, a prompt that helps you design and curate instruction datasets for SFT. Given a target capability (code generation, math, conversation), it produces a data collection plan with format specifications, quality criteria, and diversity requirements.

Author's Note

I'd run main.py and read the data format printout first. For three examples it shows how many of the tokens are response tokens, which is the loss mask made concrete. Then reread create_loss_mask and the mask[1:] slice in sft_train together. The mask has to line up with the shifted targets, and that alignment is easy to get off by one.

One thing to know before you trust any number it prints: the weight update in sft_train is not a gradient step. The script builds dlogits with the mask applied and then doesn't backpropagate it. What changes the weights is small random noise on the feed-forward matrices, scaled by lr. So the loss curve and the before/after forgetting check exercise the plumbing (formatting, masking, the denominator) and say nothing about whether SFT worked. I'd wire a real backward pass through the Lesson 04 model before running the learning rate comparison in exercise 5, since that comparison only means something after that.

The tokenizer is a toy too. Bytes above 252 are clamped to 252 so the three special ids stay free, and the evaluation decodes only tokens below 128. That's fine for ASCII prompts. For anything else I'd swap in a real tokenizer with ChatML-style special tokens.

Exercises

  1. Add system prompt support. Modify tokenize_instruction_pair to accept a system message and prepend it before the instruction. Create 5 examples with different system prompts ("You are a poet", "You are a math tutor") and verify the model sees different system prompts during training.

  2. Implement data mixing. Create a function that takes an SFT dataset and a raw text corpus, then produces training batches where 5% of examples are raw text (no masking) and 95% are instruction pairs (masked). Run 3 epochs and compare forgetting metrics against pure SFT training.

  3. Build a data quality scorer. For each instruction-response pair, compute: (a) response length in tokens, (b) instruction-to-response ratio, (c) vocabulary diversity (unique tokens / total tokens). Filter out examples with response length < 10 tokens or diversity < 0.3. Show how filtering affects the final loss.

  4. Implement multi-turn conversation training. Extend the tokenization to handle 3-turn conversations (user-assistant-user-assistant-user-assistant). The loss mask should cover all three assistant turns. Verify the mask is correct by printing the token-mask alignment for one example.

  5. Compare learning rates. Train the same model three times with lr=1e-4, lr=2e-5, and lr=1e-6. Plot the loss curves. The 1e-4 run should show rapid initial descent but higher final loss (overfitting). The 1e-6 run should barely move. The 2e-5 run should be the sweet spot.

Key Terms

TermWhat people sayWhat it actually means
SFT"Fine-tuning on conversations"Supervised Fine-Tuning: continuing training on (instruction, response) pairs with loss computed only on response tokens
Instruction tuning"Teaching the model to follow instructions"Training on explicit instruction-response pairs so the base model learns the conversation pattern, not new knowledge
Loss masking"Ignoring the prompt"Setting loss to zero for instruction tokens so gradients only flow from response token predictions
ChatML"Chat Markup Language"A token format using <|im_start|> and <|im_end|> delimiters to mark speaker roles in conversation data
Alpaca format"Stanford's format"A JSON format with instruction/input/output fields, used for 52K GPT-3.5-generated examples that cost $600
Catastrophic forgetting"The model gets dumber"Fine-tuning destroys pre-trained capabilities because gradient updates overwrite general knowledge with task-specific patterns
Weight tying"Shared embeddings"Using the same matrix for input token embeddings and output prediction head, saving parameters and improving coherence
Chat template"How you format the prompt"The specific token sequence (role markers, delimiters) that structures a conversation for the model

Further Reading

Code

Ships With