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.
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}")
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 cleanlyStep 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")
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 BStep 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))
{'improved': True, 'no_regression': False, 'clean': True}
do not ship: no_regressionWhere 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.