DPO, RLHF & alignment
Some qualities are easier to express as preferences than gold answers. RLHF does this with a reward model + RL; DPO does it directly on chosen/rejected pairs — the applied default.
- a GPU +
pip install trl datasets
Learning objectives
- Explain the difference between SFT and preference tuning (DPO/RLHF).
- Understand RLHF conceptually and why DPO simplified it.
- Build a preference dataset (chosen vs rejected).
- Run a DPO training step on top of an SFT model.
30-second recap
- When fine-tuning wins: Prompting and RAG change what the model sees; fine-tuning changes the model itself — worth it for consistent style, format or narrow skills, not for adding facts.
- Transformers: Attention lets every token look at every other token; stacking attention and feed-forward layers is the architecture behind every modern LLM.
code/ft5-dpo-rlhf/ in the course, with a README. Run the scripts or copy the configs directly.Beyond imitation: preferences intermediate
Try to write down the single "correct" answer to "was that reply helpful?" and you'll feel the whole problem in your gut. There isn't one. There are a hundred good replies and a thousand mediocre ones, and the difference between them is a matter of taste — tone, restraint, whether it hedged in the right places. That's the wall every team hits after FT3: supervised fine-tuning got the model fluent and on-topic, but the last mile — sounding trustworthy, refusing gracefully, not rambling — resists being pinned to gold examples. You can spend a month writing "perfect" answers and still watch the model produce something technically-imitating-your-examples yet somehow off.
Here's the reframe that unlocks it. Instead of demanding the right answer, ask a much easier question: given these two replies, which is better? Humans are dramatically better at judging than at authoring. Handed a blank page, "write the ideal refund response" is paralyzing; handed two drafts, "the first one is warmer and clearer" is instant and reliable. Preference tuning is built entirely on that asymmetry — it collects comparisons rather than gold answers, then teaches the model to move toward the winners and away from the losers.
The analogy that sticks: SFT is like teaching someone to cook by handing them recipes to copy exactly. Preference tuning is like being the friend who tastes two versions of the same dish and says "this one — more of that." You never had to write the recipe; you just had to prefer. Over many tastings, the cook internalizes your palate. That's what DPO does with a few thousand (chosen, rejected) pairs: it doesn't hand the model new recipes, it shifts its palate.
The common mistake is treating this as a replacement for SFT rather than a layer on top of it. Preference tuning has almost nothing to teach a model that can't yet form a coherent answer — the comparisons only sharpen behaviour that's already roughly in place. That's why the pipeline is always SFT first (learn the shape of good answers), then preferences (refine which good answers you like best). Skip the SFT foundation and you're asking someone to refine a palate for a cuisine they've never cooked. Keep that ordering in mind as you read the flow below — it starts, deliberately, from an already-tuned SFT model.
SFT (what FT3 did) teaches a model to imitate good examples. But some qualities — helpfulness, harmlessness, 'which of these two answers is better' — are easier to express as preferences than as gold answers. Preference tuning optimizes the model toward preferred over rejected responses.
This flow shows preference tuning: taking a model that already imitates good answers and nudging it to prefer better answers over worse ones. Read the four boxes left to right.
- SFT model (left) is where you start — a model that has learned to imitate good examples (that's what FT3 produced).
- Preference data (chosen/rejected) is the new ingredient: pairs of answers to the same prompt where one is marked better (chosen) and one worse (rejected).
- DPO training optimizes the model directly on those pairs — the
no reward modellabel means it skips the complicated extra model that older RLHF needed. - Aligned model (right) is the result: it now prefers good responses over the kind you marked as worse.
In short: Preference tuning teaches a model "this answer is better than that one", which is often easier than writing one perfect gold answer. DPO does it in one step, with no separate reward model.
RLHF, then DPO intermediate
RLHF is the technique that made ChatGPT possible, and understanding it is worth your time even though you'll rarely run it yourself. It's the original answer to "how do we optimize a model against fuzzy human preferences," and it works — it's what turned raw, occasionally-unhinged base models into assistants people trusted. But it does the job in two stages, and each stage is a place things can go wrong. Knowing the shape of it is what lets you appreciate why DPO felt like such a relief when it arrived.
Here's the RLHF pipeline in plain terms. Stage one: build a judge. You collect human comparisons ("answer A beats answer B"), then train a separate neural network — the reward model — whose only job is to look at a response and output a number: how much a human would like it. You've essentially distilled the crowd's taste into a scorer. Stage two: coach the model against that judge. Now you use reinforcement learning (specifically PPO) to nudge the LLM to generate responses that score high with the reward model, while a leash (a KL penalty back to the original model) stops it from drifting into gibberish that games the score. The picture to hold: a student (the LLM) writing answers, a grader (the reward model) scoring each one, and a coach (PPO) adjusting the student to chase higher grades.
Why that's painful in practice. You're now training and babysitting three models at once (the policy, the reward model, and a frozen reference), the RL loop is famously twitchy — learning rates, batch sizes, and the KL coefficient all have to be just right or training diverges — and the model loves to reward-hack: it finds responses the imperfect reward model scores highly but humans actually hate (endless flattery, hedge-everything non-answers). Getting RLHF stable is a real research skill, not a config change.
DPO's insight is almost cheeky: you can skip the middleman. The researchers showed, with a bit of algebra, that the reward model and the RL loop can be collapsed into a single loss function you compute directly on the (chosen, rejected) pairs. There's no separate judge to train and no RL coach to tune — you just run ordinary gradient descent, the same machinery as SFT, on a loss that says "raise the probability of the chosen answer relative to the rejected one, but don't stray too far from where you started." Same goal as RLHF, one model, one stable training loop. Read the table below as a checklist of moving parts removed: every "yes" in the RLHF column that becomes "no" under DPO is a thing that can no longer break. The mistake to avoid is reaching for PPO by default because it sounds more powerful — for the overwhelming majority of applied alignment, DPO gets you there with a fraction of the risk, and you only escalate to full RLHF if DPO visibly plateaus.
RLHF (Reinforcement Learning from Human Feedback) trains a separate reward model on human preferences, then uses RL (PPO) to optimize the LLM against it — powerful but complex and unstable. DPO (Direct Preference Optimization) gets similar results by optimizing the preference objective directly on the pairs — no reward model, no RL loop. For applied work, start with DPO.
Start at the shared box on the left and follow the two paths that branch out of it — the red one along the top, the green one along the bottom — both ending at an "Aligned" model.
- The left box is the one input both approaches share: your
chosen/rejectedpreference pairs. - The red top path (RLHF) spends the pairs training a separate reward model, then runs a PPO / RL loop that juggles a policy and a frozen reference — three models and an unstable loop before you get an aligned result.
- The green bottom path (DPO) feeds the same pairs straight into a single direct loss optimized with ordinary gradient descent — no reward model, no RL loop — and arrives at the same aligned model.
In short: Both roads reach an aligned model, but DPO deletes the reward model and the RL loop — fewer moving parts, far less that can break, which is why it's the applied default.
| RLHF (PPO) | DPO | |
|---|---|---|
| Reward model | yes, separate | no |
| RL loop | yes (PPO) | no — direct loss |
| Stability/complexity | harder | much simpler |
| Applied default | rarely | start here |
Build preference data & run DPO advanced
Here's the part that surprises people who braced for the hard math: the DPO run itself is almost boringly short. The whole intellectual weight of preference tuning is in the data — the (prompt, chosen, rejected) triples — and the training call is a dozen lines of library code that look a lot like the SFT you already did in FT3. If you were dreading a reinforcement-learning tangle, exhale: DPO deliberately makes the code the easy bit so your effort goes where it belongs, into curating pairs that actually encode the behaviour you want.
Picture the data format first, because it's the thing you'll spend real time on. Every example is a single prompt paired with two completions to it: a chosen one you'd rather the model produce, and a rejected one you'd rather it didn't. Crucially, both must be plausible answers to the same prompt — DPO learns from the contrast, so the two sides need to differ on exactly the axis you care about and be otherwise comparable. If your "chosen" is a great answer and your "rejected" is off-topic gibberish, the model learns "avoid gibberish," which it already knew; you've spent a training pass teaching nothing. The signal lives in the gap, and a well-chosen gap ("warm and concise" vs "warm but rambling") is worth ten sloppy ones.
The one dial worth understanding before you run anything is beta. Think of it as how hard the model is allowed to lean toward your preferences versus how tightly it's tethered to where it started (the SFT model). A small beta (like the 0.1 in the lab) keeps the model close to its original behaviour and nudges gently — safe, and the usual starting point. A large beta pushes aggressively toward the chosen answers but risks the model over-committing and losing fluency or general ability it had before. The common mistake is cranking beta or the learning rate to "make it learn faster" and instead watching the model degrade — preference tuning is a scalpel, not a hammer, and gentle-and-check beats aggressive-and-pray every time.
One reassurance as you read the lab: nothing here trains a reward model or runs a reinforcement-learning loop. You load your SFT checkpoint, hand DPOTrainer the pairs and a beta, and call train() — the direct-loss machinery from the previous section does the rest. The loss quietly compares, for each pair, how much more likely the model makes the chosen answer versus the rejected one (relative to the frozen starting model), and steps to widen that margin. That's the whole mechanism, wrapped in an API that looks like every other trainer you've used.
chosen and a rejected completion; DPO raises the chosen's probability and lowers the rejected's, and the gap between them (the amber margin) is the entire learning signal — beta just limits how far the model may drift.
Read it left to right: one prompt on the left splits into the two answers on the right, and the vertical arrow between them is the thing DPO actually learns from.
- The left box is the
prompt— a single question that both completions answer. - The green box is the
chosenanswer (DPO pushes its probability up); the red box is therejectedanswer (its probability goes down). Both must be plausible replies to the same prompt. - The amber double arrow is the margin — the gap between chosen and rejected. That gap is the learning signal: a clear gap trains strongly, a near-tie teaches almost nothing.
- The bottom bar is
beta, the leash back to the frozen SFT model — small keeps the nudge gentle, large pushes hard and risks losing fluency.
In short: Curate pairs with a real quality gap and keep beta small — DPO learns from the margin, not from either answer alone.
dpo_train.pyfrom datasets import load_dataset
from trl import DPOTrainer, DPOConfig
from transformers import AutoModelForCausalLM, AutoTokenizer
# Preference data: each row has prompt, chosen, rejected.
ds = load_dataset("json", data_files="prefs.jsonl", split="train")
# e.g. {"prompt": "...", "chosen": "helpful answer", "rejected": "worse answer"}
base = "out/merged-model" # your SFT model from FT4
model = AutoModelForCausalLM.from_pretrained(base, device_map="auto")
tok = AutoTokenizer.from_pretrained(base); tok.pad_token = tok.eos_token
trainer = DPOTrainer(
model=model, args=DPOConfig(output_dir="dpo-out", beta=0.1,
per_device_train_batch_size=2, learning_rate=5e-6, num_train_epochs=1),
train_dataset=ds, tokenizer=tok,
)
trainer.train()
This runs DPO (Direct Preference Optimization) on top of your already-trained model. Instead of one correct answer per example, DPO learns from pairs: a better answer and a worse one.
load_dataset("json", data_files="prefs.jsonl", ...)loads the preference data. Each row has three fields — aprompt, achosen(better) answer, and arejected(worse) answer, as the comment shows.base = "out/merged-model"points at your SFT model from FT4, and the next two lines load that model and its tokenizer — DPO starts from the model you already tuned.DPOTrainer(...)withDPOConfig(..., beta=0.1, ...)sets up the run.betacontrols how strongly it pushes toward chosen over rejected; the smalllearning_rate=5e-6keeps the nudge gentle so it doesn't wreck existing ability.trainer.train()runs the preference-tuning loop.
What the output means: Training prints loss numbers as it runs. When it finishes, the model in dpo-out has shifted toward the style of your chosen answers — no separate reward model was ever needed.
Try this: The rejected answer must be genuinely worse in the way you care about. Try building 10 pairs by hand where "chosen" is concise and "rejected" is rambling — sloppy pairs push the model in confused directions.
Exercise FT5.1 — Align a behavior
Context: Preference tuning is easiest to feel by aligning one concrete quality end-to-end and checking the behaviour appeared without breaking correctness.
Your task: Build ~100 preference pairs for a quality you want (e.g. concise over verbose), run DPO on your SFT model, and compare before/after on held-out prompts.
Requirements:
- Assemble ~100 chosen/rejected pairs for one target quality
- Run DPO on the SFT model
- Compare before vs after on held-out prompts
- Confirm the aligned behaviour appears
- Confirm correctness didn't regress
💡 Hint: Reuse the pair-validation and DPOTrainer steps from the ladder; the finding is whether the behaviour moved without a correctness regression.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: SFT imitates one gold answer; preference tuning learns from (chosen, rejected) pairs. A single correct output points to SFT; a “better than” comparison points to DPO.
Your task: Classify a set of training goals into which paradigm fits best — SFT or preference tuning (DPO).
Requirements:
- Map exact-output goals (copy a macro, emit an exact schema) → SFT
- Map comparative-quality goals (prefer polite over blunt, be more helpful) → DPO
- State the rule: one right answer → SFT; “better than” → preference tuning
- Cover at least one goal of each kind
💡 Hint: Ask whether the goal has a single gold answer or only a comparison between two — that fork is the classifier.
Show solution
Sort goals by whether a single gold answer exists:
goals = [
("copy this exact support macro", "SFT"),
("prefer polite over blunt phrasing", "preference (DPO)"),
("be more helpful, less evasive", "preference (DPO)"),
("emit this exact JSON schema", "SFT"),
]
for goal, method in goals:
print(f"{goal:38} -> {method}")
When there is one correct answer, SFT imitation works. When 'better' is a comparison between two answers (helpful vs evasive, polite vs blunt), preferences capture it more naturally than any single gold label.
Context: RLHF trains a separate reward model then optimizes with PPO; DPO optimizes the preference objective directly, achieving RLHF-like alignment without the reward model or the unstable RL loop.
Your task: Encode the RLHF-vs-DPO comparison and print the applied-work default.
Requirements:
- Contrast reward model (yes/no), RL loop (PPO / none), and complexity
- Print the comparison as a table
- Declare DPO the default for applied work
- Explain DPO optimizes directly on pairs, skipping reward model + PPO
💡 Hint: The row that matters is “how many moving parts” — DPO drops both the reward model and the RL loop.
Show solution
The comparison table as a decision:
compare = {
"reward model": ("yes, separate", "no"),
"RL loop": ("yes (PPO)", "no — direct loss"),
"stability/complexity": ("harder", "much simpler"),
"applied default": ("rarely start", "start here"),
}
print(f"{'axis':22}{'RLHF (PPO)':18}DPO")
for k,(rlhf,dpo) in compare.items():
print(f"{k:22}{rlhf:18}{dpo}")
print("\nDEFAULT for applied work: DPO — similar results, no reward model, no RL loop")
DPO gets RLHF-like alignment by optimizing directly on the pairs, skipping the separate reward model and unstable RL loop. For applied work you start with DPO and only reach for PPO if you must.
Context: A DPO example is {prompt, chosen, rejected}, and the model learns from the gap between chosen and rejected — so identical or empty sides carry no signal.
Your task: Write a builder that validates preference pairs (chosen ≠ rejected, both non-empty, same prompt) and drops invalid rows with a reason.
Requirements:
- Keep valid
{prompt, chosen, rejected}rows - Drop rows with an empty chosen or rejected side
- Drop rows where chosen equals rejected after stripping
- Return both the good rows and the dropped ones with reasons
- Explain that no gap means no learning signal
💡 Hint: Each drop condition maps to a reason string; a pair where the two sides match teaches the model nothing, so it must go.
Show solution
Validate the (chosen, rejected) structure DPO requires:
def build_prefs(rows):
good, dropped = [], []
for r in rows:
if not r.get("chosen") or not r.get("rejected"):
dropped.append((r, "empty side")); continue
if r["chosen"].strip() == r["rejected"].strip():
dropped.append((r, "chosen == rejected")); continue
good.append({"prompt": r["prompt"], "chosen": r["chosen"],
"rejected": r["rejected"]})
return good, dropped
rows = [
{"prompt":"Refund?","chosen":"Sure, here's how...","rejected":"No."},
{"prompt":"Refund?","chosen":"Same","rejected":"Same"}, # invalid
{"prompt":"Hi","chosen":"Hello!","rejected":""}, # invalid
]
good, dropped = build_prefs(rows)
print(f"kept {len(good)}, dropped {len(dropped)}") # kept 1, dropped 2
for r, why in dropped: print(" drop:", why)
DPO learns from the gap between chosen and rejected, so identical or empty sides carry no signal and must be dropped. Clean pairs are as important here as clean examples are for SFT.
Context: DPO's learning signal is the margin between chosen and rejected: a clear chosen>rejected gap gives a strong gradient, while a near-tie teaches almost nothing.
Your task: Model the DPO intuition without training — score answers by a proxy “quality”, compute each pair's margin, and rank pairs by learning signal.
Requirements:
- Define a toy quality proxy (reward length/structure, penalize refusals)
- Compute
margin = quality(chosen) − quality(rejected)per pair - Label large margins strong signal and near-ties weak signal
- Rank pairs by margin
- Conclude you should curate real quality gaps
💡 Hint: It's the same lesson as the deduper, one level up: pairs that barely differ are the ones to prune, this time measured by the quality margin.
Show solution
A proxy showing margin = learning signal:
def quality(ans): # toy proxy: helpful, non-refusing, specific
score = 0
score += 2 if len(ans.split()) >= 8 else 0
score += 2 if "here" in ans.lower() or "steps" in ans.lower() else 0
score -= 3 if ans.strip().lower() in ("no.", "can't help") else 0
return score
pairs = [
("Here are the steps to get a refund: ...", "No."),
("Sure, contact support.", "Please contact support."),
]
for chosen, rejected in pairs:
margin = quality(chosen) - quality(rejected)
print(f"margin={margin:+d} strong signal" if abs(margin)>=3
else f"margin={margin:+d} weak signal (near tie)")
A pair where the chosen answer clearly beats the rejected one gives DPO a strong gradient; near-ties teach almost nothing. Curate pairs with real quality gaps, not arbitrary A/B rows.
Context: DPO is a real, runnable step on top of an SFT model: TRL's DPOTrainer optimizes the preference loss directly, with beta controlling how strongly it prefers chosen over rejected.
Your task: Show the real TRL DPOTrainer on top of an SFT model, using only documented APIs and labelling it as needing a GPU + libraries.
Requirements:
- Start from an SFT checkpoint (load model + tokenizer)
- Use a JSONL dataset with exactly
prompt, chosen, rejectedcolumns - Run
DPOTrainerwith aDPOConfig(beta, one epoch) - Train, then save the model — no reward model, no PPO
- Note
betatunes preference strength; libs:trl peft transformers datasets
💡 Hint: DPO starts where SFT left off — point it at the SFT checkpoint and a three-column pairs dataset, and beta is the one dial to know.
Show solution
The real DPO run — needs a GPU + pip install trl peft transformers datasets:
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import DPOTrainer, DPOConfig
from datasets import load_dataset
model_id = "out/sft-model" # start from your SFT checkpoint (FT3)
model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")
tok = AutoTokenizer.from_pretrained(model_id)
# dataset columns must be exactly: prompt, chosen, rejected
ds = load_dataset("json", data_files="prefs.jsonl", split="train")
trainer = DPOTrainer(
model=model, args=DPOConfig(output_dir="out/dpo", beta=0.1,
num_train_epochs=1),
train_dataset=ds, processing_class=tok,
)
trainer.train()
trainer.save_model("out/dpo")
DPO starts from the SFT model and optimizes the preference loss directly — no reward model, no PPO loop. beta controls how strongly it prefers chosen over rejected.
Context: Alignment is layered by the data and risk you have: SFT is the foundation, DPO the refinement, and RLHF is reserved for when DPO stops improving.
Your task: For a support assistant, given whether you have gold answers, preference pairs, and a safety-critical bar, decide the alignment recipe and justify it.
Requirements:
- No gold answers → collect gold and do SFT first
- Gold but no pairs → SFT only, add DPO later
- Safety-critical with pairs → SFT + DPO (RLHF only if DPO plateaus)
- Otherwise SFT + DPO as the applied default
- Justify by available data and risk
💡 Hint: Walk the data you actually have: gold unlocks SFT, pairs unlock DPO, and RLHF is the last resort, not the first.
Show solution
Route the alignment recipe from what data and risk you have:
def recipe(have_gold, have_prefs, safety_critical):
if not have_gold:
return "COLLECT gold first — SFT is the foundation for any alignment"
if not have_prefs:
return "SFT only for now — add DPO once you can collect preference pairs"
if safety_critical:
return "SFT + DPO — DPO nudges toward safe/helpful; consider RLHF only if DPO plateaus"
return "SFT + DPO — the applied default"
print(recipe(True, True, True)) # SFT + DPO ...
print(recipe(True, False, False)) # SFT only for now ...
SFT builds the base behavior, DPO refines it toward preferred responses cheaply, and full RLHF is reserved for cases where DPO stops improving. Start simple and layer alignment as your data allows.
✓ Checkpoint — you can move on when you can…
- Distinguish SFT from preference tuning.
- Explain RLHF and why DPO simplified it.
- Build a chosen/rejected preference dataset.
- Run a DPO step on an SFT model.
Knowledge check check yourself
How does DPO differ from RLHF (PPO) in what it requires, and why is DPO the applied default?
Show answer
A DPO dataset uses (prompt, chosen, rejected) triples. Why does the quality of the 'rejected' response matter as much as 'chosen'?