Bonus dive
Fine-tuning: A Guided Deep Dive
A hands-on playground for learning fine-tuning, teaching a model new behavior from examples, from the ground up. You'll build a training set from scratch, validate it, run a fine-tune job, and then do the part everyone skips: prove the fine-tuned model actually beat the base model on data it never saw. No framework magic, just enough code to see how each step works.
The twist that makes this repo work: it runs completely offline on a mock
provider, with no API key. Real fine-tuning costs money and takes minutes-to-hours,
which is a terrible way to learn the shape of it. So the default PROVIDER=mock
ships a tiny deterministic "model" and a simulated fine-tune lifecycle
(upload → job → poll → use) that runs in-process, in under a second, for $0. Flip
one env var and the exact same code runs a real, paid OpenAI fine-tune.
This repo is standalone: it teaches everything it needs on its own. It is the hands-on version of the RAG deep dive's "RAG, fine-tuning, or something else?" section, and Section 7 borrows the win-rate method from the Evals deep dive, but its code depends on neither.
Like its siblings, it's meant to be walked through. Each section ends with something to run, and every section runs offline and free on the mock. EXERCISES.md has a predict-then-run prompt for each one.
0. The one big idea
Fine-tuning changes how the model behaves, not what it knows. You teach a default behavior with examples, and then you must prove it beat your baseline.
That's the whole repo. RAG and long context change what's in the context window (knowledge); fine-tuning changes how the model responds by default (format, tone, a narrow skill), taught only by showing it input→output examples. So the training set is the product; most of the work is building and validating it. And because "it feels better" is worth nothing, the discipline that makes fine-tuning real is the last step: measure the tuned model against the base model on a held-out set, and ship only if it wins. Hold onto that and none of this feels complicated.
1. Setup (5 minutes)
# 1. Create an isolated Python environment
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 2. Install dependencies (the default mock stack needs only python-dotenv)
pip install -r requirements.txt
# 3. Copy the env file: the default runs keyless (no API key needed)
cp .env.example .env
# (Real provider instead of the mock? Its key goes in your OS keychain,
# not .env: see ../SECRETS.md, then run scripts as `secrun python ...`.)
# 4. Confirm everything is wired up (makes no API call, costs nothing)
python check_setup.py
The default stack is offline and free; the whole learning arc runs on a mock provider with no key. Pick a real provider only when you want to run an actual (paid) fine-tune:
PROVIDER |
What runs | Key needed |
|---|---|---|
mock (default) |
Deterministic in-process model + a simulated fine-tune lifecycle. The entire repo, free. | none |
openai |
Real chat + the real fine-tuning API. Running a job costs real money and is opt-in (--real). |
OPENAI_API_KEY |
claude |
Chat only. Anthropic fine-tuning is a limited/enterprise program, not self-serve. Usable as a base/teacher model. | ANTHROPIC_API_KEY |
You can complete every section for $0. The mock simulates uploading, training, polling, and serving a fine-tuned model. The only thing it can't do is spend your money. The real OpenAI path exists so you can see the identical code hit a real provider, but you never need it to learn the ideas.
2. When to fine-tune: vs. prompt, few-shot, RAG
python examples/01_when_to_finetune.py
The most valuable fine-tuning skill is knowing when not to. Fine-tuning is the slow, expensive, provider-specific option, and reaching for it first is the most common and costly mistake. One rule resolves most cases:
- RAG / long context change what's in the context → reach for it when you need facts that change or must be cited.
- Fine-tuning changes how the model behaves by default → reach for it when you need the same format/tone/skill every time, or lower cost/latency on a fixed, high-volume task.
- Tools / agents change what the model can do → reach for them when it must act or fetch live data.
The example walks a handful of real scenarios and lands each on the right tool. The headline rule of thumb, don't fine-tune first, is here for a reason: a better prompt or a few examples solves most of what people think needs training.
3. The dataset is the product
python examples/02_dataset_format.py
A fine-tune learns the behavior its examples demonstrate, so the examples are the product. Hosted fine-tuning uses JSON Lines: one conversation per line, each ending in the assistant turn you want the model to learn to produce.
{"messages": [
{"role": "system", "content": "You are Acme's support triage assistant. Reply EXACTLY as 'category: <...> | reply: <...>'."},
{"role": "user", "content": "i forgot my password"},
{"role": "assistant", "content": "category: account | reply: Use Settings > Security > Reset password."}
]}
The example builds one from scratch, loads the hand-made
datasets/support_train.jsonl, and splits it into
train/validation. The whole repo's running example is a support-triage
assistant taught to answer in one rigid house format: exactly the kind of
narrow, repeated behavior fine-tuning is good at.
4. Validate your data before you pay to train on it
python examples/03_validate_data.py
A fine-tune job is slow and (on a real provider) costs money. The cheapest way to waste neither is to check the dataset first. This runs every offline check on the hand-made set (schema, duplicates, class balance, and a token + cost estimate) then runs them again on a deliberately broken set so you can watch the checks catch real problems. Garbage in, garbage model: most bad fine-tunes are bad datasets that nobody validated.
5. Run a fine-tune job: upload → create → poll → use
python examples/04_run_finetune.py
Every hosted fine-tune follows the same lifecycle:
- upload the training file (
files.create) - create a job from it (
fine_tuning.jobs.create), this is what trains - poll the job until it's done (
fine_tuning.jobs.retrieve) - use the new model id
The example runs this two ways with nearly identical code, which is the point:
- Default (
PROVIDER=mock) simulates the whole lifecycle in-process, deterministically, in under a second, for $0. - Opt-in real run (
PROVIDER=openaiand--real) uploads your file and starts a genuine job. It prints a cost warning and asks for confirmation first, and it can take a while.
6. Use the fine-tuned model
python examples/05_use_model.py
Once a job succeeds, the provider hosts your model under a new id (e.g.
ft:gpt-4o-mini:...). Using it is just a normal chat call with that id; there's
no special API. The example asks the base and the fine-tuned model the same
questions side by side: the base handles the one or two categories it happens to
know but rambles and ignores the house format on the rest; the tuned model snaps
every one into the trained category: ... | reply: ... shape. That behavior
change, taught only by examples, is the whole idea.
7. Did it actually help?
python examples/06_did_it_help.py
The punchline. A fine-tune you think is better is worth nothing; the only
thing worth shipping is one you can prove beat your baseline on data the training
never saw. This points the Evals deep dive's
method at one decision, base vs. fine-tuned on the held-out
datasets/support_eval.jsonl, with two numbers:
- accuracy: % of held-out examples where the predicted category matches gold.
- win-rate: pairwise, the way
evals/07_pairwise.pydoes it: show a judge both answers and tally which is better. (Here the "judge" is an offline format rubric so it runs free; in production you'd use an LLM-as-judge.)
If the tuned model doesn't beat the baseline, the honest move is to not ship it and go back to the dataset.
8. Hyperparameters & reading the loss curve
python examples/07_hyperparameters.py
You don't need many knobs, and the defaults are usually fine, but three matter:
- n_epochs: how many times the model sees the whole dataset. Too few and it hasn't learned; too many and it overfits. The tell: validation loss stops dropping (or rises) while training loss keeps falling.
- learning_rate_multiplier: how big each step is. Higher is faster but can overshoot; lower is steadier but slower.
- batch_size: examples per update; mostly a speed/stability tradeoff. Leave it on auto unless you have a reason.
The example renders the (simulated) loss curve and shows what overfitting looks like, so the abstract knobs become a picture.
9. Distillation: train a small model on a strong model's outputs
python examples/08_distillation.py
The most common production shape of fine-tuning isn't hand-labeling. It's
distillation: take a big, expensive, smart model (the teacher) that already
does your task well, run it over a pile of inputs, and use its answers as training
data for a small, cheap, fast model (the student). The labels write themselves,
which is what makes a set of hundreds or thousands of examples cheap to build. The
example builds a distillation dataset (on the mock, the "teacher" is just the mock
in the house format; on a real provider you'd point it at gpt-4o/claude), then
validates it, proving it's a normal training file you can feed straight into
Section 5.
10. Beyond hosted: open-weight fine-tuning & LoRA/PEFT
python examples/09_open_weights_lora.py
Everything so far was hosted fine-tuning: hand a provider a JSONL file, they
train and host the result. The other world is open-weight fine-tuning: download
a model whose weights are public (Llama, Mistral, Qwen, Gemma) and train it
yourself on your own (or rented) GPU. This section is conceptual; running open
weights needs a GPU and a different stack (PyTorch + Hugging Face
transformers/peft), its own deep dive, but it explains the two ideas you need
(full fine-tuning vs. LoRA/PEFT) and shows that your dataset is the same
asset either way: the file you built in Sections 3–4 is exactly what an
open-weight trainer consumes. To actually run open weights locally, see the
Local Models deep dive.
11. Preference tuning: learning from comparisons (DPO/RLHF)
python examples/10_preference_tuning.py
Every example so far taught by demonstration: show the one right answer and
imitate (that's SFT). But some goals have no single right answer: "be warmer," "be
more concise," "refuse this more firmly." You can't write THE correct reply, but you
can say which of two replies is better. Preference tuning learns from exactly
that, pairs of {prompt, chosen, rejected}. RLHF trains a reward model from the
rankings; DPO (the modern shortcut) trains directly on the pairs. This section is
conceptual: it shows the data shape, where the pairs come from (the Production dive's
👍/👎 flywheel is the cheapest source), and how it's run (Hugging Face trl's
DPOTrainer, usually as a LoRA). The discipline is unchanged: still gate on a held-out
eval before shipping.
12. Reinforcement fine-tuning (RFT): learning from a grader
SFT learns from demonstrations (one right answer to imitate). Preference tuning learns from comparisons (A is better than B). Reinforcement fine-tuning learns from a grader: the model generates an answer, a scoring function rates it, and training pushes the model toward higher-scoring answers. There's no labeled target and no pair, just a way to score an attempt. This section is conceptual (no runnable example): graders are expensive and fiddly, and it's the most complex rung here.
The whole game is the grader: a function score(prompt, answer) -> number. It can be
a hard programmatic check (do the unit tests pass? does the JSON validate against the
schema? does the math answer match?) or a model-as-judge scoring against a rubric. This
is the "reinforcement learning from verifiable rewards" that trains modern reasoning
models: when correctness is checkable but you can't write down THE one right output,
a grader beats labeled data.
When a grader beats labeled pairs: reach for RFT when (a) success is easy to verify but hard to demonstrate (there are many correct programs, proofs, or plans, so you can check one but not enumerate them), (b) writing thousands of gold answers or preference pairs is more expensive than writing one scoring function, or (c) you're optimizing a multi-step behavior where only the outcome is gradeable. Stick with SFT when you can cheaply demonstrate the target, and preference tuning when quality is a matter of taste a judge can rank but not score objectively.
The catch, beyond cost: a model optimizing a score will hack a weak grader, passing the letter of the check while missing the point (the eval-gaming failure the Evals dive warns about, now inside the training loop). So the grader itself needs the same scrutiny as an eval, and the shipping discipline is unchanged: gate on a held-out set the grader never saw.
The capstone: finetune_run.py
Everything assembled into one command that does the real workflow:
validate → tune → eval-gate vs. baseline → ship ONLY if it wins
# Offline, free, the full arc on the mock:
python hands_on/finetune_run.py
# Point at a different training file (e.g. the distilled set from Section 9):
python hands_on/finetune_run.py --train datasets/support_distilled.jsonl
# Require the tuned model to clear a minimum win-rate to "ship":
python hands_on/finetune_run.py --min-winrate 0.6
# The real, PAID path (opt-in, confirmed):
PROVIDER=openai secrun python hands_on/finetune_run.py --real
The gate is the discipline the whole repo is about: a fine-tune ships only when it
provably beats the base model on a held-out set. If it doesn't, the gate says so
and exits non-zero, the same shape as a CI eval gate. Read
hands_on/finetune_run.py: it's just the library
(validate + mock_tuner + evaluate) wired to a CLI.
Should I fine-tune at all? (the decision)
Fine-tuning is rarely the first thing to reach for. Match the problem to the tool before you train anything:
| Your problem | Reach for | Why |
|---|---|---|
| The model needs facts that change, or must cite sources | RAG / long context | You're changing what it knows, not how it behaves |
| You need a consistent format, tone, or narrow skill, every time | Fine-tuning | You're teaching behavior, and that's what training adjusts |
| A few examples in the prompt already get it right | Few-shot prompting | Cheaper, instant, no training loop, so try this first |
| It must act or fetch live data | Tools / agents | Capability, not behavior or knowledge |
| Lower latency/cost on a fixed, high-volume task | Distill + fine-tune a smaller model | Push known-good behavior into a cheaper model |
Two rules of thumb. Don't fine-tune first. It's the slow, expensive, provider-specific option; a better prompt or RAG solves most of what looks like a training problem. And never fine-tune on vibes. The only way to know it helped is to measure it against a baseline (Section 7). They're also complementary, not either/or: a common production shape is fine-tune for behavior + RAG for knowledge in the same app.
Where to go next
You've taught a model a behavior and proved it stuck. The frontier is more of the same idea, with more control:
- Preference tuning (DPO/RLHF): covered conceptually in §11 above; train on comparisons ("A is better than B"), not just demonstrations, to shape subtler behavior.
- Reinforcement fine-tuning (RFT): covered conceptually in §12 above; train against a grader (a verifiable check or a rubric judge) when success is checkable but not easily demonstrated, which is how reasoning models are trained.
- Open-weight LoRA in practice: actually run Section 10 on a GPU with
transformers/peft/trl; pairs with the Local Models deep dive. - Bigger, cleaner datasets: the real lever is almost always more and better data, not more epochs. Active learning: mine the cases your model gets wrong.
- Continuous fine-tuning: re-tune on production traffic as it drifts, gated by evals each time.
- Function-calling / structured-output fine-tunes: teach a small model to emit reliable tool calls or JSON for a fixed schema.
From teaching code to production
The teaching shortcuts that make this repo free and fast are exactly what you'd replace once a fine-tuned model is on a live request path:
| This repo's teaching shortcut | In production |
|---|---|
| Simulated tune on the mock provider | A real job on real hardware, tracked by id, with the trained model pinned in config |
| Tiny hand-made dataset (dozens of rows) | Hundreds–thousands of cleaned, deduplicated, deliberately-balanced examples |
| Offline format rubric stands in for a judge | A real LLM-as-judge (debiased, both orderings) and/or human review |
| Eval gate runs once, by hand | The gate runs in CI on every candidate model; nothing ships unless it clears the bar |
| The base model is fixed | A model registry + the option to re-tune as the base model and your traffic change |
| Cost is estimated, never spent | A training-cost budget and per-call cost tracking once the tuned model serves traffic |
The general ops machinery (observability, cost, reliability, caching, guardrails, prompt versioning, eval gates) is built from scratch and wired into one running app in Production (#8 in the series), which also runs offline on a mock provider.
File map
check_setup.py ← run first: Python, packages, provider, key
README.md ← this guide
EXERCISES.md ← predict-then-run prompts, one per section
finetune/ ← the from-scratch library (read it!)
providers.py ← chat for mock / openai / claude (one interface)
mock_tuner.py ← the offline simulated fine-tune: upload→job→poll→use
dataset.py ← load / split the chat-JSONL training data
databuild.py ← build training examples (incl. the distillation set)
validate.py ← offline checks: schema, dupes, balance, token+cost
evaluate.py ← base vs. tuned: accuracy + pairwise win-rate
datasets/
support_train.jsonl ← the hand-made training set (the running example)
support_eval.jsonl ← a HELD-OUT eval set (none of it is in training)
support_distilled.jsonl ← built by examples/08 (git-ignored; regenerate it)
hands_on/
finetune_run.py ← capstone: validate → tune → eval-gate → ship-if-wins
examples/
01_when_to_finetune.py ← when NOT to fine-tune (offline)
02_dataset_format.py ← the chat JSONL format; build + split (offline)
03_validate_data.py ← catch a bad dataset before training (offline)
04_run_finetune.py ← upload→create→poll→use (mock; --real for OpenAI)
05_use_model.py ← base vs. tuned, side by side (offline on mock)
06_did_it_help.py ← prove it beat the baseline on held-out data (offline)
07_hyperparameters.py ← epochs, LR, batch size + the loss curve (offline)
08_distillation.py ← build a training set from a teacher model (offline)
09_open_weights_lora.py ← LoRA/PEFT & open weights, explained (offline)
10_preference_tuning.py ← DPO/RLHF: learning from {chosen, rejected} pairs (offline)
Troubleshooting
Run python check_setup.py first; it catches most problems. Then, by symptom:
| What you see | What it means / the fix |
|---|---|
PROVIDER=openai needs OPENAI_API_KEY |
You picked a real provider. Either add the key, or set PROVIDER=mock to stay offline and free. |
A real fine-tune won't start without --real |
Working as intended; the paid path is opt-in. Add --real (and confirm the cost prompt) only when you mean it. |
PROVIDER=claude can't run a fine-tune |
Working as intended; Anthropic fine-tuning isn't self-serve. Use mock to learn, or openai for a real job. |
| Validation reports duplicates / imbalance | That's the check doing its job. Fix the dataset before training; that's far cheaper than a wasted run. |
| The tuned model didn't beat the baseline | The honest outcome sometimes. Don't ship it; improve the dataset (more, cleaner, better-balanced examples) and re-measure. |
ModuleNotFoundError (openai / anthropic) |
Only needed for real providers. On the default mock stack you need only python-dotenv. |
SyntaxError / odd type errors on startup |
You're likely on Python 3.9 or older; this repo needs 3.10+. check_setup.py confirms your version. |
Still stuck? Every file is small and self-contained. Open it, read the docstring at the top, and run it. finetune/mock_tuner.py is the whole "fine-tune lifecycle" in one readable file.
The series
This is one of sixteen standalone, hands-on deep dives into building with LLM APIs: eight core, plus eight bonus dives. Each one stands on its own, with its own setup, examples, and capstone, and they all share the same house style: provider-agnostic where it makes sense, built from scratch (no frameworks), offline-first examples, and a real capstone. Do them in any order; this sequence builds naturally:
- OpenAI API: the API from zero
- Claude API: the same ideas, the Anthropic way
- Prompt Engineering: shape model behavior with better prompts
- RAG: answer questions over your own documents
- Evals: measure whether a change actually helps
- Agents: give a model tools and a loop so it can act
- Prompt Injection & Guardrails: attack and defend all of the above
- Production: operate one app end to end
Bonus dives, standalone and slotting in where they're most useful:
- Context Engineering: manage what's in the window: memory, compaction, assembly
- Multimodal: images & audio, not just text
- Fine-tuning: teach a model new behavior by example
- MCP: serve tools, data & prompts to any LLM over a standard protocol
- Local Models: run open-weight models on your own machine
- Agent Harnesses: build on the loop: hooks, permissions, sandboxing, subagents
- Realtime Voice: low-latency speech-to-speech agents
- Observability: watch a running app over time: drift, quality, alerting, the flywheel
Fine-tuning is a bonus dive in the series. It slots most naturally after RAG (#4), whose "RAG, fine-tuning, or something else?" decision this repo makes hands-on, and leans on Evals (#5) to prove a tune actually helped.