Fine-tune a small model
A small open model adapted with LoRA to a task you define, measured against the two honest baselines — the base model, and the base model with a good prompt — and an adapter you serve.
Most fine-tunes that get run should not have been run, because nobody measured a well-prompted base model first. The training is the easy part and takes an afternoon; the dataset and the measurement are the work, and they are the part that separates someone who has fine-tuned a model from someone who has followed a fine-tuning tutorial. "When would you fine-tune rather than prompt?" is asked in almost every AI and ML interview, and the answer that lands is one with three numbers and a confidence interval behind it rather than an opinion.
Have these behind you first
You can start without them and look things up as you go. It will take longer and you will understand less of what you typed.
Define the task, then build the dataset
You build: A narrow task statement and a JSONL dataset in one fixed format, split into train and held-out with no overlap between them.
If a line here loses you, this is where it is taught
- Fine-tuning: Prompt, retrieve or fine-tune
Which tasks a fine-tune can actually move, so you pick one that is not already solved by a prompt.
- Data quality & labelling: Where ground truth comes from
Where the labels come from, and what makes a label you can train on rather than argue about.
- Evaluation: Building the dataset
Building the held-out set so it represents the traffic you care about rather than the examples that were easy to collect.
This stage is six hours of a twenty-six hour project, and in real work the ratio is worse. Fine-tuning is a data project with a training script attached. If you find yourself reaching for the trainer on hour two, you have skipped the part that decides the result.
Pick a task that is narrow, has a stable output format, and can be graded by code. Classifying support tickets into one of eight categories. Extracting five fixed fields from an invoice. Rewriting a product description into a house style with fixed section headings. Avoid anything whose correctness is a matter of taste, because you will not be able to score it and the project collapses at stage five.
Write the task statement down first, in one paragraph, including the exact output format. Everything downstream — the prompt baseline, the training format, the grader — has to agree with it, and writing it after the fact means they will not.
import hashlib, json, random, re
from collections import Counter
def normalise(s: str) -> str:
"Lowercase, collapse whitespace, drop punctuation. Only for comparison."
return re.sub(r"[^a-z0-9 ]", "", re.sub(r"\s+", " ", s.lower())).strip()
rows = [json.loads(line) for line in open("raw.jsonl")]
# One schema, enforced now rather than discovered by the trainer at 3am.
clean, seen = [], set()
for r in rows:
if not r.get("input") or not r.get("output"):
continue
key = hashlib.sha1(normalise(r["input"]).encode()).hexdigest()
if key in seen: # exact-after-normalisation duplicate
continue
seen.add(key)
clean.append({"input": r["input"].strip(), "output": r["output"].strip(), "key": key})
print(len(rows), "->", len(clean), "after dedupe")
print(Counter(r["output"] for r in clean).most_common()) # label balance
random.Random(0).shuffle(clean)
held_out, train = clean[:150], clean[150:]
# The check that matters: nothing in held-out may appear in train.
assert not ({r["key"] for r in held_out} & {r["key"] for r in train})
for name, part in (("train", train), ("held_out", held_out)):
with open(name + ".jsonl", "w") as f:
for r in part:
f.write(json.dumps({"input": r["input"], "output": r["output"]}) + "\n")If the same example is on both sides, every number after this is fiction
Scraped and synthetic data is full of near-duplicates: the same question asked twice with different punctuation, the same document in two exports. Hash on normalised text, not raw text, or you will keep pairs that differ by a trailing space and report a score the model earned by memorisation. If your fine-tune beats the prompt baseline by a suspiciously large margin, check this first.
Read fifty examples by hand before you go on. Not skim — read. You are looking for labels you disagree with, because a disagreement rate above a few percent puts a ceiling on every score in this project, and you would rather know the ceiling now than mistake it for a model failure later.
Done when
At least 500 examples in one schema, split into train and held-out by a rule you can state in a sentence, with an assertion in your code that the near-duplicate overlap between the two splits is exactly zero.
Two baselines, before you train anything
You build: A grading harness and two scores from it: the base model with a minimal prompt, and the base model with the best prompt you can write.
If a line here loses you, this is where it is taught
- Prompting: A prompt is a specification
Writing the strong prompt properly, because a weak baseline is how a pointless fine-tune gets declared a success.
- Prompting: Examples, and when they earn their cost
Few-shot examples are the cheapest competitor your fine-tune has, and the one it most often loses to.
- Evaluation: Grading: code first, judges second
Grading with code before reaching for a judge model, so the number means the same thing every time you run it.
Build the grader once and use it for every model in this project, unchanged. The moment the grader differs between baseline and fine-tune, the comparison is worthless, and it is very easy to do by accident — a stripped whitespace here, a lowercase there.
import json, random
held_out = [json.loads(l) for l in open("held_out.jsonl")]
def grade(prediction: str, expected: str) -> float:
"Exact match after the same normalisation used everywhere else."
return float(normalise(prediction) == normalise(expected))
def score(predict, examples, n_boot=1000):
marks = [grade(predict(e["input"]), e["output"]) for e in examples]
mean = sum(marks) / len(marks)
# Bootstrap the interval. Without it you cannot tell a real gain from noise,
# and on 150 examples the noise is roughly plus or minus four points.
rng = random.Random(0)
boots = []
for _ in range(n_boot):
sample = [marks[rng.randrange(len(marks))] for _ in marks]
boots.append(sum(sample) / len(sample))
boots.sort()
return mean, boots[int(0.025 * n_boot)], boots[int(0.975 * n_boot)]Now the two baselines. The first is the base model told what to do in one line. The second is the base model with the prompt you would ship if fine-tuning were not an option: the task statement, the output format spelled out, and four or five examples chosen to cover the cases the model gets wrong.
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16,
device_map="auto")
def ask(system: str, user: str, max_new_tokens: int = 64) -> str:
msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt")
# Greedy: the baseline must be reproducible, so no sampling anywhere.
out = model.generate(ids.to(model.device), max_new_tokens=max_new_tokens,
do_sample=False, pad_token_id=tok.eos_token_id)
return tok.decode(out[0, ids.shape[-1]:], skip_special_tokens=True).strip()
MINIMAL = "Classify the support ticket."
STRONG = open("prompt_v3.txt").read() # task statement + format + 5 examples
print("naive ", score(lambda x: ask(MINIMAL, x), held_out))
print("prompted", score(lambda x: ask(STRONG, x), held_out))The baseline you skip is the one that would have won
Spend real time on the strong prompt — an hour, not five minutes — and iterate it against the train split, never the held-out set. A fine-tune measured against a lazy prompt is the most common way a team spends a fortnight of GPU time to move a number that a better prompt would have moved on Tuesday. If you are not slightly worried that the prompt is too good, it is not good enough yet.
Done when
Two numbers on the held-out set from one grader function, each written down next to the exact prompt text that produced it, and a bootstrap interval on each.
Set up LoRA, and find out what the adapter is
You build: A LoRA-wrapped model where under one percent of parameters are trainable, and an explanation of where those parameters sit.
If a line here loses you, this is where it is taught
- Transfer learning: Starting from someone else's training run
Why adapting an existing training run beats starting from noise, and what is being preserved when you freeze the base weights.
- Fine-tuning: Running a fine-tune properly
What the LoRA hyperparameters mean and which of them actually change the result.
- PyTorch: Shapes, broadcasting and the bugs they cause
The adapter is two small matrices per targeted layer; the arithmetic only makes sense if you can see their shapes.
A full fine-tune updates every weight and needs optimiser state for every weight, which is where the memory goes. LoRA freezes the base model and learns a small low-rank update for chosen layers: instead of changing W, you learn A and B and use W plus B times A. Train A and B, keep W as it came.
That is the whole idea. For a 4096 by 4096 projection at rank 16, A is 16 by 4096 and B is 4096 by 16: about 131,000 parameters instead of 16.8 million, a factor of 128. The saving is why this runs on one consumer GPU.
from peft import LoraConfig, get_peft_model
config = LoraConfig(
r=16, # rank: the width of the bottleneck
lora_alpha=32, # the update is scaled by alpha / r, so this is 2x
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
# Attention projections are the usual choice. Adding the MLP projections
# ("gate_proj", "up_proj", "down_proj") helps when the task needs new
# knowledge rather than a new format, and roughly triples adapter size.
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
peft_model = get_peft_model(model, config)
peft_model.print_trainable_parameters()
# trainable params: 9,232,384 || all params: 1,552,946,688 || trainable%: 0.5945
# Check it by hand for one layer before you believe the number.
for name, p in peft_model.named_parameters():
if p.requires_grad:
print(name, tuple(p.shape))
break
# base_model.model.model.layers.0.self_attn.q_proj.lora_A.default.weight (16, 1536)alpha over r is the only scale that matters
The update is multiplied by alpha divided by r, so doubling r while leaving alpha fixed halves the effective strength of everything you learn. People raise r hoping for a stronger fine-tune and get a weaker one. Change them together, or hold alpha at twice r and treat r as capacity alone. If raising r made your results worse, this is why.
Rank 8 to 16 is enough for teaching a format or a style. Rank 64 and the MLP projections is where you go if the task needs facts the base model does not have — though if that is your situation, retrieval is usually the cheaper answer and stage six will say so.
Done when
print_trainable_parameters reports under 1% trainable, and you can state, for one targeted module, the shapes of A and B and the parameter count they come to — and check it against the printed total by hand.
Train it
You build: A training run that produces an adapter, with the config logged and the eval loss curve readable.
If a line here loses you, this is where it is taught
- Hyperparameter tuning: Tuning, in order of what matters
Which knobs to touch first here, which is learning rate and epochs and almost nothing else.
- Experiment tracking: Knowing which run produced which result
You will run this a dozen times; without the config attached to each score you cannot say which run produced the adapter you kept.
- PyTorch: When the loss will not go down
The specific checks when the loss sits flat, which for a LoRA run is usually the prompt template or a frozen adapter.
The training script is the shortest part of this project. Its one real requirement is that the text the model trains on is byte-for-byte the text it will see at inference: same chat template, same system prompt, same spacing. A mismatch here produces a model that scores worse than the base and gives no hint why.
from datasets import load_dataset
from trl import SFTTrainer, SFTConfig
data = load_dataset("json", data_files={"train": "train.jsonl",
"eval": "held_out.jsonl"})
def to_chat(row):
# The same three messages the baseline used. If this drifts from ask(),
# the fine-tune is being trained for a format it will never be asked in.
return {"messages": [
{"role": "system", "content": STRONG_SYSTEM_ONLY},
{"role": "user", "content": row["input"]},
{"role": "assistant", "content": row["output"]},
]}
data = data.map(to_chat, remove_columns=["input", "output"])
args = SFTConfig(
output_dir="runs/lora-r16-lr2e4",
num_train_epochs=3, # more than 3 on a small set memorises it
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # effective batch 16
learning_rate=2e-4, # 10-100x a full fine-tune: only A and B move
lr_scheduler_type="cosine",
warmup_ratio=0.03,
bf16=True,
logging_steps=10,
eval_strategy="epoch",
save_strategy="epoch",
report_to="wandb",
seed=0,
)
trainer = SFTTrainer(model=peft_model, args=args,
train_dataset=data["train"], eval_dataset=data["eval"])
trainer.train()
trainer.save_model("adapter/") # writes only the adapter, tens of MBOverfit three examples first
Before the real run, train on three examples for fifty steps and check the model reproduces them exactly. If it cannot memorise three examples, the adapter is not attached, the labels are being masked out, or the template is wrong — and you will find that in four minutes rather than after an hour of training that ends in a flat number you cannot explain.
A falling eval loss is not the result. It is a sign that training is functioning, nothing more: loss is measured on the training format and your task is measured by the grader, and the two come apart routinely. Do not report the loss. Report stage five.
Done when
Eval loss falls and then flattens; the run's config and curve are in a tracker; and reloading the saved checkpoint and re-running your grader gives the identical score twice in a row.
Score all three on the held-out set
You build: One script that takes a model and prints a score with an interval, run against the naive baseline, the prompted baseline and the fine-tune.
If a line here loses you, this is where it is taught
- Evaluation: Is the difference real? Statistics for evals
Whether the gap between two rows is real or is 150 examples of noise, which on this size of set it very often is.
- Evaluation: Error analysis: read traces before you write evals
Reading the errors, which tells you what to fix in a way the aggregate number never does.
- LLM-as-judge: Grading with a model
Only if your task cannot be graded by string comparison — and then how to check the judge agrees with you before you trust it.
One script, three invocations, one grader. Take the model as a command-line argument so there is no possibility of the baseline and the fine-tune following different code paths.
# evaluate.py --model base --prompt minimal
# --model base --prompt strong
# --model adapter/ --prompt strong
import argparse, json
from peft import PeftModel
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True) # "base" or a path to an adapter
ap.add_argument("--prompt", choices=["minimal", "strong"], required=True)
a = ap.parse_args()
m = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16,
device_map="auto")
if a.model != "base":
m = PeftModel.from_pretrained(m, a.model)
m.eval()
system = MINIMAL if a.prompt == "minimal" else STRONG
mean, lo, hi = score(lambda x: ask(system, x), held_out)
print(json.dumps({"model": a.model, "prompt": a.prompt,
"score": round(mean, 3), "ci": [round(lo, 3), round(hi, 3)]}))A plausible result looks like this, and it is worth sitting with before you assume yours will look better.
base + minimal prompt 0.41 [0.33, 0.49]
base + strong prompt 0.78 [0.71, 0.85]
LoRA fine-tune 0.83 [0.76, 0.89]Overlapping intervals are not a win
If the fine-tune's interval contains the prompted baseline's mean, you have not shown that the fine-tune is better. You have shown that 150 examples cannot tell them apart. The honest moves are to enlarge the held-out set or to report the result as inconclusive — not to quote the two means side by side and let the reader infer a gap that your data does not support.
Then read twenty errors. Group them by cause and count the groups: wrong format, right category but wrong label, refused to answer, truncated. A fine-tune that fixes formatting and changes nothing about the judgements is a real but narrow result, and knowing that is worth more than the aggregate number.
Done when
A three-row table with a bootstrap interval on each row, produced by one script with the model as an argument, plus twenty of the fine-tune's errors read by hand and sorted into named groups.
Decide honestly whether it was worth it
You build: A short written verdict comparing the three options on quality, cost and latency, ending in a decision.
If a line here loses you, this is where it is taught
- Fine-tuning: Prompt, retrieve or fine-tune
The decision rule this stage is applying, now with your own numbers in it rather than a general argument.
- Cost & latency engineering: Find out where the money goes
Costing each option properly, including the tokens the strong prompt spends on every single call.
- Product judgement for AI: Knowing whether the AI feature actually helps
Turning a five-point score difference into a claim about whether anything downstream changes.
This stage is the reason the project exists. You now have the one thing almost nobody who fine-tunes has: the counterfactual. Use it.
The comparison is not only quality. A fine-tune usually shortens the prompt, which cuts input tokens on every call; it also means you now own a model artefact, a serving path, and a retraining obligation the day the base model is deprecated. The prompt costs more per call and nothing per month. Put real numbers on both.
- Quality: the three scores and their intervals. Do the intervals separate?
- Cost per thousand calls: the strong prompt's few-shot examples are input tokens paid every call; the fine-tune's prompt is short but you are renting a GPU whether or not anyone calls it.
- Latency: measure p50 and p95 for each, not the mean.
- Maintenance: who retrains this when the base model is deprecated, and where does the dataset live.
- The next base model: a model one generation newer, prompted, may beat your fine-tune outright. Check before committing.
"The fine-tune was not worth it" is a completed project
If the intervals overlap and the prompt is cheaper to operate, the correct engineering output is: we measured, prompting wins, we are not shipping the adapter. That is a better interview answer than a shipped fine-tune with no baseline, because it demonstrates the judgement the role is actually testing. Write it up exactly as you would a positive result. Do not go back and weaken the prompt.
Whichever way it lands, finish stage seven anyway. Serving the adapter takes three hours and you want to have done it once, before a day when it matters.
Done when
Under 300 words naming all three scores with intervals, the cost per thousand calls for each option, the latency of each, and a decision — including "ship the prompt" if that is what the numbers say.
Merge, save and serve the adapter
You build: The adapter loaded behind an HTTP endpoint, either merged into the base weights or served alongside them.
If a line here loses you, this is where it is taught
- Deployment & serving: Serving open models yourself
Serving an open model properly, including how adapters are loaded at request time rather than baked in.
- Quantization & inference optimisation: What makes inference expensive
What the served model costs in memory and what merging changes about it.
- PyTorch: Checkpoints and reproducibility
Saving so that the thing you load next month is provably the thing you measured.
There are two ways to ship this and the choice is a real one. Merged: fold B times A into the base weights and get a normal model, with no adapter overhead at inference and a full-size artefact to store. Unmerged: keep the base model loaded once and swap small adapters per request, which is how you serve five fine-tunes on one GPU.
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, "adapter/").merge_and_unload()
merged.save_pretrained("merged-model/")
tok.save_pretrained("merged-model/")
# Then serve it like any other model:
# vllm serve ./merged-model --port 8000
#
# Or keep the base weights shared and load the adapter by name:
# vllm serve Qwen/Qwen2.5-1.5B-Instruct --enable-lora \
# --lora-modules tickets=./adapterMerge in the dtype you trained in
Loading the base in float16 to merge a bfloat16 adapter, or merging into a 4-bit quantised base, silently changes the weights and your served model scores lower than the one you evaluated. If the endpoint disagrees with your evaluation script on a fixed greedy prompt, check the dtype before anything else. That single fixed-prompt comparison is the cheapest regression test this project has, so keep it.
Finally, write the README: the task statement, how the dataset was built and split, the three numbers with intervals, the verdict, and the command that reproduces the evaluation. The adapter is the small artefact here. The measured comparison is the one worth showing someone.
Done when
A POST to a local endpoint returns, for a fixed prompt with greedy decoding, the same string your evaluation script produced offline. The saved adapter directory is under 200 MB.
What you have at the end
A repository containing a dataset you built and deduplicated, an evaluation harness that scores any model the same way, three numbers on a held-out set with intervals, a LoRA adapter of a few tens of megabytes, and a written verdict. You can honestly say you have fine-tuned a model, and — more usefully in an interview — say what it bought you over prompting, in the units of the task.
The other projects