WORKBENCH

Preference tuning with DPO.

How do you train a model on "this answer is better than that one"?

Listen · 13 min0:00 / 12:46

Preference tuning teaches a model from pairs of answers where someone only said “this one is better than that one”, instead of from perfect answers someone had to sit down and write. DPO, short for Direct Preference Optimization, is the simplest popular way to do it: a single short formula you train with exactly like an ordinary round of extra training on examples.

It matters because judging is far cheaper than writing. You can pick the better of two explanations in three seconds; writing the best one yourself takes minutes and wears you out. For years, using those judgments meant a fragile, hardware-hungry training setup that many labs couldn’t get to work, and DPO collapsed it into a few lines of code. It’s also closer to your work than it looks: the math it starts from is the same “A should score above B” loss you’d use to train your cross-encoder ranker, and “derive DPO” is a standard interview question.

It builds on Train a tiny LM, for how a model scores a piece of text, and Why training needs so much memory, for why the older approach needed so much hardware. It uses LoRA and LLM judges as tools, and it hands off to RL with verifiable rewards, where the model learns from its own fresh attempts instead of a fixed pile of pairs.

Today

Come in with Train a tiny LM, for how a model scores text one token at a time and what a training loop looks like, and Why training needs so much memory, for the bytes-per-parameter bill. LoRA and LLM judges help too, since this lesson uses both as tools. Plan on one evening for the idea and the math, one for the build, and one for training and evaluation on the Spark.

The Problem

You want your relevance-labelling model to write better explanations. Not just the label, but a short reason a human reviewer can trust. You try the obvious thing first: write a few hundred perfect explanations yourself and fine-tune on them.

It goes badly in a way you didn’t expect. Writing a perfect explanation takes you four minutes. After forty of them you are tired, they all start to sound the same, and you are not sure your fortieth is better than what the model already writes. The model learns your tired style.

Then a colleague shows you two of the model’s explanations side by side and asks “which is better?”. You answer in three seconds, and you’re confident. You could do two hundred of those in the time it took to write twenty perfect answers. And your judgment of “better” is sharper than your ability to produce “best”.

That’s the whole motivation. Judging is easier than writing. A wine critic can’t make a great wine, but can reliably tell you which of two glasses is better. If you could train a model on “this one, not that one”, you’d get far more signal per hour of human time, and the signal would be about quality, not about copying one person’s phrasing.

The catch is that “A is better than B” is not a target you can do cross-entropy against. There is no single right answer to imitate. You need some way to turn a pile of comparisons into gradient updates. For five years the answer was reinforcement learning, and it was painful. In 2023 a paper showed that, with one algebra trick, the painful part disappears and what’s left is a loss function shorter than this sentence.

The Idea

What a preference pair looks like

A preference dataset is a list of triples. A promptThe input text the model responds to. Written x in the papers. x, and two responses to it: the one a person (or a strong model) liked better, y_w for “winner” or chosenThe preferred response in a pair. Written y_w., and the other, y_l for “loser” or rejectedThe less preferred response in a pair. Written y_l. It need not be bad, only worse than the chosen one..

{
  "prompt":   "Explain why the sky is blue to a ten-year-old.",
  "chosen":   "Sunlight is a mix of all colors. When it hits the air, blue light bounces around ...",
  "rejected": "The sky is blue because of Rayleigh scattering, which is inversely proportional to ..."
}

Two things to notice. The rejected answer is not wrong, it’s just worse for this audience. And nobody wrote a “perfect” answer: both came from a model, and a human only had to pick. That’s the economics. In InstructGPT the labellers ranked 4 to 9 model outputs per prompt at once, and every ranking of K answers gives K·(K−1)/2 pairs for free.

From comparisons to a score: Bradley-Terry

Before you can train on comparisons, you need a model of how comparisons come about. The standard one is from 1952, built for ranking chess players and sports teams.

Imagine every response has a hidden quality score, a single number, r(x, y), the rewardA single number saying how good a response y is for prompt x. Higher is better. In RLHF it comes from a learned reward model.. When a person compares two responses, they don’t always pick the higher one, they’re noisy. The Bradley-Terry modelA 1952 model of paired comparisons: the chance that A beats B depends only on the difference of their scores, through the sigmoid. Elo ratings in chess are built on the same idea. says the chance they pick A over B is the sigmoid of the score difference:

P(A beats B) = σ( r(A) − r(B) )        where σ(z) = 1 / (1 + e^(−z))

A difference of 0 means a coin flip. A difference of +2 means A wins about 88% of the time. A difference of +5, about 99%. Only differences matter, so adding 100 to every score changes nothing. Hold onto that last fact, it’s the trick that makes DPO work.

Train a network to output r, and the loss is just “maximize the probability of the comparisons you actually saw”:

loss_RM = − log σ( r(x, y_w) − r(x, y_l) )

That’s logistic regression on the difference of two scores. It is exactly the loss you’d use to train a pairwise ranker, which you’ve done: a cross-encoder trained with a pairwise loss on (query, better doc, worse doc) is a Bradley-Terry reward model with a different name.

The classic pipeline: SFT, reward model, PPO

InstructGPT’s recipe has three stages.

  1. SFT. supervised fine-tuningSFT. Ordinary next-token training on example (prompt, good response) pairs written by people. Teaches the format and basic behavior. on human-written demonstrations. You get a model that answers like an assistant, roughly. Call it π_SFT.
  2. Reward model. Sample several answers from π_SFT, have people rank them, train a model (InstructGPT used a 6B one) with the Bradley-Terry loss above. It reads a prompt and a response and prints one number.
  3. RL. Let the model write answers, score each with the reward model, and nudge the model toward higher-scoring answers. The algorithm is PPOProximal Policy Optimization (Schulman et al., 2017). A reinforcement learning method that improves a policy using sampled attempts and their rewards, while clipping each update so the policy can’t change too much in one step..

In RL language the language model is now a policyThe thing that chooses actions. For a language model: the model itself, choosing the next token. Written π (pi), and πθ when we mean the version with trainable weights θ., written πθ. Stage 3 maximizes this:

maximize over πθ:   E[ r(x, y) ]  −  β · KL( πθ(·|x) ‖ π_ref(·|x) )
                    ↑ be good        ↑ but don't wander far from where you started

The second term is the important one. π_ref is the reference modelA frozen copy of the model from before preference training (usually the SFT model). The trained model is penalized for drifting away from it., a frozen copy of the SFT model. KL divergenceKullback-Leibler divergence. A measure of how different two probability distributions are; 0 when they are identical. Here: how differently the trained model and the reference spread probability over responses. measures how far the policy’s distribution has moved from it. β is the price of moving.

Why have that term at all? Because the reward model is a learned approximation, trained on a few tens of thousands of comparisons. Push a policy hard against it and the policy finds the reward model’s blind spots: answers that score high and are garbage. This is reward hackingWhen the model gets high reward by exploiting flaws in the reward signal instead of doing what the reward was meant to measure., and the KL leash is the main defence. Stay close to a model that already writes sensible text, and you can only exploit the reward model so much.

FIG_001[ RLHF WITH PPO VS DPO ]
RLHF (INSTRUCTGPT, 2022)1 · SFTdemonstrations2 · REWARD MODELpairwise B-T loss3 · PPO LOOP · FOUR MODELS IN MEMORYPOLICYTRAINEDREFERENCEFROZENREWARDFROZENVALUETRAINEDsample answers → score → KL to reference→ advantages from value → clipped updateREPEAT: GENERATION IN THE TRAINING LOOPDPO (RAFAILOV ET AL., 2023)1 · SFT(same)PAIRS ONLYno reward model2 · ONE LOSS · TWO MODELSPOLICYTRAINEDREFERENCEFROZENlog-probs of fixedpairs, no sampling
Top: the InstructGPT pipeline. Stage 3 keeps four models busy at once: the policy being trained, a frozen reference for the KL penalty, a frozen reward model, and a value model that is also being trained. Bottom: DPO skips the reward model and the sampling loop entirely and trains on the pairs directly, with only the policy and a frozen reference.

Why PPO is heavy

Look at stage 3 in Figure 001 and count.

Put numbers on it, using the 16-bytes-per-parameter rule for anything trained with Adam in mixed precision and 2 bytes per parameter for anything frozen in bf16. For a 7B model with all four networks the same size: two trained models at 16 × 7B ≈ 112 GB each, two frozen ones at 14 GB each. About 250 GB before a single activation. That’s two Sparks’ worth of memory, and real setups add a fast generation engine on top, because stage 3 also has to sample answers in every step, which is the slow, memory-bound decoding from Why ~17 tokens per second?.

Then there’s the instability. Each PPO step depends on answers sampled from the current policy, scored by a learned reward, credited by a learned value model that is itself chasing a moving target. There are a dozen knobs (clip range, KL coefficient, value loss weight, temperature, reward scaling), and wrong settings don’t raise an error. They give you a model that slowly turns into repetitive nonsense the reward model loves.

Hold that picture. Now the trick.

The key insight: the best policy has a formula

Go back to the objective: be good, but pay β for every unit of drift from the reference. Forget neural networks for a moment and ask a pure math question. Over all possible policies, which one scores best on this objective?

It has an exact answer:

π*(y|x) = π_ref(y|x) · exp( r(x,y) / β ) / Z(x)

In words: take the reference model’s probabilities and tilt them by the reward. Every possible response gets its old probability multiplied by exp(reward / β). Good answers get boosted, bad ones shrunk. Then divide by Z(x), the partition functionA normalizing number, Z(x): the sum of π_ref(y|x)·exp(r(x,y)/β) over every possible response y. Dividing by it makes the probabilities add up to 1. For language it’s a sum over every possible text, so nobody can compute it., so everything still adds up to 1.

Why is this the optimum? A short, honest sketch. You can rewrite the objective as −β · KL(π ‖ π*) + β·log Z(x). The second piece doesn’t depend on π at all. KL is never negative and is zero only when the two distributions are equal. So the best you can do is make π = π*. (Appendix A.1 of the DPO paper does the algebra in full, and it’s worth twenty minutes with a pen.)

β shows up as a temperature. Small β: the reward is divided by a small number, the tilt is extreme, and π* piles all its probability on the highest-reward answers. Large β: the tilt is gentle, and π* stays close to the reference.

This formula was known well before DPO. It’s useless on its own, because Z(x) is a sum over every possible piece of text. You can’t sample from π* or evaluate it. That’s why people did RL instead.

Flip it around

Here’s the move that makes the paper. Take that formula and solve it for the reward instead of the policy. Take the log of both sides and rearrange:

r(x,y) = β · log( π*(y|x) / π_ref(y|x) )  +  β · log Z(x)

Read it in words: the reward of a response equals β times how much more likely the optimal policy makes it, compared to the reference, plus a constant that depends only on the prompt.

If the optimal policy is twice as likely as the reference to say y, then y’s reward is β·log 2 plus the constant. If it’s ten times less likely, the reward is β·log(1/10) plus the constant. The policy is the reward model, read off through a log-ratio. Hence the paper’s title.

We still have that nasty Z(x). But remember what Bradley-Terry cares about: only differences between two responses’ rewards. And both responses in a pair share the same prompt x, so they share the same β·log Z(x). In the difference, it cancels.

Predict firstwrite it down

Plug the “reward in terms of policy” formula into the Bradley-Terry probability, σ(r(x,y_w) − r(x,y_l)). Write down what’s left. Which terms disappear, and why can you now compute everything that remains?

Reveal
r(x,y_w) − r(x,y_l) = β·log(π*(y_w|x)/π_ref(y_w|x)) + β·log Z(x)
                    − β·log(π*(y_l|x)/π_ref(y_l|x)) − β·log Z(x)

The two β·log Z(x) terms cancel, because both answers answer the same prompt. What’s left is four log-probabilities of specific, known texts: the chosen and rejected answers under the policy and under the reference. You can compute each one with a single forward pass, the same way you compute a training loss. No sum over all possible texts, no sampling.

The DPO loss, step by step

Now assemble it. Replace the unknown π* with the model we’re training, πθ, and train it to make the observed preferences likely. Step by step:

  1. How much did the policy move the chosen answer? log πθ(y_w|x) − log π_ref(y_w|x). Positive means the policy now likes the chosen answer more than the reference did. Call it a.
  2. How much did it move the rejected answer? log πθ(y_l|x) − log π_ref(y_l|x). Call it b.
  3. Multiply each by β. These are the implicit rewardβ times the log-ratio of policy to reference for one response: β·(log πθ(y|x) − log π_ref(y|x)). DPO’s stand-in for the reward model’s score. It’s only meaningful compared with another response to the same prompt.s: β·a for the chosen, β·b for the rejected. They’re what the reward model would have said, read straight off the policy.
  4. Take the difference. β·(a − b), the reward margin. Positive means the policy agrees with the human.
  5. Squash with the sigmoid. σ(β·(a − b)) is the probability, under Bradley-Terry, that the policy’s implicit reward model agrees with the label.
  6. Take minus the log. That’s the loss for one pair. Average over the batch.
L_DPO = − log σ( β · [ (log πθ(y_w|x) − log π_ref(y_w|x)) − (log πθ(y_l|x) − log π_ref(y_l|x)) ] )

That’s it. It’s the reward-model loss from earlier, with the reward model replaced by a ratio of two language models. No sampling, no value model, no reward model, no RL loop. Two forward passes on the policy (chosen and rejected), two on the frozen reference, one backward pass.

FIG_002[ FOUR LINES OF ALGEBRA ]
1 · OBJECTIVEmax E[r(x,y)] − β·KL(π ‖ π_ref)be good, stay close2 · EXACT OPTIMUMπ*(y|x) = π_ref(y|x) · exp(r/β) / Z(x)tilt reference by reward3 · SOLVE FOR THE REWARDr = β·log(π*/π_ref) + β·log Z(x)policy IS a reward model4 · INTO BRADLEY-TERRYσ(r_w − r_l): the β·log Z(x) terms cancelsame prompt, same ZDPO LOSS−log σ(β·[(log πθ_w − log πref_w) − (log πθ_l − log πref_l)])
The whole derivation. Start from the KL-constrained objective, write down its exact optimum, solve that for the reward, and substitute into Bradley-Terry. The intractable normalizer Z(x) cancels because both answers share a prompt, which leaves a loss you can compute from four log-probs.

What the loss actually does

The loss only sees two numbers, a and b (the two log-ratios), plus β. So you can draw it completely. Play with it. Start at “Step 0”, where the policy is an exact copy of the reference. Then try the other presets, and move β.

Predict firstwrite it down

At the very first training step, the policy is a copy of the reference. What’s the loss on every pair, to three decimal places? What fraction of pairs count as “correct” (chosen reward greater than rejected)?

Reveal

Both log-ratios are exactly 0, so the margin is 0, σ(0) = 0.5, and the loss is −log 0.5 = ln 2 ≈ 0.693 on every pair, regardless of β. Accuracy, if you count “chosen reward strictly greater than rejected”, is 0%: every pair is a tie. (TRL and most dashboards use strict >.) So the first logged point of any DPO run should read loss 0.693, accuracy 0, margin 0. If it doesn’t, the policy and reference aren’t the same model at step 0, and the usual culprit is dropout, which you’ll break on purpose later.

FIG_003[ THE DPO LOSS FOR ONE PAIR ]
LOSS OVER (a, b)a = bLOSS = ln 2BOTH FELLCHOSEN UPREJECTED DOWNWRONG WAYa: CHOSEN LOG-RATIO →−6+6b: REJECTED LOG-RATIO →+6−6DARK = HIGH LOSS = BIG PUSHARROW = DIRECTION OF ONE STEPLOSS VS MARGIN a − bln 2−120+1240THE GRADIENT, IN WORDSraise log π(chosen) by w·lrlower log π(rejected) by w·lrw = β·σ(−β(a−b)) = 0.050same direction everywhere;only the size changes
try
margin a−b -implicit rewards β·a / β·b -reward margin -
P(chosen wins) σ(β(a−b)) -loss -push w -
a is how much the policy raised the chosen answer's log-prob relative to the reference, b the same for the rejected answer. The loss depends only on a − b, so every diagonal line is a contour. The gradient always points the same way, chosen up and rejected down by equal amounts; only its size changes. Nothing in the loss cares whether you get there by raising the chosen answer or by sinking both.

Four things the figure makes obvious.

1. The gradient is a weighted “push chosen up, push rejected down”. Differentiate the loss and you get:

∇L = − β · σ( r̂_l − r̂_w ) · [ ∇ log πθ(y_w|x) − ∇ log πθ(y_l|x) ]

where r̂ is the implicit reward. The bracket is “make the chosen answer more likely, the rejected one less likely”, which is ordinary log-likelihood gradients with a plus and a minus. The weight σ(r̂_l − r̂_w) is the probability the policy currently has the pair wrong. Pairs it already gets right by a wide margin get almost no push. Pairs it gets wrong get pushed hardest. That weight is what separates DPO from just doing “SFT on chosen, unlikelihood on rejected”, which the paper tried and found makes the model degenerate.

2. Only the difference matters. Every point on a diagonal has the same loss. The point (+2, 0), “chosen up, rejected unchanged”, and the point (−4, −6), “both fell, rejected fell more”, are equally good to DPO. That will matter a lot in Break It.

3. The loss never reaches zero. It keeps rewarding a bigger margin forever. On a pair with a clear winner, DPO will happily push a − b toward infinity unless something stops it. In practice what stops it is that you train for one or a few epochs at a small learning rate.

4. β sets the scale. Move the β slider. With small β, the same log-ratio margin earns a small reward margin, so the loss stays high and the push stays strong for longer. The policy has to move further from the reference before the loss is satisfied. With large β, a tiny margin already saturates the sigmoid, the push dies off quickly, and the policy stays close to the reference.

What β means

In the original objective, β was the price per unit of drift: big β, stay close to the reference. In the loss, the same β multiplies the log-ratios. The two views agree. To reach a reward margin of, say, 2 (which Bradley-Terry reads as “88% sure”), the log-ratio margin has to be 2/β. At β = 0.5 that’s 4 nats of movement. At β = 0.1 it’s 20.

Typical values are 0.01 to 0.5, with 0.1 the default in the DPO paper and in TRL. Lower β means more aggressive changes to the model and more risk of drifting into weird text; higher β means gentler changes that may not move behavior enough. It is the single most important hyperparameter after the learning rate, and the two interact: a learning rate that’s fine at β = 0.1 can wreck the model at β = 0.01.

Two honest caveats. First, DPO keeps the model “close to the reference” only on the data you train on. The KL term in RLHF is measured on answers the policy itself writes, everywhere. DPO’s leash only applies through the pairs, so on texts far from your dataset the model can drift in ways nothing measured. Second, the derivation assumed we reach the exact optimum π*. A real network trained for one epoch on a few thousand pairs does not. The algebra explains what the loss is aiming at, not where it lands.

The implicit reward, and what to watch

Every DPO step hands you, for free, the numbers that a reward model would have produced. Libraries log them, and they are your dashboard:

MetricWhat it isHealthy run
rewards/chosenmean β·a over the batchstarts at 0; may go up or down a little
rewards/rejectedmean β·bstarts at 0; goes down
rewards/marginsmean β·(a − b)starts at 0; rises steadily
rewards/accuraciesfraction of pairs with β·a > β·bstarts at 0 (ties); rises fast, then plateaus
logps/chosen, logps/rejectedmean sequence log-prob under the policywatch these: see Break It
loss−log σ(margin) averagedstarts at 0.693, falls

Accuracy means “the policy’s implicit reward model agrees with the label on this pair”. On training pairs it eventually nears 100% and tells you little; on held-out pairs it tells you whether the preference generalizes.

Sequence log-probs: where most bugs live

The loss needs log πθ(y|x), the log-probability of a whole response given its prompt. Language models give you probabilities one token at a time, so by the chain rule:

log π(y|x) = log p(y₁|x) + log p(y₂|x, y₁) + ... + log p(y_T|x, y₁..y_{T−1})

A sequence log-probThe log-probability a model assigns to a whole response: the sum of the log-probs of each of its tokens, each conditioned on the prompt and the tokens before it. is the sum of token log-probs, over the response tokens only. Three details, and every one of them has bitten real implementations.

FIG_004[ WHICH TOKENS COUNT ]
INPUT IDSuserNameacolorasstDeepblue.<eot>pad◄──────── PROMPT x ─────────►◄──── RESPONSE y ────►LOGITS AT t PREDICT TOKEN t+1MASK000011110Σ log p(token) OVER MASK = 1= log π(y|x), one numberprompt tokens: given, not scoredpadding: not real text
Prompt and response are concatenated into one sequence. The logits at position t predict the token at position t + 1, so shift by one. Then keep only the response tokens, including the end-of-turn token (the model should learn when to stop), and drop the prompt and the padding. The sum of what's left is log π(y|x).

Shift by one. The model’s output at position t is its guess for the token at t + 1. So you compare logits[:, :-1] against input_ids[:, 1:], and the mask shifts with the labels. Forget the shift and you score every token against itself, which gives a large, meaningless number that still trains, badly.

Mask the prompt. The prompt is shared by chosen and rejected, so including it seems harmless: it cancels in a − b. But only if both sequences keep exactly the same prompt tokens, and it pollutes the logps/chosen numbers you’ll be watching. Score only what the model wrote. Tokenize prompt and response separately and concatenate the ids; tokenizing the joined string can merge tokens across the boundary.

Sum, don’t average. DPO as derived uses the true log-probability of the response, which is a sum. Averaging over tokens is a different objective (it’s what SimPO and ORPO use, on purpose). Summing has a consequence you should know about: a long response has a much more negative log-prob than a short one, and its log-ratio can move a lot more in absolute terms, simply because there are more tokens to move. That’s one root of the length problem below.

Also include the end-of-turn token in the response. It’s how the model learns that the chosen answer stops where it stops.

What goes wrong

Three failure modes you will see, in increasing order of sneakiness.

Overfitting. DPO usually runs 1 to 3 epochs at small learning rates (for full fine-tuning, 5e-7 is common, 10 to 100 times lower than for SFT). Run longer and training accuracy hits 100%, margins keep climbing, held-out accuracy stalls, and the text gets strange. That’s point 3 above: the loss always wants a bigger margin. The IPO paper (Azar et al., 2023) makes it precise: when preferences in the data are near-deterministic, DPO’s optimum ignores β and drifts arbitrarily far from the reference.

Both log-probs fall. Watch logps/chosen. You’d expect it to go up: you’re training the model to prefer the chosen answer. Often it goes down, along with the rejected one, just more slowly. In Figure 003 that’s the bottom-left region: loss looks great, both answers are less likely than before, and the probability went somewhere else, to responses that are in neither column. Sometimes that’s harmless. Sometimes the “somewhere else” is worse than both. This has been studied under names like likelihood displacement, and you’ll make it happen on purpose in Break It, dramatically, with a tiny model.

Length exploitation. In many preference datasets, the chosen answer is longer on average than the rejected one, because raters (human and model) like thorough answers. DPO learns the cheapest feature that separates chosen from rejected, and “longer” is very cheap. Train on UltraFeedback and the average response length of your model will very likely go up. A judge that also likes length (see LLM judges) will then report a big win that is partly just more words. Park et al. (2024) measured this directly for DPO; benchmarks like AlpacaEval added a length-controlled win rate for the same reason.

Predict firstwrite it down

You DPO-train Qwen2.5-0.5B-Instruct on 5,000 UltraFeedback pairs. Before training, its mean answer length on 100 held-out prompts is 300 tokens. Guess the mean after training. Then guess how a pairwise LLM judge’s win rate (DPO vs base) changes if you truncate both models’ answers to the same length before judging.

Reveal

There’s no number to look up, it depends on your β, learning rate and steps, so measure it. The direction is the prediction: longer, often noticeably (a 10-50% increase would not be surprising), because chosen answers in UltraFeedback skew longer than rejected ones and length is an easy signal for the loss to pick up. When you equalize lengths, the win rate usually drops toward 50%. Whatever is left is the part of the improvement that isn’t just more words. If your run shows no length increase at all, check that it actually moved the model (margins rising, held-out accuracy above 50%).

The family: IPO, KTO, SimPO, ORPO

DPO started a small industry of variants. You don’t need them all, but you should know what each one changed and why. All four below are real papers; read the original before trusting a one-line summary, including this one.

VariantWhat it changesWhy
IPO (Azar et al., 2023)Replaces −log σ(β·h) with a squared loss that pulls the log-ratio margin h toward a fixed target, 1/(2β) in TRL’s convention, instead of pushing it to infinityFixes the overfitting-to-deterministic-preferences problem: margins can’t grow forever
KTO (Ethayarajh et al., 2024)Works on single responses labelled “good” or “bad”, no pairs. Loss shaped after prospect theory from behavioral economics, with a reference point estimated from the batchThumbs-up/thumbs-down data is far more plentiful than paired comparisons
SimPO (Meng, Xia, Chen, 2024)No reference model. Uses the average token log-prob as the implicit reward, and adds a target margin γ inside the sigmoidSaves the reference model’s memory and compute; length normalization counters the length bias of summed log-probs
ORPO (Hong, Lee, Thorne, 2024)No reference model and no separate SFT stage. Adds a log-odds-ratio penalty between chosen and rejected to the ordinary SFT loss on the chosen answerDoes SFT and preference tuning in one pass

Notice the pattern. Two of them drop the reference model, which removes the “stay close” anchor and replaces it with something else (length normalization, or the SFT loss). Several practical recipes also add an SFT-style term on the chosen answers to DPO itself, which directly fights the “both log-probs fall” problem. There’s no consensus winner: published comparisons depend heavily on tuning, and the variants are close to each other when each is well tuned. DPO is still the baseline everyone compares against, which is why you implement it.

One more distinction worth having in your head. Everything above is offlineTraining on a fixed dataset collected beforehand, usually from a different model. The opposite of online, where the model being trained generates the data it learns from.: the pairs were written by other models and labelled once. PPO is onlineThe model being trained generates fresh samples during training, and those get scored and learned from. PPO and GRPO are online.: it learns from its own fresh samples. A popular middle ground is iterative DPO: generate pairs from your current model, label them with a judge or reward model, run DPO, repeat. That brings back some of the “learn from your own mistakes” benefit of RL without PPO’s machinery. The next lesson, GRPO, goes fully online.

Build It

Everything goes in ~/workbench/dpo/. The core (Tasks 1 and 2) runs on your Mac in seconds. Training (Tasks 4-6) runs on the Spark. Keep one environment for both: torch, transformers, datasets, and later trl and peft.

Set up the environment and look at the dataAgent: fine
mkdir -p ~/workbench/dpo && cd ~/workbench/dpo
uv venv && source .venv/bin/activate
uv pip install torch transformers datasets accelerate
# later, for the parity check and LoRA:
uv pip install trl peft

On the Spark, use the PyTorch build that matches its CUDA and ARM64 (NVIDIA’s containers or wheels; see the containers lesson). The dataset is HuggingFaceH4/ultrafeedback_binarized, the version of UltraFeedback used to train Zephyr: about 60k prompts, each with a chosen and a rejected response picked from four model completions using GPT-4 ratings. The preference splits are train_prefs and test_prefs. Check the dataset card for the exact column names and sizes before you rely on them.

# peek.py
from datasets import load_dataset
ds = load_dataset("HuggingFaceH4/ultrafeedback_binarized", split="train_prefs")
print(ds)
ex = ds[0]
print(ex["prompt"][:300])
print("CHOSEN:", ex["chosen"][-1]["content"][:300])     # chosen/rejected are chat message lists
print("REJECTED:", ex["rejected"][-1]["content"][:300])
print(ex["score_chosen"], ex["score_rejected"])

Read twenty pairs. Would you have picked the same winner every time? Write down how many you disagree with. That number is a rough floor on the noise in your labels, and it’ll come back when you see held-out accuracy plateau.

Write the sequence log-prob functionHands only

This is the part most likely to be subtly wrong, so it’s yours. Inputs are the model’s logits for a batch of concatenated (prompt + response) sequences, the input ids, and a mask that is 1 on response tokens and 0 on prompt and padding.

# dpo_core.py
import torch
import torch.nn.functional as F

def sequence_logps(logits: torch.Tensor,     # (B, T, V)
                   input_ids: torch.Tensor,  # (B, T)
                   response_mask: torch.Tensor  # (B, T), 1 on response tokens
                   ) -> torch.Tensor:        # (B,)
    """Sum of log p(token) over response tokens only.
    Remember: logits at position t predict the token at t + 1."""
    ...

Hints:

  • Shift: drop the last logit position and the first label position. Shift the mask the same way as the labels.
  • torch.log_softmax(..., dim=-1) then .gather(-1, labels.unsqueeze(-1)) picks out the log-prob of the actual token.
  • Do the softmax in float32 (logits.float()). In bf16 a sum of hundreds of small log-probs loses precision, and you will compare these numbers between two models.
  • Memory: log_softmax over a 150k-token vocabulary for 2 × 1024 positions is about 1.2 GB in fp32 for one sequence pair. Fine on the Spark. If it isn’t for you, compute logits.gather(...) − logsumexp(logits), which avoids materializing the whole log-softmax.

Then write the batch builder that produces those masks. For each example: render the prompt with the chat template (apply_chat_template with add_generation_prompt=True and tokenize=False), tokenize prompt and response separately, append the end-of-turn token to the response, concatenate the ids, build the mask, right-pad. Truncate the response (not the prompt) if the total exceeds your max length.

Test it by hand with a fake model: random logits of shape (1, 5, 7), ids [2, 4, 1, 3, 6], mask [0, 0, 1, 1, 1]. Compute the answer manually as log_softmax(logits[0, t−1])[ids[t]] summed over t = 2, 3, 4, and assert your function matches to 1e-6.

Write the DPO loss and check it by handHands only
def dpo_loss(policy_chosen_logps: torch.Tensor,    # (B,)
             policy_rejected_logps: torch.Tensor,  # (B,)
             ref_chosen_logps: torch.Tensor,       # (B,)
             ref_rejected_logps: torch.Tensor,     # (B,)
             beta: float = 0.1):
    """Return (loss, chosen_rewards, rejected_rewards).
    loss is the batch mean of −log σ(β·(a − b)); rewards are β·a and β·b, detached."""
    ...

Hints:

  • Use F.logsigmoid(z), not torch.log(torch.sigmoid(z)). For a large negative z the sigmoid underflows to 0 and the log becomes −inf. logsigmoid stays finite.
  • The reference log-probs must carry no gradient: compute them under torch.no_grad() (or .detach() them).

Now check it by hand, on paper, before running it. One pair, β = 0.1:

policyreference
chosen log-prob−10.0−10.5
rejected log-prob−12.0−11.5

Work out a, b, the two implicit rewards, the loss, and the gradient of the loss with respect to policy_chosen_logps. Then run your function with requires_grad=True inputs and compare.

Answer (check after you’ve done it on paper)

a = −10.0 − (−10.5) = +0.5, b = −12.0 − (−11.5) = −0.5. Rewards: β·a = +0.05, β·b = −0.05. Margin β·(a − b) = 0.1. Loss = −log σ(0.1) = log(1 + e^(−0.1)) ≈ 0.6444. The gradient with respect to the chosen log-prob is −β·σ(−0.1) ≈ −0.1 × 0.475 = −0.0475, and +0.0475 for the rejected one. Equal and opposite, as Figure 003 promised. I ran exactly this in PyTorch and got 0.644397 and ∓0.047502.

Also check: when all four inputs are equal, the loss is 0.6931 for any β.

Watch DPO on a toy modelHands only

Before spending Spark time, run the whole loop on something that trains in a minute on your Mac. Make a tiny GPT-2 from a config (no download): GPT2Config(vocab_size=50, n_positions=32, n_embd=64, n_layer=2, n_head=2), with all three dropout settings at 0. Generate 64 synthetic pairs: a random 4-token prompt, and a random 6-token response where the chosen and rejected versions are identical except the last token (token 1 for chosen, token 2 for rejected).

  1. Train it briefly with SFT (your sequence_logps, negated) on both chosen and rejected, so it starts out with no preference. That’s your reference. Deep-copy it into the policy.
  2. Run 300 steps of DPO (AdamW, lr 1e-4, β 0.1, full batch). Every 25 steps print: loss, accuracy, mean reward margin, mean logps/chosen and logps/rejected for the policy and the reference.

Predict before running: does the chosen log-prob go up?

You’ll use this exact toy again in Break It, so keep it in toy.py.

Train Qwen2.5-0.5B-Instruct with your DPO on the SparkAgent: review after

Now the real thing, with your own loop. Model: Qwen/Qwen2.5-0.5B-Instruct (small enough to full-fine-tune comfortably on the Spark; Qwen2.5-1.5B-Instruct also fits). Load it twice: policy in training mode, reference frozen in eval mode with requires_grad_(False), both in bf16. Disable dropout in the policy too (model.config dropout fields, or set every nn.Dropout to p=0), or step 0 won’t read 0.693.

Data: 5,000 pairs from train_prefs, filtered to prompt + longest response under 1,024 tokens. 500 pairs from test_prefs for held-out accuracy.

The loop shape:

for batch in loader:                        # chosen and rejected for the same prompts
    pol_c = sequence_logps(policy(batch.chosen_ids,   attention_mask=...).logits, ...)
    pol_r = sequence_logps(policy(batch.rejected_ids, attention_mask=...).logits, ...)
    with torch.no_grad():
        ref_c = sequence_logps(ref(batch.chosen_ids, ...).logits, ...)
        ref_r = sequence_logps(ref(batch.rejected_ids, ...).logits, ...)
    loss, rc, rr = dpo_loss(pol_c, pol_r, ref_c, ref_r, beta=0.1)
    ...  # backward, clip grad norm to 1.0, step, log every 10 steps

Starting hyperparameters (tune from here): β 0.1, AdamW with lr 5e-7 for full fine-tuning, effective batch 32 pairs (micro-batch 4 with gradient accumulation), warmup 10% of steps, 1 epoch. That’s about 156 optimizer steps. Log the six metrics from the table in The Idea, plus held-out accuracy and margin every 50 steps.

Two optional upgrades, each a real engineering lesson:

  • Precompute the reference. The reference never changes, so compute ref_c and ref_r for the whole dataset once and store them. Then the reference model doesn’t need to be in memory during training. Compare wall-clock time.
  • LoRA. Wrap the policy with your adapter from LoRA (or peft). Nice trick: with LoRA, the reference model is the base model with the adapter switched off, so you need only one copy of the weights. LoRA wants a much larger learning rate, something like 5e-6 to 5e-5. Check what the LoRA paper and TRL docs suggest.

When it runs, ask an agent to review the loop for the classic bugs: reference computed with gradients, shift/mask off by one, padding counted in the sum, chosen and rejected batches misaligned after shuffling.

Evaluate before and after with a judge, and measure lengthAgent: review after

Hold out 150 prompts from test_prefs that you never trained on. Generate one answer per prompt from the base model and from your DPO model, same decoding settings (greedy, or temperature 0.7 with a fixed seed, max 512 new tokens).

Then, using what you built in LLM judges:

  1. Pairwise judge: base vs DPO, each pair judged twice with the order swapped. Count a win only if the judge agrees in both orders; otherwise call it a tie.
  2. Record the mean and median length in tokens of each model’s answers.
  3. Rejudge with both answers truncated to the shorter one’s length (cut at a sentence boundary). This crude length control tells you how much of the win is length.
  4. Read 20 pairs yourself. Does your verdict agree with the judge?

Have an agent review the judge prompt and the swap logic, not the conclusion.

Parity with TRL's DPOTrainerAgent: explain only

Train the same model, same 5,000 pairs, same hyperparameters with TRL. The shape (TRL’s API moves fast; check the current docs for argument names, for example the tokenizer argument has been renamed processing_class in recent versions):

from trl import DPOConfig, DPOTrainer
cfg = DPOConfig(output_dir="trl-dpo", beta=0.1, learning_rate=5e-7,
                per_device_train_batch_size=4, gradient_accumulation_steps=8,
                num_train_epochs=1, warmup_ratio=0.1, max_length=1024,
                bf16=True, logging_steps=10, loss_type="sigmoid")
trainer = DPOTrainer(model=model, ref_model=None, args=cfg,
                     train_dataset=train, eval_dataset=test, processing_class=tokenizer)
trainer.train()

ref_model=None makes TRL create a frozen copy for you. Compare three things:

  1. Step 0. Both should log loss 0.693, margin 0.
  2. The core, exactly. Take one fixed batch of 8 pairs, and compute logps/chosen and logps/rejected with your function and with TRL’s (call its batch-logps method directly, or compare to what it logs on that batch). They should agree closely. If they don’t, the difference is almost always tokenization: chat template, whether the end-of-turn token is included, truncation side.
  3. The curves. Overlay your loss, margin and accuracy curves on TRL’s. They won’t be identical (data order, padding, numerics), but they should have the same shape and similar values.

Ask an agent to explain any mismatch you can’t find, but find the line yourself.

Done when
  • sequence_logps and dpo_loss pass your hand checks (the 0.6444 example, and 0.6931 at equal inputs).
  • The toy run prints its curves and you can explain every column.
  • The 0.5B run logs all six metrics, starts at loss 0.693, and you have held-out accuracy.
  • The judge comparison is done in both orders, with length recorded and a length-controlled rerun.
  • Your log-probs match TRL’s on a fixed batch, or you can name exactly why they don’t.

Measure

Fill this in for your 0.5B run (and the TRL run alongside it).

MetricPredictionYour DPOTRL DPO
Loss at step 0
Train loss at end
Held-out reward accuracy
Held-out reward margin
logps/chosen change (end − start)
logps/rejected change
Mean answer length, base → DPO (tokens)
Judge win / tie / loss vs base (both orders)
Same, lengths equalized
Wall clock, ref live vs precomputed

And for the toy (Task 3), here’s my run on the Mac: a 2-layer GPT-2 with random init, 64 pairs that differ only in their final token, β 0.1, lr 1e-4.

StepLossAccuracyReward marginlogps/chosenlogps/rejected
00.6930.000.00−0.86−0.85
500.5901.000.22−17.5−19.7
1000.5591.000.29−19.1−22.0
3000.4391.000.60−20.3−26.2

Read that row at step 50 again. Accuracy is perfect and the loss is falling exactly as designed, and the chosen response went from a log-prob of −0.86 (probability 0.42) to −17.5 (probability about 2.5 in 100 million). The model “prefers” the chosen answer only in the sense that it now finds the rejected one even more absurd. That’s the bottom-left corner of Figure 003, measured. Most of the collapse happened in the first 20 steps.

FIG_005[ TOY RUN: WHERE THE PROBABILITY WENT ]
0−10−200STEP 150300SEQUENCE LOG-PROB (NATS)DPO · CHOSENDPO · REJECTED+SFT · CHOSEN+SFT · REJECTEDREFERENCE: −0.86 / −0.85
Measured on the toy from Task 3: sequence log-probs of the chosen (solid) and rejected (dashed) responses over 300 DPO steps. Ink: plain DPO, where both crash and the chosen answer ends up about 20 nats less likely than under the reference. Blue: the same run with an SFT term on the chosen answers added (Break It 2), where chosen rises toward log-prob 0 and only the rejected one falls.

What the real numbers should look like, roughly (these are expectations, not measurements; check yourself):

Step 0: exactly 0.693. Anything else is a bug: dropout on, policy and reference not identical, or mismatched batches.

Held-out accuracy: clearly above 50%, well below 100%. Somewhere in the 60-80% range is plausible for UltraFeedback with a small model and one epoch. Your Task 0 disagreement count is a rough ceiling: if you disagreed with a fifth of the labels, don’t expect the model to agree with more than about 80%.

logps/rejected falls clearly. logps/chosen may fall too. On real data the drop is usually much milder than the toy, but it’s common for chosen to end slightly below where it started. If chosen falls by tens of nats, your learning rate is too high or β too low.

Length goes up. By how much depends on how far you pushed. If the judge’s win rate drops a lot when you equalize lengths, a big part of your “improvement” was length.

Parity: same story, small numeric differences. If your step-0 sequence log-probs differ from TRL’s by more than about 0.1 nats per sequence, look at tokenization before anything else.

Break It

1. Turn dropout back on. Rerun the toy with GPT-2’s default dropout (0.1). I got a step-0 loss of 0.696 instead of 0.693 and accuracy 0.39 instead of 0. With dropout, the policy (in training mode) and the reference (in eval mode) are different models even at step 0, so the log-ratios are random noise from the first step. TRL disables dropout by default for exactly this reason. Small effect on a toy; on a real run it’s noise injected straight into every margin.

2. Fix the falling log-probs. Add an SFT term on the chosen responses to the toy’s loss: loss = dpo_loss + α · (−logps_chosen / response_length), with α = 1. In my run, chosen went from −0.86 up to −0.08 (probability 0.92) over 300 steps, rejected still fell to −5.0, and accuracy stayed at 1.00. The SFT term anchors the absolute level of the chosen answer, which DPO alone never looks at. This is the idea behind several published recipes (an NLL term in iterative reasoning DPO, the DPO-Positive loss in the Smaug paper). The trade-off: you’re back to imitating the chosen text, which is what you were trying to avoid in The Problem, so keep α modest on real data.

3. Forget the prompt mask. Change sequence_logps to sum over the prompt tokens too. The loss barely changes (the prompt cancels in a − b when both sequences share it), but logps/chosen and logps/rejected jump to much more negative values and no longer mean “log-prob of the response”. Then break the cancellation: truncate long examples from the left so the chosen and rejected versions keep different amounts of prompt. Now the prompt no longer cancels, and the model gets credit for prompt text it didn’t write.

4. Turn β down and the learning rate up. On the 0.5B model, try β = 0.01 with lr 5e-6. Watch held-out accuracy, logps/chosen, and then actually read the generations. The usual result: margins explode, both log-probs dive, and the text drifts into repetition or odd formatting while the dashboard looks great. Then try β = 1: the model barely moves at all. You’ve now seen both ends of the leash.

Explain It Back

Answer out loud, in plain words, as if in an interview. Then check.

1. Why train on preferences at all, instead of just writing better answers and doing SFT?

Because judging is much cheaper and more reliable than writing. A person can say which of two answers is better in seconds, even when they couldn’t write the best answer themselves, and a ranking of K answers gives K(K−1)/2 pairs. Preference data also carries information SFT can’t: it tells the model what not to do (the rejected answer), and it’s about quality relative to alternatives the model actually produces, instead of copying one person’s phrasing.

2. Walk me through the derivation of DPO in four steps, without equations if you can.

RLHF wants the policy that gets high reward while staying close to a reference model, with β as the price of drift. That problem has an exact solution: take the reference’s probabilities, multiply each response’s by exp(reward/β), and renormalize. Normally that’s useless because the normalizer sums over all possible texts. But you can solve the formula backwards: the reward of a response equals β times the log of how much more likely the optimal policy makes it compared to the reference, plus a per-prompt constant. Plug that into the Bradley-Terry model of preferences, which only looks at reward differences between two answers to the same prompt, and the constant cancels. What’s left is a logistic loss on β times the difference of two log-ratios, all computable with forward passes. Train the policy on that and it acts as its own reward model.

3. Your DPO run has 95% training accuracy and the chosen log-probs are falling. Is it working?

Not necessarily. DPO only cares about the gap between chosen and rejected, not their absolute probabilities, so it can increase the gap by pushing both down, with the rejected one falling faster. Probability mass then goes to responses outside the dataset, which may be worse than both. I’d check held-out accuracy (95% on train may just be overfitting), read generations, check length, and compare against the reference with a judge. Fixes: lower learning rate or fewer steps, higher β, an SFT term on the chosen answers, or chosen/rejected pairs that are less similar to each other (near-identical pairs are a known trigger).

4. How does DPO’s cost scale compared with PPO, as the model grows from 0.5B to 70B?

Both grow with model size; the constants differ. DPO: one trained model (about 16 bytes per parameter with Adam in mixed precision) plus a frozen reference at 2 bytes per parameter, which you can drop by precomputing its log-probs once. A 70B DPO run is roughly 1.1 TB plus activations: many GPUs, but ordinary supervised-training infrastructure. PPO: two trained models (policy, value) and two frozen ones (reference, reward), so about double the memory, plus autoregressive generation inside every step, which is slow, memory-bandwidth-bound, and usually needs a separate inference engine kept in sync with the trainer’s weights. At 70B that orchestration is the hard part. The flip side: DPO only knows the pairs you collected; PPO keeps learning from the model’s own new outputs.

5. What does β do, and what happens at the extremes?

β is the price of moving away from the reference. In the loss it multiplies the log-ratios, so the reward margin is β times the log-ratio margin. With a large β, a small change in the model already looks like a big, confident margin, the sigmoid saturates and the gradient dies, so the model stays near the reference and may barely change behavior. With a small β, the model has to move much further before the loss is satisfied, so it changes more, and risks drifting into degenerate text, especially combined with a large learning rate. Typical values are around 0.1, with 0.01 to 0.5 common.

Go Deeper

Connections

This builds on Train a tiny LM (log-probs and the training loop), Why training needs so much memory (why four models is a problem), LoRA (the cheap way to train the policy, and the trick that makes the base model your reference for free) and LLM judges (how you know the result is better, and why length fools you). The Bradley-Terry loss is the same pairwise loss you’d use to train a ranker, which ties back to your cross-encoder work and to ranking metrics.

It sets up RL with verifiable rewards. DPO learns from a fixed pile of other models’ answers. GRPO goes back to sampling from the model being trained, like PPO, but drops the value model and replaces the learned reward with a check you can run, like “did the code pass the tests”. The question to carry forward: when your reward is a unit test instead of a human’s opinion, what goes wrong in new ways? That’s Rewards and verifiers.