DPO and direct preference methods
Preference training without a separate reward model.
Create a free account Try part of it here
Why it matters
RLHF works, but it is costly and fiddly. You train a separate reward model. You sample fresh answers during training. You keep four models in memory at once. Direct Preference Optimization, or DPO (2023), gets much of the same benefit in a simpler way. Its loss fits on one line, and you train with it like ordinary fine-tuning. It quickly became a standard step in post-training open models. If you ever tune a model on your own preferences, it's the method you're most likely to use.
What you'll be able to do
- Explain how DPO turns preference pairs into a loss on the model itself, with no reward model
- Compute sequence log-probabilities and the DPO loss for chosen and rejected answers
- Run DPO on a real model and evaluate what changed, including side effects
- Know the main variants and when DPO is preferred over RLHF with PPO
What it covers
- Learning straight from the pairs
- The model's own score for an answer
- Turning scores into a loss
- Worked example: the loss for one pair
- A toy run: five possible answers
- DPO on a real model
- DPO in practice
Learning straight from the pairs
Say you're teaching a new cook your taste in food. You have a stack of notes from past meals, each one saying "this dish beat that one."
- The RLHF way: first train a food critic on your notes. Then let the cook practice for weeks, with the critic scoring every new dish.
- The DPO way: skip the critic. Show the cook each pair of dishes and say "more like this one, less like that one."
DPO uses the same ingredients you met in the last few lessons:
- A preference pair: a prompt, a chosen answer people liked better, and a rejected answer they liked less.
- The policy: the model you are training. It's written (the Greek letter pi).
- The reference model: a frozen copy of where the policy started, usually the SFT model. It's written , and it never changes.
For each answer, DPO asks one question of each model: how likely are you to write this exact answer? The chance of a whole answer is tiny, like 0.000001. So models work with the log of the probability instead, as in the cross-entropy lesson. A log-probability is 0 or below. The closer it is to 0, the more likely the answer.
Show the answer
A prompt, a chosen answer and a rejected answer
There's no reward score at all. It learns straight from the comparison.
The model's own score for an answer
Here is DPO's key idea. You can read a score for any answer straight off the policy. Compare how likely the policy makes the answer with how likely the reference made it. If training has made an answer twice as likely, the policy now favors it. That shift is DPO's implicit reward. Nobody trains it as a separate model. It's implied by the two models you already have.
Written as a formula, with standing for one answer:
Here is the chance the policy writes answer . The bottom of the fraction is the same chance under the reference. (beta) is the leash from the RLHF lesson. "const" is a constant: it's the same for every answer to a prompt, so it drops out when you compare two answers.
In plain words: an answer's score is how much more likely the policy makes it than the reference did, scaled by beta. Leave out the constant, and an answer made more likely scores above zero. One made less likely scores below zero.
A worked example with . The reference gives an answer a chance of 0.2. After some training, the policy gives it 0.4. The ratio is 2, and the log of 2 is about 0.693. Half of that is about 0.35. If training had instead cut the chance to 0.1, the ratio would be 0.5. The score would be about −0.35.
import math beta = 0.5 p_ref, p_now = 0.2, 0.4 # reference and policy chances print(round(beta * math.log(p_now / p_ref), 2)) print(round(beta * math.log(0.1 / p_ref), 2)) # an answer made half as likely
It prints 0.35 and then -0.35. Rafailov and colleagues at Stanford introduced DPO in 2023. Their paper's title sums up this idea: "Your Language Model Is Secretly a Reward Model".
Go deeper Where the implicit reward comes from
The last lesson's RLHF objective has an exact best policy, .
Read it as: start from the reference's chances, then boost each answer by raised to its reward divided by beta. The sign means "proportional to". The two sides match once you rescale so the chances add up to 1.
Take the log of both sides and solve for . You get the formula above. The constant is beta times the log of that rescaling factor. It depends on the prompt but not on the answer. So every policy defines a reward: the one it would be the best possible policy for.
Show the answer
0
The ratio is 0.1 / 0.1 = 1, and the log of 1 is 0. Training hasn't moved the policy's view of this answer, so it scores zero.
Turning scores into a loss
The preference-data lesson gave you the Bradley-Terry loss for one pair, . Here (sigma) is the sigmoid. It squashes any number into a value between 0 and 1. The loss is small when the chosen answer scores well above the rejected one.
DPO puts its implicit rewards into that same loss. Two more names first:
- is the chosen answer (w for winner).
- is the rejected answer (l for loser).
Logs turn division into subtraction, so each ratio becomes a difference of two logs. The constants are equal for both answers, so they cancel. What's left is the DPO loss:
In plain words: see how far the policy has raised the chosen answer above the reference. Subtract how far it has raised the rejected one. That gap is the margin. Multiply it by beta and feed it to the same loss a reward model uses. A big, positive margin gives a small loss.
So training raises the chosen answer's log-probability relative to the reference. It lowers the rejected one's. And it stops pushing once the gap is comfortable, because the loss flattens out:
| beta × margin | loss |
|---|---|
| 0 | 0.693 |
| 1 | 0.313 |
| 2 | 0.127 |
| 4 | 0.018 |
Pairs the policy still gets wrong get the hardest push. Pairs it already separates clearly barely move. There's no reward model and no sampling during training. All you need is the log-probability of fixed answers under two models: the one being trained and its frozen copy.
import math gap = 0.0 print(round(-math.log(1 / (1 + math.exp(-gap))), 3))
Show the answer
0.693
With no gap, e to the power of 0 is 1, so the code works out −log(1 / 2), which is ln 2, about 0.693. Pulling the preferred answer ahead lowers it.
DPO skips the reward model and learns straight from the pairs.
Show the answer
ln 2 ≈ 0.693, because the margin is 0 and −log σ(0) = ln 2
Both log-ratios are 0, so the margin is 0 whatever β is. The sigmoid of 0 is one half, and −log σ(0) = log 2 ≈ 0.693.
Worked example: the loss for one pair
Take . The reference gives the chosen answer a log-probability of −10 and the rejected one −12. After some training, the policy gives −9 and −13. Find the margin and the loss.
import math beta = 0.5 ref_chosen, ref_rejected = -10.0, -12.0 # reference log-probabilities pol_chosen, pol_rejected = -9.0, -13.0 # policy log-probabilities, after some training margin = (pol_chosen - ref_chosen) - (pol_rejected - ref_rejected) sigma = 1 / (1 + math.exp(-beta * margin)) loss = -math.log(sigma) print(margin, round(sigma, 3), round(loss, 3))
It prints 2.0, 0.731 and 0.313. The chosen answer rose by 1, from −10 to −9. The rejected one fell by 1, from −12 to −13. So the margin is 1 minus −1, which is 2. Beta halves it to 1. The sigmoid of 1 is 0.731, and the loss is 0.313. That's down from 0.693, the loss before training. Notice that the raw values like −9 don't matter on their own. Only the change from the reference counts.
A toy run: five possible answers
Here is DPO on the smallest model possible. The "policy" is nothing but a chance for each of five possible answers, and it starts equal to the reference. Simulated raters judge 300 pairs by a hidden quality score. The bigger the quality gap in a pair, the more often the better answer wins. DPO only ever sees who won.
p_refholds the reference's chance for each answer.qualityis the hidden score the raters judge by.- The first loop builds the pairs: winners go in
W, losers inL. - The training loop computes every pair's margin and the DPO loss, then takes 200 small steps.
import numpy as np import torch import torch.nn.functional as F answers = ["on brand, clean", "on brand, plain", "repeats a word", "wrong material", "no repair promise"] p_ref = torch.tensor([0.30, 0.35, 0.15, 0.15, 0.05]) quality = np.array([1.0, 0.7, -0.5, -0.8, -0.3]) # hidden; raters judge by it rng = np.random.default_rng(1) pairs = [] for _ in range(300): i, j = rng.choice(5, size=2, replace=False) i_wins = rng.random() < 1 / (1 + np.exp(-(quality[i] - quality[j]))) pairs.append((i, j) if i_wins else (j, i)) W, L = torch.tensor(pairs).T # winners, losers beta = 0.5 logits = torch.log(p_ref).clone().requires_grad_(True) # start the policy at the reference opt = torch.optim.Adam([logits], lr=0.05) for step in range(200): logp, logp_ref = torch.log_softmax(logits, dim=0), torch.log(p_ref) margin = (logp[W] - logp_ref[W]) - (logp[L] - logp_ref[L]) loss = -F.logsigmoid(beta * margin).mean() opt.zero_grad() loss.backward() opt.step() with torch.no_grad(): p = torch.softmax(logits, dim=0) implicit = beta * (torch.log(p) - torch.log(p_ref)) print(f"{'answer':<20} {'reference':>9} {'after DPO':>9} {'implicit reward':>15}") for a, r0, r1, ir in zip(answers, p_ref.tolist(), p.tolist(), implicit.tolist()): print(f"{a:<20} {r0:>9.2f} {r1:>9.2f} {ir:>15.2f}")
Probability moves toward the answers the raters preferred. The two on-brand answers end up with nearly all of it. The implicit rewards in the last column come out in the same order as the hidden quality, though DPO never saw a single score. (torch here is the course's in-browser PyTorch subset.)
Show the answer
From who won each pair: answers that win more often get pushed up, and ones that lose get pushed down
Better answers win more of their pairs, so the loss keeps raising their chance and lowering the others'. The order of the implicit rewards follows from that.
DPO on a real model
Now for DPO on tiny-gpt-chat, with the 239 training pairs from the last lesson's brand dataset. Recall the brand reviewer's four guidelines. Name the product that was asked about. Don't repeat a word in a sentence. Use a material the catalog uses for that kind of product. Mention the repair promise.
A whole answer needs a sequence log-probability. That's the sum of the log-probabilities the model gives each of the answer's tokens, one after another. Only the answer's tokens count, not the prompt's: the same masking as SFT. In the code, logprob does this for a batch of answers.
The reference model's values are worked out once, since it never changes. The policy's are worked out again every step. Training uses and batches of 16 pairs. Afterwards, both models describe products held out of training. The brand reviewer then counts how often each guideline is broken. It takes about half a minute.
import requests import torch import torch.nn.functional as F from tinygpt import load_pretrained policy, tok = load_pretrained("tiny-gpt-chat") # the model we train reference, _ = load_pretrained("tiny-gpt-chat") # a frozen copy of where it started end = tok.token_id("<end>") pairs = requests.get("https://api.gradient.test/v1/preferences").json()["data"] held_out = {"Kestrel", "Lark", "River", "Sierra", "Summit", "Willow"} train = [p for p in pairs if p["prompt"].split()[2] not in held_out] def encode(prompt, answer): p, a = tok.encode(f"<user> {prompt} <assistant>"), tok.encode(answer) + [end] return (p + a)[:65], ([-100] * len(p) + a)[:65] def logprob(model, rows): # Total log-probability the model gives each answer, given its prompt. T = max(len(ids) for ids, _ in rows) ids = torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in rows]) labels = torch.tensor([lab + [-100] * (T - len(lab)) for _, lab in rows])[:, 1:] logp = torch.log_softmax(model(ids[:, :-1]), dim=-1) picked = logp.gather(-1, labels.clamp(min=0).unsqueeze(-1)).squeeze(-1) return (picked * (labels != -100).float()).sum(dim=-1) chosen = [encode(p["prompt"], p["chosen"]) for p in train] rejected = [encode(p["prompt"], p["rejected"]) for p in train] with torch.no_grad(): ref_chosen, ref_rejected = logprob(reference, chosen), logprob(reference, rejected) kinds = sorted({" ".join(p["prompt"].split()[3:-1]) for p in pairs}) products = [f"{n} {k}" for n in sorted(held_out) for k in kinds] @torch.no_grad() def violations(model): items = [] for product in products: ids = torch.tensor([tok.encode(f"<user> Describe the {product} . <assistant>")] * 2) for row in model.generate(ids, 60, temperature=0.8, stop_id=end)[:, ids.shape[1]:].tolist(): items.append({"product": product, "text": tok.decode(row).split("<end>")[0].strip()}) flaws = [r["flaws"] for r in requests.post("https://api.gradient.test/v1/brand-check", json={"items": items}).json()["results"]] rate = lambda test: sum(any(test(f) for f in fs) for fs in flaws) / len(flaws) return {"names another product": rate(lambda f: f.startswith("doesn't name")), "repeats a word": rate(lambda f: f.startswith("repeats")), "wrong or no material": rate(lambda f: "material" in f or "isn't used" in f), "no repair promise": rate(lambda f: f == "no repair promise"), "any violation": sum(bool(fs) for fs in flaws) / len(flaws)} torch.manual_seed(0) before = violations(policy) beta = 0.1 opt = torch.optim.Adam(policy.parameters(), lr=2e-4) policy.train() torch.manual_seed(1) for step in range(61): b = torch.randint(0, len(train), (16,)).tolist() margin = (logprob(policy, [chosen[i] for i in b]) - ref_chosen[b]) - (logprob(policy, [rejected[i] for i in b]) - ref_rejected[b]) loss = -F.logsigmoid(beta * margin).mean() opt.zero_grad() loss.backward() opt.step() if step % 20 == 0: print(f"step {step:>2} loss {loss.item():.3f} chosen ahead in {(margin > 0).float().mean().item():.0%} of the batch") policy.eval() torch.manual_seed(0) after = violations(policy) print(f"\n{'share of answers that...':<24} {'before':>7} {'after':>7}") for rule in before: print(f"{rule:<24} {before[rule]:>7.0%} {after[rule]:>7.0%}")
Read the table carefully, because it's typical. DPO learned one guideline decisively. Answers without the repair promise fell from 81% to 19%, on products it was never trained on. The other three didn't improve: all three got slightly worse. There are three reasons.
- DPO learns whatever separates the pairs most easily. The repair promise is a fixed phrase. In this dataset, it almost only appears on the chosen side: 130 chosen answers have it, and only 14 rejected ones. That's a clean, steady signal. Naming the right product is harder. The model has to copy a name it may rarely have seen in that spot.
- Preference training reshuffles habits the model already has. It can turn a phrase the model sometimes writes into one it nearly always writes. It can't add a skill the model lacks.
- Side effects are normal. Pushing probability toward chosen answers moves it away from other things too. So always test every rule you care about, not only the one you aimed at. Treat a regression like this as a side effect to evaluate on every criterion: adjust the data, the beta or the training length, and keep the change only if the trade-off is acceptable. Push harder and it gets dramatic. In testing, 100 steps at ten times this learning rate made the model mention the repair promise almost every time. But 96% of its answers named the wrong product, and 99% broke at least one guideline.
Show the answer
Check the other guidelines too, because preference training can make them worse
Pushing probability toward chosen answers moves it away from other things. Here the other three guidelines all got slightly worse, which you only see if you measure them.
DPO in practice
Why it caught on. DPO trains like SFT. You need a fixed dataset, a loss and two models: the policy and a frozen reference. Nothing is generated during training. It's far cheaper and more stable than PPO. Open models such as Zephyr, Tulu 3 and Meta's Llama 3 used it, or close variants, in post-training.
PPO-style RL still has an edge in one case: when you can generate fresh answers and score them during training. That's called online training. The model learns from its own current mistakes, not from a fixed set of pairs. Some teams get part of that benefit by running DPO in rounds, making new pairs each time.
The knobs. is often 0.1. It sets how far the policy may move from the reference. Smaller values let it chase the preferences harder. Learning rates are small: around , one millionth, for large models. One to three passes over the data are typical.
Variants you'll see:
- IPO copes better with noisy preferences, or ones where the same answer nearly always wins.
- KTO learns from single thumbs-up or thumbs-down labels instead of pairs.
- ORPO and SimPO drop the reference model.
Several also add an SFT term on the chosen answers, to keep the policy fluent.
With Hugging Face's TRL library, the dataset has prompt, chosen and rejected columns, like the practice API's:
from datasets import load_dataset from transformers import AutoModelForCausalLM, AutoTokenizer from trl import DPOConfig, DPOTrainer name = "Qwen/Qwen2.5-0.5B-Instruct" model, tokenizer = AutoModelForCausalLM.from_pretrained(name), AutoTokenizer.from_pretrained(name) dataset = load_dataset("json", data_files="brand_preferences.jsonl", split="train") trainer = DPOTrainer(model=model, args=DPOConfig(output_dir="brand-dpo", beta=0.1), train_dataset=dataset, processing_class=tokenizer) trainer.train() # the frozen reference copy is created for you
For marketers: preference tuning is good at building steady, recognizable habits. Always mention the guarantee, end with a call to action, keep it under 50 words. It won't make a model know your product line. That still needs your data in the prompt.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does DPO remove from the RLHF pipeline?