Skip to content
Chapter 3 of 8

Supervised fine-tuning (SFT)

Supervised fine-tuning is how you make a general model do your specific thing. You gather examples of the exact behavior you want, prompts paired with the answers you would accept, and run the same training loop from Course 1 over them: forward, loss, backward, step, repeat. As the loss falls, the model fits your examples, and behavior it could not produce before starts coming out correct. In this lab you fine-tune a tiny GPT, the real architecture with attention, on a small set of question and answer lines. You confirm the fresh model does not know the answer, run the SFT loop, watch the loss fall, and then confirm the model completes a held prompt with the correct taught answer. Before and after, on the same prompt, from garbage to right.

The lab: read it, then run it

labs/training/tr3-sft.py
#!/usr/bin/env python3
"""
LAB TR3: Supervised fine-tuning (SFT), the loop that adapts a model to a task.

Supervised fine-tuning is the workhorse of making a model yours. You collect
examples of the behavior you want (here, a small set of question/answer lines),
then run the exact training loop from Course 1 over them: forward, compute the
loss, backward for gradients, step the optimizer, repeat, and watch the loss
fall. When the loss falls, the model is fitting your examples, and it starts
producing the taught answers it could not produce before.

This lab does real SFT on a tiny GPT (the shared Academy architecture, with
attention, so it can actually learn the mapping):
  1. take a fresh model and confirm it does NOT know the answer yet,
  2. run the SFT loop over the examples across many steps,
  3. watch the loss fall, then confirm the model now completes a held prompt with
     the correct taught answer,
and PROVES both: the SFT loss decreased and the model learned the taught answer.

Tiny and fast: a small model, a few hundred steps, a second or two.

Run: python3 modules/academy-content/labs/training/tr3-sft.py
"""
import sys, os
_labs = None
for _c in [os.path.join(os.path.dirname(__file__), "..") if "__file__" in globals() else None,
           os.path.join(os.getcwd(), "..", "labs"), os.path.join(os.getcwd(), "labs")]:
    if _c and os.path.isdir(os.path.join(_c, "_models")): _labs = os.path.abspath(_c); break
if _labs: sys.path.insert(0, os.path.join(_labs, "_models"))

import torch
import torch.nn.functional as F
from model import TinyGPT, CharTokenizer, generate

# The supervised examples: the task we want the model to learn. Repeated so a
# tiny model sees each mapping many times, exactly like a real SFT dataset.
EXAMPLES = [
    "q: capital of france? a: paris.",
    "q: capital of japan? a: tokyo.",
    "q: capital of italy? a: rome.",
    "q: capital of spain? a: madrid.",
]
TEXT = ("\n".join(EXAMPLES) + "\n") * 6
TOK = CharTokenizer(sorted(set(TEXT)))
V = TOK.vocab_size
BLOCK = 32
PROMPT = "q: capital of japan? a:"


def batch(data, bs, gen):
    ix = torch.randint(0, len(data) - BLOCK - 1, (bs,), generator=gen)
    x = torch.stack([data[i:i + BLOCK] for i in ix])
    y = torch.stack([data[i + 1:i + 1 + BLOCK] for i in ix])
    return x, y


def answer_for(model):
    """Greedily complete the held prompt and return just the answer it produced."""
    out = generate(model, TOK, PROMPT, n=8, temperature=0.0, device="cpu")
    return out[len(PROMPT):].strip()


def main():
    torch.manual_seed(1234)
    gen = torch.Generator().manual_seed(1234)
    data = torch.tensor(TOK.encode(TEXT), dtype=torch.long)
    model = TinyGPT(V, c=64, n_layer=2, n_block=BLOCK)

    before = answer_for(model)  # fresh model, before any fine-tuning

    opt = torch.optim.AdamW(model.parameters(), lr=3e-3)
    print("STEP 1-4: the SFT loop (forward, loss, backward, step) over epochs")
    losses = []
    STEPS = 400
    for step in range(STEPS):
        x, y = batch(data, 24, gen)
        logits = model(x)
        loss = F.cross_entropy(logits.reshape(-1, V), y.reshape(-1))
        opt.zero_grad(); loss.backward(); opt.step()
        losses.append(loss.item())
        if step % 50 == 0 or step == STEPS - 1:
            print("  step %3d  loss %.4f" % (step, loss.item()))

    after = answer_for(model)
    start = sum(losses[:10]) / 10
    end = sum(losses[-10:]) / 10
    decreased = end < start * 0.5
    learned_task = ("tokyo" in after) and ("tokyo" not in before)

    print("")
    print("  loss (first 10 avg) %.4f -> (last 10 avg) %.4f" % (start, end))
    print("  before SFT, prompt %r -> answer %r" % (PROMPT, before[:12]))
    print("  after  SFT, prompt %r -> answer %r" % (PROMPT, after[:12]))
    print("  model learned the taught answer (says tokyo now, did not before): %s"
          % ("YES" if learned_task else "NO"))
    print("")
    print("SFT LOSS DECREASED: %s" % ("YES" if decreased else "NO"))
    if not (decreased and learned_task):
        sys.exit(1)


if __name__ == "__main__":
    main()
Lab (read-only)
tr3-sft.py

Fine-tune a tiny GPT on question/answer examples and prove the loss fell and the model learned the answer.

Proves: SFT LOSS DECREASED: YES

Before fine-tuning, the model answered the capital-of-Japan prompt with noise. After a few hundred steps of SFT, it answered tokyo, and the loss fell by more than an order of magnitude. That is supervised fine-tuning in miniature: labeled examples in, adapted behavior out. Two cautions for real work. Your examples define the ceiling, so their quality and coverage matter more than the number of steps, and you watch the validation loss from the last chapter so you stop before the model overfits your small example set. Full fine-tuning updates every weight, which is expensive. Next you learn the cheaper method that runs the industry: LoRA.
Check your understanding
  1. 1. What is supervised fine-tuning?

  2. 2. What proves an SFT run actually worked, beyond a falling loss?

  3. 3. What most limits how good an SFT model can get?