# Run a Real Fine-tune

Course: [Fine-tuning & Model Customization](https://www.devtutorials.ai/learn/fine-tuning) · Section: Train and Operate
Source: https://www.devtutorials.ai/learn/fine-tuning/train-and-operate/run-a-real-fine-tune

> Use the starter kit to go from a helpdesk export to a scored QLoRA adapter: config, data prep, template check, training with TRL, generation and a CI-ready score.

## Run a Real Fine-tune

Everything so far ran in your browser. Now do it for real: a real base model, a real GPU, a real adapter.

The course has a **starter kit**: a small Python project that runs every step from the helpdesk export to a ship decision. Watch the steps first.

Three of the seven steps need no GPU at all. You can run them on the fixtures right now and see real output.

**Simple lesson:** a fine-tune is a short pipeline of scripts, not one magic command.

### A Real-World Example

Think about cooking from a recipe card for the first time.

You read the whole card, lay out the ingredients, taste as you go, and write down what you changed. Next time, you can repeat it, or know exactly what to change.

A fine-tuning run is the same: a config (the recipe), prepared data (the ingredients), checks along the way (tasting), and run notes (what you changed).

**Simple lesson:** write the recipe down, so the next run is not a guess.

## What You Will Learn

In this lesson, you will learn:

1. What is in the **starter kit**, and which steps need a GPU.
2. How to set up a run in **config.yaml**.
3. How **batch size**, **gradient accumulation** and **steps** fit together.
4. How to read a **training log** and keep the **best checkpoint**.
5. How to **score** base and fine-tune, and use the **exit code** as a gate.

**Before you start**

- You need Python 3.10+ for the data and scoring steps. For training: an NVIDIA GPU. A free Colab T4 works for the kit's default 1.5B model.
- Download the kit from the Fine-tune Lab's **Export** tab. Start with its README.
- Library names change between versions. If a setting is rejected, check the TRL, PEFT and Transformers docs for your installed version.

## TL;DR

```bash
python -m unittest test_kit.py                                    # no GPU
python prepare_data.py fixtures/tickets-export.json --out data    # no GPU
python check_template.py                                          # no GPU (downloads the tokenizer)
python generate.py --base-only --out preds/base.jsonl             # GPU
python train_qlora.py                                             # GPU
python generate.py --adapter runs/ticket-adapter-v1/best --out preds/tuned.jsonl   # GPU
python score.py --eval data/eval.jsonl --base preds/base.jsonl --tuned preds/tuned.jsonl   # no GPU
```

## What Is in the Kit

| File | What it does |
|---|---|
| `config.yaml` | Every setting: base model, LoRA, training, generation |
| `prepare_data.py` | Export to train, val and eval JSONL, redacted and split by customer |
| `check_template.py` | One example through the chat template; end token; lengths |
| `train_qlora.py` | QLoRA SFT, loss on the answer only, best checkpoint kept, run notes |
| `generate.py` | Same greedy settings for base and fine-tune |
| `score.py` | Exact-match, failure types, paired 95% interval, ship rule as exit code |
| `merge_adapter.py`, `serve_vllm.sh` | Two ways to serve |
| `finetune_colab.ipynb` | The same steps as notebook cells |
| `test_kit.py`, `fixtures/` | Tests and fictional data, so you can try it without a GPU |

**Run the no-GPU steps first.**

Unzip the kit and run `python -m unittest test_kit.py`, then `prepare_data.py` and `score.py` on the fixtures. In two minutes you know Python works, the data steps behave, and what a passing and a failing score look like. Only then start a GPU.

## The Config

One file holds every setting, so a run can be repeated and compared:

```yaml
base_model: Qwen/Qwen2.5-1.5B-Instruct
max_seq_length: 512
load_in_4bit: true        # QLoRA
lora_r: 16
lora_alpha: 32
learning_rate: 0.0002
num_train_epochs: 3
per_device_train_batch_size: 4
gradient_accumulation_steps: 4
eval_steps: 50
temperature: 0            # for the eval: greedy, repeatable
```

Swap `base_model` for the one you chose in the Choosing the Method lesson, after checking its licence.

## Batches, Accumulation and Steps

- The GPU looks at a **batch** of examples, then works out how to nudge the weights.
- If a big batch does not fit in memory, **gradient accumulation** adds up several small batches before one nudge.
- **Effective batch** = batch size × accumulation steps. One nudge is one **optimiser step**.

```ts runnable title="Steps and time for one run"
// How many optimiser steps, and how long, for one run?
const rows = 560;
const batch = 4;          // per_device_train_batch_size
const accumulation = 4;   // gradient_accumulation_steps
const epochs = 3;
const secondsPerStep = 13; // measure this from your first 20 steps

const effective = batch * accumulation;
const perEpoch = Math.ceil(rows / effective);
const total = perEpoch * epochs;
console.log(`effective batch: ${effective}`);
console.log(`steps: ${perEpoch} per epoch, ${total} in total`);
console.log(`time: about ${Math.round((total * secondsPerStep) / 60)} minutes`);
```

```csharp runnable title="Steps and time for one run"
// How many optimiser steps, and how long, for one run?
int rows = 560;
int batch = 4;          // per_device_train_batch_size
int accumulation = 4;   // gradient_accumulation_steps
int epochs = 3;
int secondsPerStep = 13; // measure this from your first 20 steps

int effective = batch * accumulation;
int perEpoch = (int)Math.Ceiling((double)rows / effective);
int total = perEpoch * epochs;
Console.WriteLine($"effective batch: {effective}");
Console.WriteLine($"steps: {perEpoch} per epoch, {total} in total");
Console.WriteLine($"time: about {Math.Round(total * secondsPerStep / 60.0, MidpointRounding.AwayFromZero)} minutes");
```

Expected output:

```text
effective batch: 16
steps: 35 per epoch, 105 in total
time: about 23 minutes
```

With 560 rows, an effective batch of 16 gives **35 steps** per epoch and **105 steps** in total. With `eval_steps: 50`, you would only get two validation checks, so lower it to about 20 for a run this short.

## Read the Log

When training ends, `train_qlora.py` saves the best checkpoint to `runs/ticket-adapter-v1/best` and writes `run_notes.json`: the config, the best validation loss, the row counts and every library version. Commit it next to the dataset card.

**Experiment tracking** = a tool that records every run's settings and curves, so you can compare them later. Set `report_to: wandb` or `mlflow` in the config if your team uses one.

## Score It

`score.py` needs no GPU. Run it on the kit's fixture predictions to see real output:

```bash
python score.py --eval fixtures/eval.jsonl --base fixtures/predictions-base.jsonl --tuned fixtures/predictions-tuned.jsonl
```

```text
cases: 100
base exact-match: 76.0%
fine-tune exact-match: 96.0%
change: +20.0 points (95% interval +10.0 to +29.0)

fine-tune failures:
  extra text after the JSON: 1 (E-008)
  never stops (text after the JSON): 1 (E-042)
  template tokens in output: 1 (E-024)
  truncated or invalid JSON: 1 (E-067)

(per-intent table here)

SHIP RULE: PASS (task slice only: add your general and safety slices before release)
```

The exit code is 0 when the ship rule passes and 1 when it fails, so the same command can block a merge in CI. The When the Fine-tune Misbehaves lesson explains each failure type.

## What It Costs

GPU time for a small LoRA is cheap. Count every run, not just the final one:

```ts runnable title="GPU cost for a year of runs"
// What does an experiment cost? Count every run, not just the final one.
const gpuPerHour = 1.2;   // illustrative price for a rented 24 GB GPU; check current prices
const runMinutes = 23;
const runs = [
  { why: "first try", count: 1 },
  { why: "learning rate sweep (3 values)", count: 3 },
  { why: "fix a template bug, re-run", count: 1 },
  { why: "final run with best settings", count: 1 },
  { why: "re-trains over a year", count: 3 },
];

let total = 0;
for (const r of runs) {
  const cost = (r.count * runMinutes / 60) * gpuPerHour;
  total += cost;
  console.log(`${r.why.padEnd(32)} $${cost.toFixed(2)}`);
}
console.log(`${"GPU total for the year".padEnd(32)} $${total.toFixed(2)}`);
console.log("People time is the big cost: compare with the first lesson's ownership numbers.");
```

```csharp runnable title="GPU cost for a year of runs"
// What does an experiment cost? Count every run, not just the final one.
double gpuPerHour = 1.2;   // illustrative price for a rented 24 GB GPU; check current prices
int runMinutes = 23;
var runs = new List<Run>
{
    new("first try", 1),
    new("learning rate sweep (3 values)", 3),
    new("fix a template bug, re-run", 1),
    new("final run with best settings", 1),
    new("re-trains over a year", 3),
};

double total = 0;
foreach (var r in runs)
{
    double cost = (r.Count * runMinutes / 60.0) * gpuPerHour;
    total += cost;
    Console.WriteLine($"{r.Why.PadRight(32)} ${cost:F2}");
}
Console.WriteLine($"{"GPU total for the year".PadRight(32)} ${total:F2}");
Console.WriteLine("People time is the big cost: compare with the first lesson's ownership numbers.");

record Run(string Why, int Count);
```

Expected output:

```text
first try                        $0.46
learning rate sweep (3 values)   $1.38
fix a template bug, re-run       $0.46
final run with best settings     $0.46
re-trains over a year            $1.38
GPU total for the year           $4.14
People time is the big cost: compare with the first lesson's ownership numbers.
```

- **Where it runs**: Colab for a first try; a company GPU or cloud account for real data. Customer data may not be allowed on personal accounts.
- **Budget**: GPU hours are small. Engineer days are the real cost.
- **Base model licence** and **data sign-off**, before the first run, not after.
- **Run notes and dataset card** committed with every adapter version.

**Do's and Don'ts**

- **Do** run the tests and score.py on the fixtures before touching a GPU.
- **Do** keep every setting in config.yaml and commit it.
- **Do** generate base and fine-tune predictions with the same settings.
- **Do** keep run_notes.json with each adapter.
- **Don't** put customer data in a personal notebook account.
- **Don't** change settings by editing scripts: change the config.
- **Don't** skip check_template.py.

## Common mistakes

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Training before checking | "The scripts work, start the GPU." | Tests, prepare, check_template first. | A wasted run on a formatting bug. |
| No baseline predictions | "We know the base model is worse." | Generate base predictions with the same settings. | No honest before/after. |
| Different decoding settings | "Sampling makes it look nicer." | Greedy for both, temperature 0. | The comparison measures luck. |
| Settings in code | "I tweaked the script a bit." | One config file, committed. | Nobody can repeat the good run. |
| Ignoring eval_steps | "Default is fine." | Enough validation checks for the run's length. | The best checkpoint is never seen. |

## Check Your Understanding

## Exercises

## What to Remember

- The **starter kit** runs the whole pipeline; data and scoring need **no GPU**.
- Keep every setting in **config.yaml**, and commit **run_notes.json**.
- **Effective batch** = batch × accumulation; count **steps** to set eval_steps.
- Generate **base and fine-tune** with the **same settings**.
- **score.py**'s exit code is your **ship gate**.

Next: **Fine-tune with a Hosted API**, the same pipeline on a provider's GPUs, driven from TypeScript or C#.
