Index Ai Academy
Phase 6 · Modern LLM engineering · Adapting models

Fine-tune an open model

Run a real fine-tune and evaluate the result.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Each earlier lesson in this phase taught one piece. A real fine-tuning project is mostly about the parts around the training loop. You decide what "better" means. You build data you can trust. You measure honestly. Teams that skip those steps ship models that shine on their own training data and disappoint in real use. This lesson runs the whole workflow in your browser, in miniature, so you can repeat it on a real model later.

What you'll be able to do

  • Run a fine-tuning project end to end: metric, data, baseline, training, checkpoint choice, test
  • Curate training data by filtering model outputs with an automatic checker
  • Use a validation set to pick a checkpoint, and a separate test set to report the result
  • Adapt the same workflow to a real open model with LoRA on a GPU

What it covers

  1. The project
  2. Worked example: scoring four answers
  3. Step 2: split the data three ways
  4. Step 3: curate the training data
  5. Reading the log
  6. The same project on a real open model
  7. A checklist for real projects

The project

Picture a new shop assistant learning the store's style guide. You wouldn't judge them on the questions you drilled them on. You'd watch how they handle customers they've never met. A fine-tuning project works the same way.

Fine-tuning means training an existing model a little more, on your own examples, so it behaves the way you want. Here the model is the tiny chat model from earlier lessons. The goal is to make it follow the four brand guidelines from the RLHF lesson more often:

  • name the product that was asked about;
  • don't repeat words;
  • use a material the catalog uses for that kind of product;
  • mention the repair promise.

The project has five steps, and each gets its own part below:

  1. Define the metric. Decide how to score "better" before you touch the model.
  2. Split the data three ways: training, validation and test.
  3. Curate the training data. Keep only clean examples.
  4. Measure a baseline, then train, checking the validation score as you go.
  5. Test once, at the very end.

Step 1: decide what "better" means

Write the scoring rule first. If you choose it after you've seen results, it's too easy to pick whichever rule flatters your model.

Here the judge is the practice API's brand checker, POST /v1/brand-check. You send it some answers. For each one, it sends back a list of flaws: the guidelines that answer broke. An empty list means a clean answer. Each broken guideline is called a violation.

From those lists, the project works out two numbers. For both, lower is better.

  • The share of answers that break at least one guideline. This says how often something goes wrong.
  • The average number of violations per answer. This also says how much goes wrong. An answer with three problems counts three times here, but only once in the first number.

In plain words: for the first number, count the answers with any problem and divide by the number of answers. For the second, count all the problems and divide by the number of answers.

Worked example

Worked example: scoring four answers

The checker has judged four answers. Two are clean. One repeats a word. One forgets the repair promise and also uses the wrong material. Work out both scores.

# the problems the checker found in each of four answers (made-up)
flaws = [[], ["repeats a word"], ["no repair promise", "wrong material"], []]

share = sum(bool(f) for f in flaws) / len(flaws)       # answers with at least one problem
per_answer = sum(len(f) for f in flaws) / len(flaws)   # problems per answer, on average
print(f"{share:.0%} of answers break a guideline")
print(f"{per_answer:.2f} violations per answer")
It runs right here in your browser, with nothing to install.

Two of the four answers have at least one problem, so the share is 2 ÷ 4 = 50%. There are 0 + 1 + 2 + 0 = 3 problems in all, so the average is 3 ÷ 4 = 0.75 per answer. bool(f) is True for a list with anything in it, and Python counts True as 1. The project cell below ends its violations function with these same two lines of arithmetic.

Quick check
Five answers have 0, 1, 0, 3 and 1 violations. What are the two scores?
  1. 40% break a guideline; 1.00 violations per answer
  2. 60% break a guideline; 1.00 violations per answer
  3. 100% break a guideline; 1.00 violations per answer
  4. 60% break a guideline; 5 violations per answer
Show the answer

60% break a guideline; 1.00 violations per answer

Three of the five answers have a problem, so 3 ÷ 5 = 60%. The problems add up to 0 + 1 + 0 + 3 + 1 = 5, and 5 ÷ 5 = 1.00 per answer.

Step 2: split the data three ways

Think of a student getting ready for an exam. Practice questions are for learning. A mock exam tells them when they're ready. The real exam is sat once, and its score is the one that counts. Machine learning uses the same three sets:

  • The training set holds the examples the model learns from. These are the practice questions.
  • The validation set is for making choices during the project, such as when to stop training. This is the mock exam.
  • The test set is used once, at the very end, to report the result. This is the real exam.

The project splits by product name. Six names go to the test set: Kestrel, Lark, River, Sierra, Summit and Willow. Four go to validation: Alder, Basin, Cove and Ember. Every other product is for training.

Why split by name instead of at random? In real use, the model will meet products it has never seen. Testing it on products it trained on would make it look better than it is.

Quick check
What does this print?
total = 500
splits = {"train": 400, "val": 50, "test": 50}
print(sum(splits.values()) == total)
  1. True
  2. 400
  3. 500
  4. False
Show the answer

True

400 + 50 + 50 is 500, so the two sides match and it prints True. Every example goes in exactly one set, so that nothing leaks between them.

Quick check
Why does the project use a validation set separate from the test set?
  1. To make the training data larger
  2. Because the test set is too small to train on
  3. Validation and test sets must have different formats
  4. The validation set is used to pick the checkpoint, so it can't also give an unbiased final score
Show the answer

The validation set is used to pick the checkpoint, so it can't also give an unbiased final score

Any set you make decisions with becomes optimistic, because you kept whatever did best on it. The test set is touched once, at the end.

Step 3: curate the training data

To curate data means to choose it with care, the way a museum curator chooses what goes on show. Fewer, cleaner examples beat more, noisier ones.

The preference dataset from the preference-data and RLHF lessons gives you a head start. For each prompt, the model wrote four answers, and the dataset kept the best of the four as the "chosen" answer. But the best of four can still break a guideline. So the project adds one more filter. It keeps a chosen answer only if the checker found no flaws at all, and only if it's about a training product.

This trick has a name: rejection-sampling fine-tuning. You sample several answers, reject the ones a checker fails, and fine-tune on the rest. It's a cheap way to get clean examples.

Steps 4 and 5: baseline, training, test

A baseline is the score before you change anything. Without one, you can't tell whether training helped.

A checkpoint is a saved copy of the model's weights at one moment in training. It's like a save point in a video game. The project trains for 120 steps. Every 40 steps, it scores the model on the validation products. Whenever the validation score improves, it saves a checkpoint. At the end, it loads the best checkpoint and scores the test products, once.

The cell runs all five steps. It takes a while, and the Run button counts the seconds. (torch here is the course's in-browser PyTorch subset.)

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

model, tok = 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"}
val_names = {"Alder", "Basin", "Cove", "Ember"}

# 1. the metric: violations of the brand guidelines on products the model never trained on
@torch.no_grad()
def violations(model, names):
    torch.manual_seed(0)
    items = []
    for product in [f"{n} {k}" for n in sorted(names) for k in kinds]:
        ids = torch.tensor([tok.encode(f"<user> Describe the {product} . <assistant>")])
        reply = tok.decode(model.generate(ids, 60, temperature=0.8, stop_id=end)[0, ids.shape[1]:].tolist())
        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"]]
    return sum(bool(f) for f in flaws) / len(flaws), sum(len(f) for f in flaws) / len(flaws)

# 2-3. curated training data: answers that pass every guideline, for training products only
good = [p for p in pairs if not p["chosen_flaws"] and p["prompt"].split()[2] not in test_names | val_names]
rows = []
for p in good:
    prompt, answer = tok.encode(f"<user> {p['prompt']} <assistant>"), tok.encode(p["chosen"]) + [end]
    rows.append(((prompt + answer)[:65], ([-100] * len(prompt) + answer)[:65]))
T = max(len(ids) for ids, _ in rows)
X = torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in rows])
Y = torch.tensor([lab + [-100] * (T - len(lab)) for _, lab in rows])
print(f"{len(rows)} curated training examples")

# 4. baseline, then training with a validation check every 40 steps
test_before = violations(model, test_names)
best = (violations(model, val_names), 0, copy.deepcopy(model.state_dict()))
print(f"step   0: validation {best[0][0]:.0%} of answers break a guideline ({best[0][1]:.2f} per answer)")
model.train()
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
torch.manual_seed(1)
for step in range(1, 121):
    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()
    if step % 40 == 0:
        model.eval()
        val = violations(model, val_names)
        model.train()
        print(f"step {step:>3}: validation {val[0]:.0%} of answers break a guideline ({val[1]:.2f} per answer)   training loss {loss.item():.3f}")
        if val[1] < best[0][1]:
            best = (val, step, copy.deepcopy(model.state_dict()))

# 5. keep the best checkpoint and test once
model.load_state_dict(best[2])
model.eval()
test_after = violations(model, test_names)
print(f"\nbest checkpoint: step {best[1]}")
print(f"test, before: {test_before[0]:.0%} of answers break a guideline ({test_before[1]:.2f} per answer)")
print(f"test, after:  {test_after[0]:.0%} of answers break a guideline ({test_after[1]:.2f} per answer)")
Go deeper What the code does, part by part
  • The metric. violations asks the model to describe every product in a set of names. It writes up to 60 tokens per answer, at temperature 0.8. The seed is fixed at 0, so each call samples the same way. It sends all the answers to the checker and returns the two scores.
  • The split. A prompt reads "Describe the name kind .", so word number 2 (counting from 0) is the product name. That's what p["prompt"].split()[2] picks out.
  • The curation. good keeps pairs whose chosen answer has an empty chosen_flaws list and whose product is neither a test nor a validation name.
  • The rows. Each training row is the prompt followed by the answer. The labels are -100 on the prompt's positions, so the loss only counts the reply, as in the SFT lesson. Rows are cut to 65 tokens. Then the inputs are padded with the end token and the labels with -100, so every row has the same length and the padding isn't learned.
  • The training. Each of the 120 steps picks 16 random rows and takes one step of AdamW, with a learning rate of 0.0003 (written 3e-4).
  • The checkpoints. The best checkpoint is the one with the fewest violations per answer on validation. copy.deepcopy saves an independent copy of the weights. A plain reference would keep changing as training went on.
Try it now

The same checklist works on a tiny GPT and on a real open model.

Reading the log

Read the log the way a practitioner would.

  • Only 61 clean examples survived curation, from 293 pairs, because the checker is strict. Even so, 61 examples were enough to change behavior. That echoes the LIMA finding from the base-vs-chat lesson: a small set of carefully chosen examples can go a long way.
  • Training loss fell the whole time, but validation didn't. Validation improved up to step 80 and got worse by step 120. The model was starting to memorize its 61 examples. Without the validation set, you'd have shipped the step-120 model.
  • The test result is the honest number. It was measured once, on products never used for training or for choosing the checkpoint. Violations per answer fell from 1.44 to 0.92, about a third fewer. The share of answers with any violation fell from 85% to 53%.

The DPO lesson reached a similar improvement with a different method. When you have clean examples, plain SFT on curated data is often the simpler choice. Preference methods earn their extra complexity when you have comparisons ("this answer beats that one") rather than perfect examples.

Quick check
Curation left only 61 clean examples. What should you expect if you keep training on them for many steps?
  1. The model starts to memorize them: training loss keeps falling while the validation score gets worse
  2. Training loss starts to rise
  3. Nothing changes, because 61 examples is plenty for any amount of training
  4. The validation score keeps improving for as long as training loss falls
Show the answer

The model starts to memorize them: training loss keeps falling while the validation score gets worse

That's what the log showed. Training loss fell the whole time, but validation was best at step 80 and worse by step 120. The validation check is what catches it.

The same project on a real open model

On your own GPU, or a free notebook GPU from Google Colab, the workflow is the same. Only the tools change.

  1. Save the curated chats as JSON lines in the messages format. That's one conversation per line, written as a list of messages, each with a role (user or assistant) and its text.
  2. Split by product into train, validation and test files.
  3. Fine-tune a small open model with LoRA, from the LoRA lesson.
# pip install transformers trl peft datasets
from datasets import load_dataset
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer

data = load_dataset("json", data_files={"train": "brand_train.jsonl", "validation": "brand_val.jsonl"})
trainer = SFTTrainer(
    model="Qwen/Qwen2.5-0.5B-Instruct",
    train_dataset=data["train"],
    eval_dataset=data["validation"],
    peft_config=LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", task_type="CAUSAL_LM"),
    args=SFTConfig(output_dir="brand-lora", num_train_epochs=3, learning_rate=2e-4,
                   eval_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True),
)
trainer.train()
trainer.save_model("brand-lora")       # saves just the adapter, a few megabytes

Here's what the main settings mean:

  • model names a small open chat model with about half a billion weights.
  • LoraConfig trains small LoRA updates instead of every weight. r=16 is the rank, the size of each small update. target_modules="all-linear" adds an update to the linear layers in every block of the model.
  • num_train_epochs=3 means three passes over the training data. The learning rate 2e-4 is 0.0002.
  • eval_strategy and save_strategy set to "epoch" mean: after each pass, measure the validation loss and save a checkpoint.
  • save_model saves only the adapter, the small LoRA update, which is a few megabytes.
Quick check
What does LoraConfig set up here?
  1. A test set
  2. A larger model
  3. A new tokenizer
  4. Small trainable tables on top of frozen weights
Show the answer

Small trainable tables on top of frozen weights

Only the thin LoRA tables train, which is why this fits on a modest GPU.

load_best_model_at_end does what the cell did by hand. It keeps the checkpoint with the best validation score. But that score is the validation loss, and loss is only a stand-in for what you care about. A low loss means the model finds the validation answers likely. It doesn't prove the model's own answers follow the guidelines. So also score each saved checkpoint with your real metric on the validation prompts. That could be a guideline checker or an LLM judge. Then run the chosen checkpoint once on the test prompts.

Go deeper What lora_alpha does

LoRA multiplies its small update by lora_alpha divided by r before adding it to the original weights. Here that's 32 ÷ 16 = 2. Alpha is often set to the rank or twice the rank. The LoRA paper added this scaling so that trying a different r needs less retuning of the learning rate.

Quick check
load_best_model_at_end=True keeps the checkpoint with the lowest validation loss. What else should you do before reporting a result?
  1. Score each saved checkpoint with your real metric on the validation prompts, then test the chosen one once
  2. Nothing: the lowest validation loss is the final result
  3. Pick the checkpoint with the lowest training loss instead
  4. Score every checkpoint on the test prompts and keep the best
Show the answer

Score each saved checkpoint with your real metric on the validation prompts, then test the chosen one once

Validation loss is only a stand-in for the real goal. Choose with your real metric on validation. Scoring checkpoints on the test set would turn it into a second validation set.

A checklist for real projects

  1. Write the metric first. Make it automatic if you can: a checker, a classifier, or an LLM judge with a rubric whose scores you've checked against people's.
  2. Get a baseline from a well-prompted model, including a larger hosted model. If prompting meets the bar, stop there.
  3. Split by the thing you'll see new examples of, such as new products, new customers or new months. A split like this, not a random one, makes the test reflect real use.
  4. Curate. Fewer, cleaner examples beat more, noisier ones. Read a sample of your data.
  5. Watch validation during training and keep the best checkpoint. Expect a small dataset to overfit within a few passes.
  6. Test once. Compare against the baseline on every criterion you care about, and read some outputs yourself.
  7. Plan for upkeep. Retrain when the base model, the guidelines or the inputs change.

For marketers: this checklist works as a brief for an agency or an internal team proposing a fine-tune. Ask to see the metric, the baseline, the split, and the test result before and after.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Training loss keeps falling, but validation violations rise after step 80. What should you do?

  1. Keep the step-80 checkpoint: later steps are memorizing the small training set
  2. Keep training until training loss reaches zero, as violations will fall again by then
  3. Report the validation score at step 80 as your final result, as it's the best you saw
  4. Add the validation products to the training data, so that the model can learn them too

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson