Align It: Preference Tuning with DPO (Fine-Tuning in Production, Module 8)
This is Module 8 of Fine-Tuning in Production, a free 15-module course that takes one open model from raw data to a deployed, published tool-calling specialist on the Hugging Face Hub. Start at Module 1 or browse the full syllabus.
Your model knows what good looks like. It doesn’t know what better looks like.
In Module 7 you proved it. Anvil — your Qwen3-4B base model, supervise-fine-tuned on tool-calling demonstrations — beats the raw base model on every metric that matters: it emits valid JSON tool calls, it picks the right function name, it fills in the right arguments. The base model couldn’t do any of that. The numbers are in the table; SFT works.
And yet. On some prompts Anvil still hesitates. Two tools are plausible — a search_notes and a search_emails — and it picks the less relevant one. Or it gets the call right but wraps it in a paragraph of friendly preamble — “Sure! I’d be happy to help with that. Let me look that up for you…” — when the only thing your runtime wants is the call. The behavior isn’t wrong, exactly. It’s just not the one you’d have chosen.
Here’s why, and it’s the whole module in one sentence: SFT only ever showed Anvil good answers. It never showed it a bad one. Supervised fine-tuning minimizes a next-token loss on demonstrations — it teaches the model to reproduce targets. It has no notion of “better than.” It can’t learn to prefer the concise call over the chatty one, or the relevant tool over the plausible-but-wrong one, because it was never shown the two side by side and told which one wins.
To teach that, you need to show the model both — a chosen answer and a rejected one for the same prompt — and optimize it to prefer the chosen. That’s preference tuning, and in 2026 the default method is not the heavy RLHF-PPO pipeline of 2022. It’s DPO — Direct Preference Optimization — a single, stable, supervised-style loss with no reward model and no online reinforcement-learning loop. This module aligns Anvil to prefer the right tool call, on a free Colab T4, and then re-runs Module 7’s eval to prove the alignment actually helped.
In this module
You’ll learn how to:
- Explain what SFT cannot teach — imitation has no notion of “better than” — and why preference tuning is the step that shapes behavior after SFT (it doesn’t add knowledge, and it doesn’t replace SFT).
- Contrast RLHF-PPO (the InstructGPT pipeline: SFT → reward model → online PPO loop) with DPO, and explain why DPO became the 2026 default: no separate reward model, no online RL rollouts, one stable loss, cheaper and more reproducible.
- Build preference data — turn your tool-calling fixture into
(prompt, chosen, rejected)triples, a valid concise call aschosenversus a degraded one asrejected— and know where pairs come from in real projects. - Train Anvil with the stable
DPOTrainer+DPOConfig: start from the SFT checkpoint, let TRL manage the frozen reference model, tunebeta, and read the DPO loss and reward margins — TRL v1 idioms only (processing_class, nevertokenizer=). - Re-evaluate with Module 7’s
eval.py(DPO-vs-SFT on the held-out, plus an anti-forgetting glance) and diagnose the classic DPO failure modes (too-highbeta, a drifting reference, reward hacking toward verbosity).
You’ll build: Anvil now learns to prefer the right tool call, not just imitate one. You’ll build (prompt, chosen, rejected) triples from the fixture, add anvil/train_dpo.py (the stable DPOTrainer with DPOConfig, starting from your SFT checkpoint with a frozen reference model and a tuned beta), then re-run Module 7’s eval.py to show the DPO-vs-SFT delta — all on a free Colab T4.
Theory areas covered:
- T8 — Preference tuning (DPO) — 11% of the course. T8 is shared with Module 9: this module owns the DPO half (the method, its founding paper, the reference model,
beta); Module 9 owns the family — ORPO, KTO, SimPO — and the decision tree.
This module also leans hard on two areas you’ve already built: T3 (SFT) — DPO starts from your SFT checkpoint, and the reference model is that checkpoint, frozen — and T7 (evaluation) — we re-run eval.py unchanged to measure the gain, never assume it.
Prerequisites: Modules 1–7. Specifically: the pinned stack from Module 1, config.get_model_and_tokenizer(tier="qlora") from Module 5, the SFT checkpoint from Module 3, the tool-calling dataset from Module 2, and — most of all — eval.py from Module 7, which we re-execute. For the lab: a free Colab/Kaggle T4 (16 GB) is the default path (a QLoRA DPO run on a 4B model fits comfortably); a local GPU with ≥ 16 GB works too. Have HF_TOKEN set (Module 1).
Where you are
From now on, every module marks where you are with ✅ (done), 👉 (you’re here), and ⬜ (ahead):
✅M1–M7 — baseline, data, SFT, LoRA, QLoRA, Unsloth/Axolotl, and task-grounded evaluation. Anvil is a working SFT model with a proven base-vs-fine-tuned table.👉M8 — Align It: Preference Tuning with DPO. Anvil learns to prefer the right call.⬜M9 — The DPO Family: ORPO, KTO, SimPO. The rest of the preference family, and which to pick.⬜M10 — Teach It to Reason: GRPO and Verifiable Rewards. RL with a checkable reward.⬜M11–M15 — merging, quantization, serving, publication, and the capstone.
Before this module, Anvil imitates good tool calls (SFT) but still hesitates between plausible tools and rambles before the call. After it, Anvil prefers the valid, concise call — aligned with DPO. That DPO checkpoint becomes the starting point for the preference family (Module 9), for reasoning RL (Module 10, GRPO), and it gets re-evaluated at every stage and replayed in the capstone (Module 15).
What SFT can’t teach: imitation versus preference
Go back to what SFT actually does, because the gap lives there. Supervised fine-tuning minimizes a next-token cross-entropy loss over demonstrations: for each training example, the model is nudged to assign higher probability to the exact tokens of the target completion. For Anvil that target is a valid <tool_call>{...}</tool_call> string (or a clean refusal on negatives). Run enough of those and the model learns the format and the mapping — given this request and these tools, produce that call.
What SFT never does is show the model a bad answer labeled as bad. Every gradient it ever sees points toward “make this good answer more likely.” There is no contrast, so there is no notion of better than. And that’s exactly the capability Anvil is missing in those two failure cases from the intro:
- Two plausible tools. SFT taught Anvil what a
search_notescall looks like and what asearch_emailscall looks like. It never taught it that, for this prompt, the first is preferable to the second — because it only ever optimized one target at a time, never a ranking. - Verbose preamble. SFT’s gold targets are concise calls, so concise calls are likely. But a chatty completion that contains the right call is also fairly likely — and SFT has no mechanism to say “the concise one is better.” Both look like things it has seen.
So SFT teaches format; it does not teach preference. To teach preference you supply, for the same prompt, a chosen completion (the one you prefer) and a rejected completion (the one you don’t), and you optimize the model to assign more probability to chosen than to rejected. That comparison — the thing SFT structurally lacks — is what preference tuning adds. It’s how you shape behavior after SFT.
Side by side, the two objectives line up like this — and note that DPO does not replace SFT, it refines its checkpoint:
| SFT | DPO | |
|---|---|---|
| Objective | Reproduce the target tokens (next-token cross-entropy) | Prefer chosen over rejected (a contrastive log-ratio loss) |
| Data | Demonstrations — one gold completion per prompt | (prompt, chosen, rejected) pairs — two completions per prompt |
| What it learns | The format and the mapping (request + tools → call) | The preference — “this completion is better than that one” |
| What it does NOT do | No notion of “better than” — never sees a labeled bad answer | Adds no knowledge; doesn’t replace SFT — it refines the SFT checkpoint |
The historical frame: RLHF-PPO
Preference tuning isn’t new. The technique that put it on the map is RLHF — Reinforcement Learning from Human Feedback — as deployed in InstructGPT (arXiv 2203.02155, the paper behind the original ChatGPT alignment). RLHF-PPO is a three-stage pipeline, and it’s worth seeing in full because DPO is best understood as the thing that collapses it:
- SFT. Fine-tune the base model on demonstrations (you already did this in Module 3).
- Reward model. Collect human preference comparisons (humans rank pairs of outputs), then train a separate model — the reward model — to predict which output a human would prefer. This is a second neural network, trained and stored.
- PPO. Optimize the SFT model (now the policy) with PPO — Proximal Policy Optimization, an online reinforcement-learning algorithm — to maximize the reward model’s score, while a KL penalty keeps the policy from drifting too far from the SFT reference.
It works, and it produced the first wave of aligned assistants. But look at what it costs. You train and hold in memory an extra reward model. You run an online RL loop — the policy generates rollouts, they get scored, gradients flow — which is notoriously unstable and hyperparameter-sensitive (reward hacking, mode collapse, KL blowups). It’s hard to reproduce and expensive to run. In 2026, presenting RLHF-PPO as “the default alignment method” is simply out of date — and in TRL, PPOTrainer has moved to trl.experimental.ppo to reflect exactly that (more on the freshness twist below). Here, PPO is historical context — never code you run.
⚠️ Common misconception: “DPO replaces SFT” / “DPO teaches the model new facts.”
Two false twins, and they’re the most important thing in this module to get right.
(a) “DPO replaces SFT” — no. DPO starts from the SFT checkpoint and refines it. Without a decent SFT first, there’s no baseline behavior to align — and, as you’ll see, the reference model that DPO needs literally is the SFT model, frozen. No SFT, no anchor, no DPO.
(b) “DPO teaches new facts or skills” — no. Preference tuning shapes behavior: it nudges the model toward preferring the better of the answers it already knows how to produce. It does not inject knowledge. If Anvil has never seen a tool schema for currency conversion, DPO won’t teach it one — that’s the job of data and SFT (and for live facts, RAG — see Module 1).
Say it as a single rule: DPO shapes behavior after SFT; it doesn’t add knowledge and it doesn’t replace SFT.
So the question DPO answers is: can we get RLHF’s preference-shaping without the reward model and without the online RL loop? Yes — and that’s the next section.
DPO: preference tuning without a reward model
DPO — Direct Preference Optimization (arXiv 2305.18290, NeurIPS 2023) — is the result that made preference tuning cheap and stable. Its central insight is a piece of math, and you don’t need the derivation to use it, only the intuition:
The reward model that RLHF trains has a known analytical solution in terms of the policy itself. So you can skip training the reward model and skip the online RL loop, and optimize the policy directly on the preference pairs — with a single classification-style loss.
That’s the “Direct” in Direct Preference Optimization: no intermediary. Concretely, DPO compares the policy’s log-probabilities on chosen and rejected to those of a frozen reference model, and pushes them apart in the right direction:
- it increases
log π(chosen) − log π_ref(chosen)— make the chosen answer more likely than the reference would, and - it decreases
log π(rejected) − log π_ref(rejected)— make the rejected answer less likely than the reference would.
π is the policy (the model you’re training); π_ref is the reference (frozen). The loss is a logistic/classification loss over the difference of those two log-ratios, scaled by a hyperparameter beta. No reward model is ever instantiated; no completions are sampled during training. It’s an offline loss over a fixed dataset of pairs — which is why it trains as stably as SFT does.
The frozen reference model π_ref
The reference model is not an implementation detail — it’s the load-bearing part of why DPO is stable, so it gets its own paragraph. π_ref is, in the standard setup, the SFT model itself, frozen (its weights never receive gradients). It’s the anchor: the DPO loss only ever measures how far the trained policy has moved relative to this fixed point. Without it, “make chosen more likely” has no scale — the model could wander arbitrarily far from everything it learned in SFT, degenerating into nonsense that happens to score well on the contrast. The reference is the structural equivalent of PPO’s KL penalty, except it’s baked directly into the loss, with no RL machinery around it.
There’s a practical wrinkle that matters enormously on a free GPU. Naively, a reference model is a second full copy of the model in memory — fine on an A100, fatal on a 16 GB T4. But Anvil trains with LoRA/QLoRA (Module 5): the policy is a base model with a trainable adapter on top. In that case TRL doesn’t load a second model at all — it uses the base model with the adapter disabled as the reference (turn the adapter off for the reference forward pass, on for the policy forward pass). One set of base weights, two forward passes. That’s what keeps DPO on a 4B model comfortably inside a free T4. (Verify this behavior against the TRL version you install; as of trl 1.6.0, June 2026, PEFT runs use the adapter-disabled base as the reference automatically — you don’t pass a reference model at all.)
beta — the one knob that matters
beta (default 0.1 in DPOConfig — as of trl 1.6.0, June 2026) controls how far the policy is allowed to move from the reference. Read it as the conservative ↔ aggressive dial of alignment:
- High
beta(say0.5) keeps the policy glued to the reference. Alignment is timid — the model barely changes, the reward margins barely move, and your eval delta is near zero. - Low
beta(say0.01) lets the policy move hard away from the reference. Alignment is aggressive — which can mean fast gains, but also degeneration, catastrophic forgetting, and over-optimizing the surface form of the preference instead of its intent.
The practical opinion: I start DPO around beta=0.1 with learning_rate=5e-6, and if the reward margins saturate within a handful of steps and the loss flatlines near zero, I lower the learning rate before I touch beta — that pattern is over-optimization, not a beta problem (see the pitfalls section). DPO over-corrects fast, which is why the learning rate sits one to two orders of magnitude below SFT’s.
Why DPO replaced RLHF-PPO
Here’s the head-to-head. This is the table to internalize — it’s the entire dpo vs rlhf argument:
| RLHF-PPO (historical) | DPO (2026 default) | |
|---|---|---|
| Components | SFT model + a separate reward model + policy + frozen reference | Policy (the SFT model) + frozen reference. No reward model. |
| Online RL loop? | Yes — the policy samples rollouts, they get scored online | No — offline supervised-style loss over fixed pairs |
| Stability | Unstable; sensitive to hyperparameters, prone to reward hacking | Stable; trains like supervised fine-tuning |
| Cost / memory | ≥ 3 models live + online generation | Policy + reference (and in LoRA, the reference is the adapter-off base — no extra copy) |
| Reproducibility | Low (RL variance) | High (deterministic loss over a fixed dataset) |
| Default in 2026? | No — historical; trl.experimental.ppo | Yes — top-level DPOTrainer |
The pipeline difference is easiest to see as a diagram. SFT produces one checkpoint; DPO uses it twice — once as the trainable policy, once (frozen) as the reference — and the loss only updates the policy:
flowchart TB
base["Qwen3-4B-Base"] --> sft["SFT (Module 3)"]
sft --> ckpt["SFT checkpoint"]
ckpt --> policy["policy π<br/>(trainable — gets DPO gradients)"]
ckpt --> ref["reference π_ref<br/>(frozen — no grad)"]
pairs["(prompt, chosen, rejected)"] --> policy
pairs --> ref
policy --> loss["DPO loss<br/>compares log π / log π_ref<br/>on chosen vs rejected"]
ref --> loss
loss -->|updates ONLY the policy| policy
classDef frozen fill:#eee,stroke:#999,color:#333;
class ref frozen;
The annotations to keep in your head: the reference is a frozen SFT anchor that replaces PPO’s reward model and KL penalty, and the whole thing is offline — no RL rollouts. That’s the entire reason DPO is the default: same preference-shaping outcome as RLHF, none of the moving parts.
Where the pairs come from: building preference data for tool-calling
DPO is only as good as its preference data, and this is the part most tutorials hand-wave. The data format itself is simple — DPO consumes examples shaped like:
{"prompt": "<user request + tools, rendered>", "chosen": "<preferred completion>", "rejected": "<dispreferred completion>"}
chosen and rejected share the same prompt; they differ only in the completion. As with SFT (Module 2), you don’t hand-roll the special tokens — the chat template renders the prompt, and you supply the prompt and the two completions as strings. The real question is: where do the pairs come from?
In practice, there are four sources, roughly ordered by cost and by how directly they reflect what you actually want:
- Human preferences. A person ranks two outputs (the original RLHF setup). The gold standard for capturing nuanced human taste — and the most expensive to collect at scale.
- AI feedback / LLM-as-judge (“RLAIF”). A stronger model ranks the pairs instead of a human. This is exactly the LLM-as-judge you wired up in Module 7, repurposed to label preferences rather than score a single output. Cheaper than humans; inherits the judge’s biases (position, verbosity, self-preference).
- On-policy sampling. Draw two completions from the model itself and keep the better one as
chosen, the worse asrejected. Keeps the pairs close to the model’s own distribution — useful, but you need some way to decide which is better (often back to a judge or a verifier). - Programmatic construction from a verifiable task. When your task has a checkable notion of correctness, you can build pairs mechanically: take a known-good answer as
chosen, and degrade it into a plausible-but-wrongrejected. This is Anvil’s case — and it’s the cheapest reliable source, because the tool-calling task is exactly that verifiable.
Anvil’s recipe: degrade the gold call
Anvil already has gold tool calls (the fixture’s targets). So we synthesize rejected by degrading the gold chosen — one degradation mode per pair, rotated so the dataset stays balanced and the construction stays deterministic and pedagogical. The three modes mirror the failure cases we want to un-teach:
flowchart TB
gold["gold tool call<br/>(from the fixture)"] --> chosen["chosen ✅<br/>the M7-valid call"]
gold -->|degrade| r1["wrong tool name<br/>(get_weather → get_weather_v2)"]
gold -->|degrade| r2["dropped arguments<br/>(arguments: {})"]
gold -->|degrade| r3["chatty prose<br/>(preamble, no call)"]
r1 --> rejected["rejected ❌"]
r2 --> rejected
r3 --> rejected
chosen --> dpo["(prompt, chosen, rejected) → DPO"]
rejected --> dpo
In the lab’s data.py the three degradations are _wrong_name (append _v2 to the function name — a call that looks right but targets a function that doesn’t exist), _drop_args (keep the name, empty the arguments — right tool, useless call), and _PROSE_REJECT (a chatty sentence with no tool call at all — the verbose preamble we want to kill). Each pair gets one mode, chosen by i % 3 over the rows. The key alignment property: chosen is exactly the completion eval.py would score as correct in Module 7 — so DPO optimizes directly toward the metric we already measure.
Pitfalls in the data itself
Three data traps, all at the “why” level:
rejectedtoo easy. If your rejected is random noise (gibberish, an empty string), the model learns a trivial distinction — “valid-ish vs garbage” — not the real preference you care about. The degradations above are plausible failures on purpose.chosenandrejectedtoo close. If they barely differ, the preference signal is weak and DPO has little to push on. There’s a sweet spot between “trivially different” and “indistinguishable.”- Leakage. Do not build preference pairs from Module 7’s held-out eval split. If you train on contrastive versions of the very examples you grade on, your DPO-vs-SFT delta is contaminated. The
preferencepairs come from thetrainrows; theevalsplit stays untouched (reinforcing the splits/contamination discipline from Module 2).
Training Anvil with the stable DPOTrainer
Now the freshness twist that defines this module. DPOTrainer is stable in TRL v1 — you import it from the top-level trl, exactly like SFTTrainer. Its historical counterpart, PPOTrainer, has been moved to trl.experimental.ppo (legacy 🧪). That inversion — preference tuning promoted, online RLHF demoted — is the 2026 story in one import line. (As of trl 1.6.0, June 2026: DPOTrainer, SFTTrainer, GRPOTrainer, and KTOTrainer are top-level stable; ORPO, CPO, BCO, and PPO live in trl.experimental. Re-check the split against the version you install — it churns.) KTO/ORPO/SimPO themselves are Module 9; here we stay on stable DPO.
The canonical pattern
This is anvil/train_dpo.py, trimmed to the load-bearing lines. Note what’s there — and what’s deliberately not:
from trl import DPOTrainer
def train_dpo(*, tier: str = "full", use_lora: bool = True, data_limit: int | None = None,
output_dir: str = "outputs/anvil-dpo", beta: float = 0.1, **arg_overrides):
model, tokenizer = config.get_model_and_tokenizer(tier) # base via the M5 factory (conceptually the SFT checkpoint)
train_dataset = data.make_preference_dataset("train", tokenizer, limit=data_limit)
args = config.dpo_args(output_dir=output_dir, beta=beta, **arg_overrides)
peft_config = config.lora_config() if use_lora else None
trainer = DPOTrainer(
model=model, # the policy — conceptually the SFT checkpoint; in production load the saved SFT adapter first
args=args,
train_dataset=train_dataset, # (prompt, chosen, rejected) triples
processing_class=tokenizer, # TRL v1 idiom — never tokenizer=
peft_config=peft_config, # LoRA adapter; the reference is the adapter-OFF base (auto)
)
trainer.train()
trainer.save_model(output_dir)
return trainer
And the config — anvil/config.dpo_args builds a DPOConfig:
from trl import DPOConfig
def dpo_args(output_dir: str = "outputs/anvil-dpo", **overrides):
defaults = dict(
output_dir=output_dir,
beta=0.1, # distance from the reference — the conservative↔aggressive knob
max_length=1024, # NOT max_seq_length (removed; TypeError)
learning_rate=5e-6, # DPO sits 1-2 orders of magnitude below SFT
per_device_train_batch_size=4,
gradient_accumulation_steps=2,
seed=0, report_to=[],
)
defaults.update(_precision_kwargs()) # bf16 on Ampere+, fp16 on a T4, neither on CPU
defaults.update(overrides)
return DPOConfig(**defaults)
Each line, at the “why” level rather than the “what”:
model=is conceptually the SFT checkpoint — DPO’s whole premise is to start from SFT behavior, never the raw base, because there’s no baseline behavior to align on a base model (the misconception box, made concrete). In the lab,get_model_and_tokenizer(tier="qlora")returns the base via the same frozen Module 5 loader (4-bit NF4 base, fresh adapter on top); loading your saved SFT adapter before DPO is the production step that makes the policy literally continue from SFT.- No
ref_modelargument. When the policy is a PEFT model, TRL builds the reference automatically by disabling the adapter — so you pass nothing. That’s the lightweight path that fits a T4. (In a full fine-tune you’d pass a frozen copy asref_model; the LoRA path needs no second model.) Verify against your installed version. processing_class=tokenizer— the TRL v1 idiom.tokenizer=was removed; passing it raises aTypeError.max_lengthonDPOConfig— nevermax_seq_length(removed). (As of trl 1.6.0,DPOConfigexposesbeta,loss_type, andmax_length; there is nomax_prompt_lengthfield on it — don’t copy that from older tutorials.)learning_rate=5e-6— far below SFT’s2e-4, because DPO’s contrastive gradient over-corrects quickly.
Reading a DPO run
Beyond the loss, watch the metrics TRL logs specifically for preference training: rewards/chosen, rewards/rejected, and especially the reward margin (chosen minus rejected) and rewards/accuracies (the fraction of pairs where the policy already prefers chosen). A healthy run shows the margin climbing steadily — the model preferring the good completion more and more, by a widening gap. A margin that explodes in a few steps while the loss flatlines at zero is the over-optimization tell: usually beta too low or learning rate too high.
The re-eval loop (the differentiator)
This is where the course’s spine — task-grounded evaluation — earns its keep. You re-run Module 7’s eval.py, unchanged, on the DPO model:
from anvil import eval as evaluate
rows = data.load_rows("eval") # the held-out split, never trained on
results = evaluate.compare_base_vs_finetuned(base_model, dpo_model, tokenizer, rows)
# -> {"base": {...}, "finetuned": {...}} valid_json / fn_acc / arg_match / abstention
forget = evaluate.anti_forgetting_probe(dpo_model, tokenizer) # did alignment break general ability?
You produce the same base / SFT / DPO table that ships in Anvil’s model card (Module 14) — valid JSON, function-name accuracy, argument match on the held-out — plus the anti-forgetting probe that catches a model that got “more aligned” but lost general competence. The point is the discipline: you do not believe DPO helped; you measure it. (Report the delta as “on my run” — you’ll get your own numbers in the lab, and they depend on the run.)
Training-time pitfalls
betatoo high → almost nothing changes. The policy stays glued to the reference; margins barely move; the eval delta is noise. Lowerbetato let it move.- An absent or drifting reference → divergence or forgetting. The reference must be frozen; in the LoRA path TRL handles that for you, but if you ever pass your own reference, freeze it.
- Reward hacking toward verbosity. DPO learns whatever distinguishes
chosenfromrejected. If yourchosenanswers are systematically longer, DPO may learn “prefer longer” — the opposite of what Anvil wants. For a tool-calling specialist we want concision, which is precisely why one of the three degradations makesrejecteda verbose preamble: it pushes the gradient toward the short call. - Catastrophic forgetting. Aggressive alignment can erode general ability — which is exactly why the Module 7 anti-forgetting probe rides along on every DPO run.
Build it: align Anvil with DPO
Goal: align Anvil with DPO on a free Colab T4 — build (prompt, chosen, rejected) triples from the fixture, run train_dpo.py from the SFT checkpoint (QLoRA, frozen reference), then re-run Module 7’s eval.py to show the DPO-vs-SFT delta.
What you’ll see: the DPO loss falling and the reward margin (chosen − rejected) climbing in the TRL logs, then a base / SFT / DPO table from eval.py showing a DPO-vs-SFT gain (especially on concision / picking the right tool), with a stable anti-forgetting line.
The full, tested code lives in finetune-prod-labs/module-08. Its smoke tests run offline — no GPU, no network — by running a real 2-step DPOTrainer on a tiny Qwen3 fixture, so you can verify the plumbing before you spend a minute of GPU time.
Step 1 — Build the preference pairs. data.make_preference_pairs reads the positive rows of a split, renders the gold call as chosen, and degrades it into rejected (one mode per pair: wrong name, dropped args, or chatty prose). make_preference_dataset wraps that into a 🤗 Dataset with {prompt, chosen, rejected} columns — exactly what DPOTrainer wants. It builds from train, never from the held-out eval split.
Step 2 — Run the DPO training. From the SFT checkpoint, on a T4:
uv run --env-file .env python -m anvil.train_dpo
main() calls train_dpo(tier="qlora", use_lora=True): it loads the 4-bit base via the frozen Module 5 factory, builds the preference dataset, constructs DPOConfig (beta=0.1, max_length=1024, learning_rate=5e-6), and runs the stable DPOTrainer with processing_class=tokenizer and peft_config — no explicit reference model, because the adapter-off base is the reference. Watch the logs: loss down, rewards/margins up.
Step 3 — Re-evaluate with Module 7. Call eval.compare_base_vs_finetuned(...) on base / SFT / DPO over the held-out split, and eval.anti_forgetting_probe(...) on the DPO model. You get the same task table that goes in the model card, plus the forgetting glance. This is the proof — not a vibe that “it feels more aligned.”
You’ll see something like this (trimmed — your numbers will differ):
{'loss': 0.69, 'rewards/chosen': 0.00, 'rewards/rejected': 0.00, 'rewards/margins': 0.00, ...}
{'loss': 0.42, 'rewards/chosen': 0.31, 'rewards/rejected': -0.58, 'rewards/margins': 0.89, ...}
{'loss': 0.28, 'rewards/chosen': 0.52, 'rewards/rejected': -1.04, 'rewards/margins': 1.56, ...}
base / SFT / DPO on held-out (n=200):
valid_json fn_acc arg_match
base 0.06 0.09 0.04
SFT 0.93 0.88 0.81
DPO 0.95 0.91 0.86
anti-forgetting probe (DPO): 1.00
The margin climbing from 0.00 toward ~1.5 is DPO learning the preference; the small but real DPO-vs-SFT bump (and a stable forgetting probe) is the alignment paying off without breaking anything.
Step 4 — The smoke test. The offline suite (module-08/tests/smoke_test.py) builds preference triples on the tiny fixture and runs a real 2-step DPOTrainer — verifying that chosen != rejected, that the verbose-prose degradation mode appears across the rows, that dpo_args carries beta and max_length, and that the trainer logs preference metrics. It needs no GPU and no network:
uv run pytest module-08/tests/
What this lab does NOT do, on purpose. No ORPO / KTO / SimPO — those are Module 9 (we stay on stable DPO; one-sentence teaser only). ORPO and SimPO are trl.experimental in TRL v1; KTO is stable top-level, like DPO. No GRPO and no reward_fn — DPO is offline on fixed pairs, not RL with a verifiable reward; that distinction is Module 10. No PPO — PPOTrainer is trl.experimental.ppo, historical context, never executed here. No merging, quantization, serving, or Hub push (Modules 11–14). The real QLoRA run needs a CUDA GPU (free Colab/Kaggle T4); the smoke test runs on CPU.
Try it yourself:
- Sweep
beta(try0.05,0.1,0.5) and watch both the reward margins and the Module 7 table. Find the trade-off between alignment strength and forgetting — too high barely moves, too low risks degeneration. - Change the
rejectedstrategy — make every pair a verbose-prose rejection, or every pair a wrong-name rejection — and see which degradation buys the biggest concision/accuracy gain at eval. (Hint: the prose mode is the one that fights verbosity directly.)
In production
What changes outside the lab? The single most important shift: the preference data is the bottleneck, not the trainer. Running DPOTrainer is the easy part. Manufacturing reliable, varied pairs — human-labeled, judge-labeled, on-policy, or programmatically degraded — is where the cost and the risk live. Treat that dataset as a first-class artifact: version it like code (decision F14 — reproducibility), because your alignment is only as reproducible as the pairs that produced it.
Second: always measure DPO-vs-SFT on a held-out split. Alignment can degrade the task — a model that learned to prefer something subtly wrong, or that over-optimized verbosity, can score worse than the SFT model it came from. That’s why Module 7’s eval runs after every alignment stage, not just once. Watch catastrophic forgetting too: a model that’s “more aligned” but has lost its general competence is a bad trade.
On cost and hardware (decision F6): a QLoRA DPO run on a 4B model fits a free T4 — it’s offline, no RL rollouts, lighter than GRPO. A longer or full-precision run goes on a rented GPU; ballpark $1.19–$2.69/hour for an A100 depending on provider (as of June 2026 — re-check, and see Module 1’s GPU cost notes). My standing opinion: reach for DPO first precisely because it’s stable and data-cheap; reach for RL (GRPO, Module 10) only when you have a verifiable reward and you’re trying to push reasoning — not for ordinary preference alignment.
And the one-line teaser for what’s next: DPO has a whole family — ORPO (folds preference into a single stage, no separate SFT), KTO (learns from binary good/bad labels instead of pairs), and SimPO (drops the reference model entirely) — and which one to pick depends on the data you have. That’s Module 9. ORPO and SimPO are trl.experimental in TRL v1; KTO is stable top-level, like the DPO default you just used.
Concept check
Why this matters. These five questions exercise the core of T8’s DPO half: what SFT can’t teach, why DPO replaced RLHF-PPO, where preference pairs come from, the role of the frozen reference model, and how to read and tune beta. The DPO checkpoint you built here feeds Module 9 (the family), Module 10 (GRPO), and the Module 15 capstone — so getting these straight pays off repeatedly. The questions stay inside what this module taught.
-
A teammate says: “Our SFT model already emits valid tool calls, so DPO will replace the SFT and also teach it new APIs we never trained on.” What’s wrong, and what does DPO actually do?
- A. Nothing’s wrong — DPO is a stronger SFT that also adds knowledge.
- B. DPO doesn’t replace SFT; it starts from the SFT checkpoint and shapes behavior — preferring among answers the model already knows how to produce. It does not add new knowledge.
- C. DPO replaces SFT but doesn’t add knowledge.
- D. DPO adds knowledge but runs before SFT.
-
You want to DPO-tune Anvil, but you only have known-good (“gold”) tool calls — no human preference labels. How do you build
(prompt, chosen, rejected)?- A. You can’t do DPO without human labels.
- B. Use the gold call as
chosenand degrade it programmatically (wrong name / dropped args / verbose prose) forrejected; other sources include human labels, an LLM-as-judge, and on-policy sampling. - C. Use the gold call as
rejectedand a random string aschosen. - D. Use two copies of the gold call as
chosenandrejected.
-
Why does DPO need a frozen reference model, and what is it for a LoRA/QLoRA run?
- A. It’s an optional speedup; you can always omit it.
- B. It’s a second reward model trained on preferences.
- C. It’s the frozen anchor (the SFT model) that replaces PPO’s reward model + KL penalty and keeps the policy from drifting; in a PEFT run it’s the base model with the adapter disabled — no second model is loaded.
- D. It’s the raw base model before any SFT.
-
A colleague proposes re-implementing PPO for alignment “because that’s how RLHF works.” Why is DPO the better default in 2026, and what’s the freshness gotcha?
- A. PPO is still the default; DPO is experimental.
- B. DPO needs no separate reward model, no online RL loop, is more stable, reproducible, and cheaper; and
PPOTrainernow lives intrl.experimental.ppowhileDPOTraineris top-level stable. - C. They’re equivalent; pick either.
- D. DPO is only for chatbots, PPO for tool-calling.
-
A few steps into DPO, the loss collapses toward zero and the reward margins explode. What’s happening, and what do you change first?
- A. Training converged perfectly; stop.
- B.
betais too high; lower it. - C. Over-optimization (
betatoo low or learning rate too high). Lower the learning rate first; a too-highbeta, by contrast, would cause almost no change at all. - D. The reference model is too strong; remove it.
Answers.
- B. DPO refines the SFT checkpoint and shapes behavior — it nudges the model to prefer the better of the outputs it already produces. It neither replaces SFT (it depends on it; the reference is the SFT model) nor injects knowledge (that’s data + SFT, or RAG for live facts).
- B. Anvil’s task is verifiable, so you can manufacture pairs: gold =
chosen, a controlled degradation =rejected. The other real sources are human preferences, an LLM-as-judge (RLAIF), and on-policy sampling. You do not need human labels to start. - C. The reference is the frozen anchor — the SFT model, which plays the role of PPO’s reward model + KL penalty inside the loss, keeping the policy from drifting arbitrarily. In a LoRA/QLoRA run, TRL uses the adapter-disabled base as the reference, so no second full model is loaded (which is what fits a T4).
- B. DPO drops the reward model and the online RL loop, trains stably like supervised fine-tuning, and is cheaper and more reproducible. The freshness gotcha: in TRL v1
PPOTraineristrl.experimental.ppo(legacy) whileDPOTraineris stable top-level — PPO is not the 2026 default. (As of trl 1.6.0, June 2026.) - C. Loss → 0 with exploding margins is over-optimization: the model is trivially separating chosen from rejected, usually because
betais too low or the learning rate too high. Lower the learning rate first. A too-highbeta, the opposite failure, glues the policy to the reference and produces almost no change.
Common pitfalls.
- “DPO replaces SFT / teaches new facts.” The central conceptual trap. DPO starts from the SFT checkpoint and shapes behavior; it adds no knowledge (facts come from data/SFT, or RAG — Module 1). Without a good SFT first, there’s nothing to align.
- Treating RLHF-PPO as “the default,” or importing
PPOTrainerfromtrl. Wrong in 2026 — DPO is the default, andPPOTrainermoved totrl.experimental.ppo. PPO here is historical context, never code you run. (As of trl 1.6.0, June 2026.) - Building
rejectedat random, or building pairs on the held-out split. A trivialrejectedgives a useless signal; building preference pairs on the eval split is leakage that contaminates your DPO-vs-SFT delta. Thepreferencepairs come fromtrain; theevalsplit stays untouched.
Key takeaways
- SFT imitates; it has no notion of “better than.” It minimizes next-token loss on good answers and never sees a labeled bad one — so it can’t learn to prefer the concise, correct call. Preference tuning shapes behavior after SFT.
- DPO ≠ replacing SFT, and DPO ≠ adding knowledge. It refines the SFT checkpoint to prefer among answers the model already produces; the reference model literally is the frozen SFT.
- DPO (arXiv 2305.18290) replaced RLHF-PPO (InstructGPT, arXiv 2203.02155) because it needs no separate reward model and no online RL loop: one stable, reproducible, cheaper supervised-style loss.
- The data is
(prompt, chosen, rejected)triples. For Anvil: gold call =chosen, controlled degradation (wrong name / dropped args / verbose prose) =rejected— aligned with the Module 7 metric. Other sources: human, LLM-judge, on-policy. - The frozen reference model anchors the policy (= the SFT model, frozen; in LoRA, no second model — the adapter-off base is the reference), and
betais the conservative↔aggressive knob — too high = inert, too low = degeneration. DPOTraineris stable in TRL v1 (PPOTraineristrl.experimental.ppo); use the v1 idioms —processing_class,max_lengthonDPOConfig— as of trl 1.6.0, June 2026.- Always re-evaluate (Module 7): DPO-vs-SFT plus an anti-forgetting glance. Alignment can degrade the task — measure it, don’t assume it.
What’s next
Anvil prefers the right tool call now. Module 9 meets the rest of the family — ORPO (no separate SFT stage), KTO (binary good/bad labels instead of pairs), and SimPO (no reference model at all) — and gives you a decision tree for which one your data calls for. ORPO and SimPO are trl.experimental in TRL v1; KTO is stable top-level, alongside the DPO foundation they build on.
Want the full fine-tuning concept-check bank and the next module in your inbox? Subscribe here — it’s free, like everything in this series.
Links: Course index · ← Module 7: Is It Any Better? Task-Grounded Evaluation · Module 9: The DPO Family → · Lab code for this module
References
- DPO paper — “Direct Preference Optimization: Your Language Model is Secretly a Reward Model” (NeurIPS 2023): https://arxiv.org/abs/2305.18290
- InstructGPT / RLHF-PPO paper — “Training language models to follow instructions with human feedback”: https://arxiv.org/abs/2203.02155
- TRL documentation —
DPOTrainerandDPOConfig(beta,loss_type,max_length, reference handling): https://huggingface.co/docs/trl/en/dpo_trainer - TRL documentation — dataset formats (the preference
{prompt, chosen, rejected}format): https://huggingface.co/docs/trl/en/dataset_formats - TRL documentation — DPO with PEFT (the LoRA reference-model behavior): https://huggingface.co/docs/trl/en/dpo_trainer#using-peft