Index Ai Academy
Phase 6 · Modern LLM engineering · Post-training

DPO and direct preference methods

Preference training without a separate reward model.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Learning straight from the pairs
  2. The model's own score for an answer
  3. Turning scores into a loss
  4. Worked example: the loss for one pair
  5. A toy run: five possible answers
  6. DPO on a real model
  7. 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 π\pi (the Greek letter pi).
  • The reference model: a frozen copy of where the policy started, usually the SFT model. It's written πref\pi_{\text{ref}}, 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.

Quick check
What does one training example for DPO contain?
  1. A prompt and a numeric reward
  2. Only the best answer
  3. A whole conversation with ratings
  4. A prompt, a chosen answer and a rejected 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 yy standing for one answer:

r(y)=βlog⁡π(y)πref(y)+constr(y) = \beta \log \frac{\pi(y)}{\pi_{\text{ref}}(y)} + \text{const}

Here π(y)\pi(y) is the chance the policy writes answer yy. The bottom of the fraction is the same chance under the reference. β\beta (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 β=0.5\beta = 0.5. 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 runs right here in your browser, with nothing to install.

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, π∗(y)∝πref(y) er(y)/β\pi^*(y) \propto \pi_{\text{ref}}(y)\, e^{r(y)/\beta}.

Read it as: start from the reference's chances, then boost each answer by ee raised to its reward divided by beta. The sign ∝\propto 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 rr. 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.

Quick check
The reference gives an answer a chance of 0.1. After DPO, the policy also gives it 0.1. Leaving out the constant, what is its implicit reward?
  1. β
  2. ln 2 ≈ 0.693
  3. 0.1
  4. 0
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, −log⁡σ(rchosen−rrejected)-\log \sigma(r_{chosen} - r_{rejected}). Here σ\sigma (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:

  • ywy_w is the chosen answer (w for winner).
  • yly_l 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:

LDPO=−log⁡σ(β[(log⁡π(yw)−log⁡πref(yw))−(log⁡π(yl)−log⁡πref(yl))])\mathcal{L}_{\text{DPO}} = -\log \sigma\Big(\beta \big[\big(\log \pi(y_w) - \log \pi_{\text{ref}}(y_w)\big) - \big(\log \pi(y_l) - \log \pi_{\text{ref}}(y_l)\big)\big]\Big)

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 × marginloss
00.693
10.313
20.127
40.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.

Quick check
What does this print?
import math
gap = 0.0
print(round(-math.log(1 / (1 + math.exp(-gap))), 3))
  1. 0.0
  2. 1.0
  3. 0.5
  4. 0.693
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.

Try it now

DPO skips the reward model and learns straight from the pairs.

Quick check
At the very start of DPO training, the policy equals the reference. What is the loss for every pair?
  1. ln 2 ≈ 0.693, because the margin is 0 and −log σ(0) = ln 2
  2. 0, because nothing is wrong yet
  3. Infinity
  4. It depends on β
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

Worked example: the loss for one pair

Take β=0.5\beta = 0.5. 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))
In the lesson you can change this and run it.

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_ref holds the reference's chance for each answer.
  • quality is the hidden score the raters judge by.
  • The first loop builds the pairs: winners go in W, losers in L.
  • 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.)

Quick check
In the toy run, DPO never saw the quality numbers. How did its implicit rewards end up in the same order?
  1. The reference model already ranked the answers that way
  2. It read the quality array during training
  3. A separate reward model scored the answers
  4. From who won each pair: answers that win more often get pushed up, and ones that lose get pushed down
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 β=0.1\beta = 0.1 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.
Quick check
After DPO, the share of answers missing the repair promise fell from 81% to 19%. What should you do next?
  1. Nothing: the loss went down, so the model improved
  2. Check whether the reference model changed
  3. Re-test the repair promise on the training products only
  4. Check the other guidelines too, because preference training can make them worse
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. β\beta 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 10−610^{-6}, 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

Create a free account

Try a quiz question

What does DPO remove from the RLHF pipeline?

  1. The separate reward model and the sampling-based RL loop
  2. The preference data, since it learns from single answers
  3. The reference model, since it has no need to stay close to one
  4. Supervised fine-tuning, since it starts straight from a base model

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson