Index Ai Academy

Fine-tune an LLM and decide whether to ship it

Fine-tune a small chat model, test it on held-back products, and use release gates to catch a candidate that looks better but isn't.

What you'll build

Fine-tuning is the easy part. Knowing whether the new model is better takes discipline. This tutorial tunes a small chat model to follow a brand's rules, and the first candidate improves the headline number while quietly getting worse at something else. Release gates, set before any training, are what catch it.

What you'll learn

  • Run a release review: train a candidate, evaluate it on every criterion, and apply release gates
  • Diagnose a failed candidate and fix it by changing the training data
  • Explain why a single headline metric, or a raw loss, can hide regressions
  • Write a model card that records what the model is for, how it was made and how it scored

What you need

Python and basic PyTorch, and what fine-tuning is. This one runs inside the academy, because the small chat model and the brand checker it uses are served by the practice API. The method carries over to any model and any tuning service. A free account opens it.

Step 1: Set the release gates first

A gate is a pass or fail test that a model must pass to ship. There are three here: violations fall by at least 20%, no single guideline gets worse by more than 5 points, and at least 90% of answers still finish cleanly. They're decided before any training, because afterwards it's too easy to choose whichever rule the new model happens to pass.

def gates(before, after, guidelines, min_drop=0.2, max_regression=0.05, min_clean=0.9):
    return {
        "improved": after["violations per answer"] <= (1 - min_drop) * before["violations per answer"],
        "no_regression": all(after[g] - before[g] <= max_regression for g in guidelines),
        "clean": after["ends cleanly"] >= min_clean,
    }

Step 2: Load the model and prepare the data

Load two copies of the model, one to keep as the baseline and one to tune. Ten product names are held back from training, and six of those make up the test, so the results show whether it learned general rules or just memorized products. In the labels, -100 tells the loss to ignore the prompt, so the model is only graded on its answers.

import requests
import torch
import torch.nn as nn
from tinygpt import load_pretrained

baseline, tok = load_pretrained("tiny-gpt-chat")
candidate, _ = load_pretrained("tiny-gpt-chat")
end = tok.token_id("<end>")
pairs = requests.get("https://api.gradient.test/v1/preferences").json()["data"]
kinds = sorted({" ".join(p["prompt"].split()[3:-1]) for p in pairs})
test_names = {"Kestrel", "Lark", "River", "Sierra", "Summit", "Willow"}
held_back = test_names | {"Alder", "Basin", "Cove", "Ember"}        # test and validation names

def to_tensors(rows):
    enc = []
    for prompt, answer in rows:
        p, a = tok.encode(f"<user> {prompt} <assistant>"), tok.encode(answer) + [end]
        enc.append(((p + a)[:65], ([-100] * len(p) + a)[:65]))
    T = max(len(ids) for ids, _ in enc)
    return (torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in enc]),
            torch.tensor([lab + [-100] * (T - len(lab)) for _, lab in enc]))

Step 3: Write the fine-tuning loop

It's an ordinary training loop with a learning rate well below the original training's, because you're adjusting a working model, and large steps would damage what it already knows.

def finetune(model, rows, steps=80):
    X, Y = to_tensors(rows)
    opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
    model.train()
    torch.manual_seed(1)
    for step in range(steps):
        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()
    model.eval()

Rather write it yourself?

In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.

Create a free account

Step 4: Test on products it never saw

Each of 72 prompts is answered three times, for 216 answers. Models vary from run to run, and at 72 answers chance alone could move a guideline by more than the 5-point margin. A checker counts the flaws in each answer, and the function reports each guideline separately.

@torch.no_grad()
def evaluate(model):
    torch.manual_seed(0)
    items, clean = [], 0
    for product in [f"{n} {k}" for n in sorted(test_names) for k in kinds]:
        ids = torch.tensor([tok.encode(f"<user> Describe the {product} . <assistant>")] * 3)   # three answers each
        for row in model.generate(ids, 60, temperature=0.8, stop_id=end)[:, ids.shape[1]:].tolist():
            reply = tok.decode(row)
            clean += reply.endswith("<end>")
            items.append({"product": product, "text": reply.split("<end>")[0].strip()})
    flaws = [r["flaws"] for r in requests.post("https://api.gradient.test/v1/brand-check", json={"items": items}).json()["results"]]
    share = lambda test: sum(any(test(f) for f in fs) for fs in flaws) / len(flaws)
    return {"violations per answer": sum(map(len, flaws)) / len(flaws),
            "names another product": share(lambda f: f.startswith("doesn't name")),
            "repeats a word": share(lambda f: f.startswith("repeats")),
            "wrong or missing material": share(lambda f: "material" in f or "isn't used" in f),
            "missing repair promise": share(lambda f: f == "no repair promise"),
            "ends cleanly": clean / len(items)}

Step 5: Train candidate A and read the gates

Candidate A trains on the model's own best answers. Violations fall by 29%, and if that were the only number you looked at, you'd ship it. But it names the wrong product 9 points more often. Training on its own outputs strengthened its habits, including the bad ones.

curated = [(p["prompt"], p["chosen"]) for p in pairs if not p["chosen_flaws"] and p["prompt"].split()[2] not in held_back]
finetune(candidate, curated)
before, after = evaluate(baseline), evaluate(candidate)
print(f"candidate A: {len(curated)} curated answers\n")
print(f"{'':<28} {'baseline':>9} {'candidate':>10}")
for metric in before:
    print(f"{metric:<28} {before[metric]:>9.2f} {after[metric]:>10.2f}")
print()
for gate, ok in gates(before, after).items():
    print(f"{'PASS' if ok else 'FAIL'}  {gate}")
What it prints
candidate A: 61 curated answers

                              baseline  candidate
violations per answer             1.57       1.11
names another product             0.42       0.51
repeats a word                    0.07       0.12
wrong or missing material         0.30       0.21
missing repair promise            0.77       0.25
ends cleanly                      0.98       0.98

PASS  violations down at least 20%
FAIL  no guideline worse by > 5 points
PASS  at least 90% end cleanly

Step 6: Fix the data, not the settings

Candidate B keeps the same settings, test and gates, and only the data changes. It trains on the catalog's real descriptions, which are right about the facts, with the brand rule added to them. All three gates pass. It still names the wrong product in nearly half of answers, and that belongs in the record.

product = re.compile(r"^(?:Meet the|Our|The) ([A-Z][a-z]+) (.+?)(?:,| is| are) made")
rows = []
for d in docs:                                         # the catalog's real descriptions...
    name, kind = product.match(d).groups()
    if name in held_back:
        continue
    answer = d if "repair promise" in d else d + " Backed by our repair promise."   # ...plus the brand rule
    rows.append((f"Describe the {name} {kind} .", answer))

finetune(candidate, rows)
before, after = evaluate(baseline), evaluate(candidate)
print(f"candidate B: {len(rows)} catalog descriptions with the repair promise\n")
print(f"{'':<28} {'baseline':>9} {'candidate':>10}")
for metric in before:
    print(f"{metric:<28} {before[metric]:>9.2f} {after[metric]:>10.2f}")
print()
results = gates(before, after)
for gate, ok in results.items():
    print(f"{'PASS' if ok else 'FAIL'}  {gate}")
print("\nDecision:", "ship candidate B" if all(results.values()) else "do not ship")
What it prints
candidate B: 193 catalog descriptions with the repair promise

                              baseline  candidate
violations per answer             1.57       0.96
names another product             0.42       0.46
repeats a word                    0.07       0.11
wrong or missing material         0.30       0.31
missing repair promise            0.77       0.08
ends cleanly                      0.98       0.94

PASS  violations down at least 20%
PASS  no guideline worse by > 5 points
PASS  at least 90% end cleanly

Decision: ship candidate B

Step 7: Automate the decision and write it down

decision names the gates that failed, so a failure comes with a reason. model_card records the before and after for every metric, so the next person can see what was traded away. The example at the end uses made-up numbers that fail one gate, to show the message.

def decision(gate_results):
    failed = [name for name, ok in gate_results.items() if not ok]
    return "ship" if not failed else "do not ship: " + ", ".join(failed)

def model_card(name, base, metrics_before, metrics_after):
    return {"model": name, "base_model": base,
            "evaluation": {m: {"before": metrics_before[m], "after": metrics_after[m]} for m in metrics_before}}

before = {"violations per answer": 1.44, "wrong material": 0.22, "ends cleanly": 1.0}
after = {"violations per answer": 1.14, "wrong material": 0.32, "ends cleanly": 0.99}
g = gates(before, after, ["wrong material"])
print(g)
print(decision(g))
What it prints
{'improved': True, 'no_regression': False, 'clean': True}
do not ship: no_regression

Where to take it next

  • Add the gates to your release process, so a model can't ship without passing them.
  • Watch a sample of real conversations after release. Test prompts never cover everything.
  • Write down what the model is for, what it isn't for and where it still falls short.

Mistakes to watch for

  • Shipping a model because its headline metric improved.
  • Changing gate thresholds after seeing results.
  • Fixing a data problem with more training steps.
  • Treating a fine-tuned model as finished at release, without monitoring.
The complete program
import re
import requests
import torch
import torch.nn as nn
from tinygpt import load_pretrained

baseline, tok = load_pretrained("tiny-gpt-chat")
candidate, _ = load_pretrained("tiny-gpt-chat")
end = tok.token_id("<end>")
pairs = requests.get("https://api.gradient.test/v1/preferences").json()["data"]
docs = requests.get("https://api.gradient.test/v1/corpus").json()["text"].strip().split("\n")
kinds = sorted({" ".join(p["prompt"].split()[3:-1]) for p in pairs})
test_names = {"Kestrel", "Lark", "River", "Sierra", "Summit", "Willow"}
held_back = test_names | {"Alder", "Basin", "Cove", "Ember"}

def to_tensors(rows):
    enc = []
    for prompt, answer in rows:
        p, a = tok.encode(f"<user> {prompt} <assistant>"), tok.encode(answer) + [end]
        enc.append(((p + a)[:65], ([-100] * len(p) + a)[:65]))
    T = max(len(ids) for ids, _ in enc)
    return (torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in enc]),
            torch.tensor([lab + [-100] * (T - len(lab)) for _, lab in enc]))

def finetune(model, rows, steps=80):
    X, Y = to_tensors(rows)
    opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
    model.train()
    torch.manual_seed(1)
    for step in range(steps):
        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()
    model.eval()

@torch.no_grad()
def evaluate(model):
    torch.manual_seed(0)
    items, clean = [], 0
    for product in [f"{n} {k}" for n in sorted(test_names) for k in kinds]:
        ids = torch.tensor([tok.encode(f"<user> Describe the {product} . <assistant>")] * 3)   # three answers each
        for row in model.generate(ids, 60, temperature=0.8, stop_id=end)[:, ids.shape[1]:].tolist():
            reply = tok.decode(row)
            clean += reply.endswith("<end>")
            items.append({"product": product, "text": reply.split("<end>")[0].strip()})
    flaws = [r["flaws"] for r in requests.post("https://api.gradient.test/v1/brand-check", json={"items": items}).json()["results"]]
    share = lambda test: sum(any(test(f) for f in fs) for fs in flaws) / len(flaws)
    return {"violations per answer": sum(map(len, flaws)) / len(flaws),
            "names another product": share(lambda f: f.startswith("doesn't name")),
            "repeats a word": share(lambda f: f.startswith("repeats")),
            "wrong or missing material": share(lambda f: "material" in f or "isn't used" in f),
            "missing repair promise": share(lambda f: f == "no repair promise"),
            "ends cleanly": clean / len(items)}

def gates(before, after):
    guidelines = ["names another product", "repeats a word", "wrong or missing material", "missing repair promise"]
    return {"violations down at least 20%": after["violations per answer"] <= 0.8 * before["violations per answer"],
            "no guideline worse by > 5 points": all(after[g] - before[g] <= 0.05 for g in guidelines),
            "at least 90% end cleanly": after["ends cleanly"] >= 0.9}

product = re.compile(r"^(?:Meet the|Our|The) ([A-Z][a-z]+) (.+?)(?:,| is| are) made")
rows = []
for d in docs:                                         # the catalog's real descriptions...
    name, kind = product.match(d).groups()
    if name in held_back:
        continue
    answer = d if "repair promise" in d else d + " Backed by our repair promise."   # ...plus the brand rule
    rows.append((f"Describe the {name} {kind} .", answer))

finetune(candidate, rows)
before, after = evaluate(baseline), evaluate(candidate)
print(f"candidate B: {len(rows)} catalog descriptions with the repair promise\n")
print(f"{'':<28} {'baseline':>9} {'candidate':>10}")
for metric in before:
    print(f"{metric:<28} {before[metric]:>9.2f} {after[metric]:>10.2f}")
print()
results = gates(before, after)
for gate, ok in results.items():
    print(f"{'PASS' if ok else 'FAIL'}  {gate}")
print("\nDecision:", "ship candidate B" if all(results.values()) else "do not ship")

Write it yourself

In the lesson you write this from a starter, in your browser with nothing to install, and checks tell you what's wrong as you go. A free account opens it and all 244 lessons.

Create a free account