[LLM 4/10] DPO: When a Language Model Becomes Its Own Reward Model
In the last chapter we did RLHF with PPO, and you saw how many moving parts it has — a separate reward model to train, four models resident in VRAM at once, a dozen-plus PPO hyperparameters to tune, and if the reward model is off, the policy finds a shortcut that games the score. In this chapter we'll accomplish the same thing with an ordinary supervised training loop — no reward model, no RL. And crucially, this is not an approximation. We'll prove algebraically that those two pieces genuinely cancel out.
Open in Colab04_dpo.ipynb
1. The Problem
Say you want your AI assistant to "always answer in Thai" — sounds simple. Now try writing that as a loss function. You can't.
This is the central problem of alignment: answer quality cannot be written as an equation. "More polite," "more natural," "doesn't drift into English" — there is no single correct answer key. There is only comparison: show a human two answers and ask which one they prefer. So the data comes in threes: a prompt , a preferred answer (chosen), and a dispreferred one (rejected).
PPO-style RLHF solves this by taking a two-step detour:
- Train a reward model to imitate human preferences
- Use RL to push the policy toward high scores from that reward model
The detour has a price:
| Problem with RLHF/PPO | What it costs you in practice |
|---|---|
| An extra model to train | More steps, more ways to fail, more time |
| Four models loaded at once | policy + ref + reward + value — VRAM explodes |
| Reward hacking | The model finds a loophole that scores well without humans liking it any more |
| PPO is hyperparameter-sensitive | Two runs with different seeds can tell completely different stories |
So this chapter asks one short question: can we skip steps 1 and 2 entirely?
2. What We're Going to Do
Yes, we can — and the reason is beautiful.
The starting point is the observation that the KL-constrained RLHF objective has a closed-form solution. We already know exactly what the optimal policy looks like, without running a single step of RL. Knowing that, we invert the equation — instead of asking "what policy does this reward produce?" we ask "what reward does this policy imply?"
Once you invert the equation, the language model already is a reward model, implicitly.
The reward model and the RL loop aren't "approximated away" — they cancel out algebraically.
What's left is an ordinary supervised loss function you can train with a single Trainer.
This is DPO (Direct Preference Optimization), proposed by Rafailov et al. (2023). The "Direct" comes from optimizing on preference data directly, with no intermediary.
3. The Equations
3.1 Setting up: the RLHF objective
In plain language: "maximize the reward, but don't wander too far from where you started."
- = the policy, i.e. the model we're training
- = the reference policy, i.e. the starting model (here, the post-SFT model from chapter 2)
- = the reward for answer given prompt
- = how tight the leash is; higher values pull harder back toward
The KL term is not decoration. Without it the model runs off to wherever reward is highest and the language falls apart.
3.2 Step 1 — the closed-form solution
That problem can be solved by hand (it amounts to finding the distribution that minimizes KL against a target distribution), giving
- is the partition function, the denominator that makes everything sum to 1
- Note that depends only on , not on — hold on to that sentence, it's about to become the hero of the story
The intuition: the optimal policy is the original model, reweighted by . High-reward answers get their probability amplified, low-reward answers get pushed down, but everything starts from the original shape of .
In practice we can't compute , because it requires summing over every possible answer in the universe. That's why people reach for RL — and it's exactly why DPO doesn't have to.
3.3 Step 2 — invert the equation to get the reward
Take the log of both sides and rearrange:
This line is the crux: any reward function can be rewritten in terms of the optimal policy and the starting policy. Which means that if we have two models, we can compute their implicit reward immediately, without ever training a reward model.
3.4 Step 3 — substitute into Bradley-Terry and vanishes
The standard model of preference is Bradley-Terry: the probability a human picks over is
where is the sigmoid. Notice that reward appears in this equation only as a difference. Substitute equation 3.3 — appears identically on both sides because it's the same — so it cancels out.
The uncomputable thing () disappears, because Bradley-Terry only cares about the difference of rewards. What remains is the log-probability of two models on text we already have, obtainable with an ordinary forward pass. No sampling of answers, no rollouts, no value function — DPO is supervised learning, all the way down.
3.5 The gradient — where the intuition lives
where is called the implicit reward.
Read it piece by piece:
- The right-hand parenthesis = the direction: push the log-prob of up and the log-prob of down, simultaneously
- = the weight: "how badly is the model ranking this pair?"
That weight is the most important teaching point here. If the model already ranks the pair correctly ( clearly greater than ), then approaches zero and the pair contributes almost no gradient. DPO therefore focuses on its own mistakes automatically, with nobody curating the data for it.
4. Seeing the Equations
Bradley-Terry: from a reward difference to a probability
Figure 4.1Bradley-Terry turns a reward difference into the probability a human picks chosen — the model never needs absolute reward values, only the difference
Where the difference is zero the probability is exactly 0.5 — "the model has no opinion." And because the curve only cares about the difference, adding a constant to both rewards changes nothing at all. That is the geometric reason can cancel.
Loss and the gradient weight
Let . Then the loss is and the gradient weight is .
Figure 4.2Left: DPO loss against margin — Right: the weight a pair receives in the gradient, for β = 0.1, 0.3, 1.0
The right panel is the one to study. Once the margin is strongly positive, the weight converges to zero — that pair has "graduated." And the higher is, the steeper the curve: the model both learns fast and "stops learning" fast. At , pairs with a margin past 4 have essentially no gradient left. At the curve is far flatter, so the model keeps collecting gradient from every pair — slower, but steadier.
controls how far the model can drift from where it started
Figure 4.3The equation π* ∝ π_ref · exp(r/β) on a toy example with 5 answers — a small β collapses everything onto the highest-reward answer, a large β restores the shape of π_ref
Read this one right to left: at nearly all the probability mass collapses onto , the highest-reward answer. That is mode collapse — a great score with all diversity wiped out. At the bars nearly overlap the dashed line, meaning almost nothing was learned. isn't a hyperparameter you "tune for lowest loss." It is a choice of where to sit on the trade-off between following preferences and keeping your original identity.
Drag the slider yourself and watch how loss and gradient change shape:
Drag anywhere on the plot, or use the Δ slider with the arrow keys.
This pair carries real signal.σ(−βΔ) is 45.02%: the model is still wrong or unsure about this pair, so it dominates the batch gradient. Drag Δ to the right and watch that weight collapse.
5. Setting Up the Environment
Open Colab and pick Runtime → Change runtime type → T4 GPU (the free tier suffices).
The Colab T4 is Turing architecture (SM 7.5), which does not support bfloat16 and does not support FlashAttention-2.
But Qwen3-0.6B's config.json declares torch_dtype: bfloat16.
So torch_dtype="auto" is a trap — your code will crash or run bizarrely slowly without telling you why.
torch_dtype=torch.float16 # not bfloat16
attn_implementation="sdpa" # not flash_attention_2
fp16=True # in DPOConfig (not bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (counts emulation!)
is_bf16_supported() lies on a T4Recent torch returns True on a T4 because it counts emulation as support — which is far slower than fp16.
Gate on compute capability ≥ 8.0 (Ampere and up) instead. This was a real bug, caught only by running the notebook on Colab.
A reference model that costs zero extra VRAM
DPO needs both and , which sounds like loading two models. But we're building on the LoRA adapter from chapter 2, which lets both share the same base weights.
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()
policy = PeftModel.from_pretrained(base, "kobkrit/qwen3-0.6b-th-sft-lora", is_trainable=True)
policy is base + adapter, while is that same base with the adapter switched off.
Just call it inside the policy.disable_adapter() context manager — nothing extra to load.
Had we done full fine-tuning back in chapter 2, we'd now need two complete copies of the model in memory. Choosing LoRA from the start makes the reference model cost zero additional bytes of VRAM. That's an architectural reason, not merely a way to save memory during training.
6. Preparing the Data
DPO data needs exactly three columns: prompt, chosen, rejected.
Set 1 — iapp/dpo_thai_tutorial (100 pairs, Apache-2.0)
A dataset I built myself for this series and released under Apache-2.0 so anyone can reuse it.
Hand-curated Thai preference pairs, emphasizing politeness and natural-sounding language.
Set 2 — roughly 400 pairs built from scratch out of airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k.
The construction is about as direct as it gets:
chosen= the Thai reference answer shipped with the datasetrejected= the base model's own output under greedy decoding
def build_rejected(prompt):
with torch.no_grad(), policy.disable_adapter():
out = policy.generate(**tok(prompt, return_tensors="pt").to("cuda"),
max_new_tokens=192, do_sample=False)
return tok.decode(out[0], skip_special_tokens=True)[len(prompt):]
Qwen3-0.6B's greedy answers to Thai prompts frequently drift into English mid-sentence. That is a real defect of this specific model, not a defect we invented.
Using it as rejected points the gradient precisely at the behavior we want to fix,
and lines up exactly with the th_ratio metric we'll measure in section 8.
If you pull rejected answers from a different model, you're teaching your model "don't be that other model," which isn't what you want.
That gives roughly 500 pairs, with 15% held out for evaluation and never touched during training.
7. The Main Code
The point of this section isn't calling a library. It's writing the DPO loss by hand and then proving it matches the real thing. If the equations in section 3 didn't convince you, this code will.
7.1 Twenty-five lines, written from scratch
import torch, torch.nn.functional as F
def seq_logp(model, input_ids, attention_mask, labels):
"""Sum of log-probs over the ANSWER ONLY (prompt tokens are masked to -100)"""
logits = model(input_ids=input_ids, attention_mask=attention_mask).logits
logits = logits[:, :-1, :] # position t predicts the token at t+1
target = labels[:, 1:] # so the targets shift by 1
mask = target.ne(-100) # count answer tokens only
target = target.masked_fill(~mask, 0) # keep gather from breaking on -100
logp = torch.log_softmax(logits.float(), dim=-1)
tokp = logp.gather(-1, target.unsqueeze(-1)).squeeze(-1)
return (tokp * mask).sum(-1) # [B] — a sum, not a mean
def dpo_loss(policy, batch, beta=0.1):
pi_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
pi_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])
with torch.no_grad(), policy.disable_adapter(): # reference: adapter off + no gradient
ref_w = seq_logp(policy, batch["chosen_ids"], batch["chosen_mask"], batch["chosen_labels"])
ref_l = seq_logp(policy, batch["rejected_ids"], batch["rejected_mask"], batch["rejected_labels"])
delta = (pi_w - ref_w) - (pi_l - ref_l) # the Δ from section 4
loss = -F.logsigmoid(beta * delta).mean() # equation 3.4, literally
r_w = beta * (pi_w - ref_w).detach() # implicit reward of chosen
r_l = beta * (pi_l - ref_l).detach() # implicit reward of rejected
return loss, r_w, r_l
All of DPO is right there. Nothing else is hiding.
The delta line is equation 3.4 transcribed one-to-one, and -F.logsigmoid(beta * delta) is the entire loss.
7.2 Proving it matches TRL
from trl import DPOTrainer, DPOConfig
cfg = DPOConfig(
output_dir="dpo-out",
beta=0.1,
loss_type="sigmoid", # must match the formula we wrote by hand
label_smoothing=0.0, # anything nonzero and it stops being equation 3.4
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # effective batch = 16
num_train_epochs=2,
learning_rate=5e-6, # much lower than SFT — see the warning below
lr_scheduler_type="cosine",
warmup_ratio=0.1,
max_length=768,
max_prompt_length=256,
fp16=True, # the T4 has no bf16
logging_steps=5,
)
trainer = DPOTrainer(model=policy, args=cfg, train_dataset=train_ds, processing_class=tok)
# Critical: a freshly created LoRA has lora_B = 0, so the policy is exactly the
# reference. The margin is zero and BOTH implementations return ln 2 — even if the
# formula is wrong. Perturb the weights first or the assert proves nothing.
with torch.no_grad():
for name, p in policy.named_parameters():
if "lora_B" in name:
p.add_(torch.randn_like(p) * 0.01)
loss_manual, _, _ = dpo_loss(policy, batch, beta=0.1)
loss_trl = trainer.compute_loss(policy, trl_batch)
assert torch.allclose(loss_manual, loss_trl, atol=1e-4)
print("match:", loss_manual.item(), loss_trl.item())
Most tutorials stop at "call DPOTrainer and it works."
That assert above says the equation we derived across all of section 3 produces the same value as the library the whole world uses.
If the assert passes, you understand DPO well enough to implement it — not just well enough to call it.
The usual causes, in order of frequency: label_smoothing isn't zero, loss_type isn't "sigmoid",
the two sides weren't fed the same example, or the padding/masking doesn't line up.
All four are mismatched definitions, not bugs — and hunting them down is the best lesson in this section.
SFT with LoRA is perfectly happy at 2e-4, but DPO at 2e-4 will send the policy running away from the reference
within a few dozen steps, and the language will degrade to the point of being unreadable.
Start at 5e-6, because DPO isn't teaching new content.
It's merely tilting a probability distribution that already exists, which takes far less force.
Total training time on a T4 is around 9 minutes for 500 pairs over 2 epochs.
8. Results
The notebook measures three things and writes them to results.json:
- Held-out preference accuracy — the fraction of pairs where the implicit reward of chosen exceeds rejected, with a Wilson 95% CI
- The distribution of implicit reward margins () — the whole distribution, not just the mean
th_ratio— the fraction of Thai characters in the model's generated answers, this series' standing metric for catching silent drift into English
A beautiful average margin can come from a handful of pairs with enormous margins while most pairs still sit near zero. A histogram tells you that; a single number conceals it. And accuracy without a CI still isn't an experimental result, as we've said since chapter 1.
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
The thing that will alarm you the first time you read the logs
During training you'll see rewards/chosen and rewards/rejected in TRL's logs,
and what happens nearly every time is that both values drift negative together while rewards/margins keeps widening.
Remember the definition: , so a negative means the policy assigns that text less probability than the reference does.
DPO was never told to "make chosen more probable." It was told only to "widen the gap." Pushing rejected down hard while pushing chosen down gently satisfies that just as well — and is often the easier path.
So what you watch is the margin and the held-out accuracy, not the absolute level of the rewards.
That said, if rewards/chosen plunges very deep (below −10, say), that starts to signal the model is abandoning the reference — lower the LR or raise .
9. Comparison
| Model | Pref. acc | Mean margin | th_ratio | TH-INSTR | Training time |
|---|---|---|---|---|---|
| starting point (before DPO) | ~50% (chance) | 0 | 0.93 | 73.3% | — |
| DPO, β = 0.1 | 83.8% | +0.94 | 0.93 | 76.7% | 8.3 min |
iapp/dpo_thai_tutorial (Kobkrit's own dataset), peak VRAM 12.65 GB.
Every number from results.json.
assert torch.allclose(loss_manual, loss_trl, atol=1e-4) passed: the ~25-line hand-written DPO
loss gave 1.46599 against TRL's 1.46602 (2×10⁻⁵ apart), and crucially on a margin of −12.0 that
is not zero — with a zero margin both formulas return ln 2 even if the formula is wrong, making the
check meaningless.
The DPO curves match theory: rewards/chosen +0.74, rewards/rejected −0.20, margins +0.94,
accuracies (fraction ranked correctly) = 83.8%.
th_ratio did not move (0.93 → 0.93) because the baseline was already high — on this dataset DPO improves other things, not Thai-ness. TH-INSTR rose 73.3% → 76.7% but McNemar gives p = 1.0 (n=30 is too small to conclude); the solid evidence is the 83.8% preference accuracy on held-out pairs. We did not run a full β = 0.5 comparison (free-Colab budget), so that row is dropped — we report only what was measured.
10. Summary
- DPO doesn't approximate RLHF — it solves the same equation in closed form; the reward model and RL loop cancel algebraically
- disappears because Bradley-Terry only cares about the difference of rewards — this is the key to the whole chapter
- A language model is its own reward model, via the implicit reward
- The gradient is weighted by the model's own error — pairs it already ranks correctly contribute almost nothing
- is the trade-off dial between indulging preferences and preserving existing capability
- A very low LR (5e-6), because we're tilting a distribution, not teaching new knowledge
- Both rewards drifting down together is normal — watch the margin, not the absolute level
- Always measure answer length, because length bias impersonates quality remarkably well
DPO is strictly offline. It learns only from the answer pairs already sitting in the file.
What it can do is re-rank behaviors the model was already capable of sampling.
It can never discover a way of answering that the base model never produced, because nobody ever put that way into the chosen column.
That gap is exactly why chapter 5 (GRPO) has to exist — for when the model must sample its own answers to learn from, rather than merely ranking what someone prepared for it.
And one more thing: 500 pairs is a demonstration of the mechanism, not real alignment. Production alignment work uses preference pairs in the tens to hundreds of thousands — several orders of magnitude more. What you get from this chapter is an understanding of how the equations work and what each dial does, and that transfers to real scale. But don't cite these results as evidence that you got a better Thai model.
Next chapter: GRPO — when ranking what already exists stops being enough, and we let the model sample several of its own answers and compare them against each other, with no PPO-style value function.
References
- Rafailov et al. (2023). Direct Preference Optimization: Your Language Model is Secretly a Reward Model — the original DPO paper -- the derivation in section 3
- Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — the Bradley-Terry model every reward model rests on
- Azar et al. (2023). A General Theoretical Paradigm to Understand Learning from Human Preferences — IPO: identifies DPO's overfitting-to-preferences weakness
- Ethayarajh et al. (2024). KTO: Model Alignment as Prospect Theoretic Optimization — KTO: an alternative that needs no chosen/rejected pairs
- Park et al. (2024). Disentangling Length from Quality in Direct Preference Optimization — the DPO length bias that section 9 warns about
- Tang et al. (2024). Understanding the performance gap between online and offline alignment algorithms — why offline (DPO) trails online (PPO/GRPO)
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: the origin of the whole SFT -> RM -> PPO pipeline
The writing, code and notebooks in this series are licensed under CC BY-NC-SA 4.0 — reuse and adapt them freely with attribution, for non-commercial purposes, and share your adaptations under the same terms. Third-party models and datasets referenced here keep their own licences.
