ML EngineerAI Engineer26h

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.

Stage 1 of 7, 6h

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

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")
Deduplicate before splitting, never after. Splitting first and deduplicating each side separately leaves the overlap intact.

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.

Stage 2 of 7, 4h

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

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.

Stage 3 of 7, 3h

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

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.

Stage 4 of 7, 4h

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

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 MB

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

Stage 5 of 7, 4h

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

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]
Five points of gain, and the intervals overlap heavily. This is the normal outcome, not a failure of your training.

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.

Stage 6 of 7, 2h

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

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.

Stage 7 of 7, 3h

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

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=./adapter

Merge 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