TAI BUI
Back to curriculum
CapstonePython (torch, numpy)

Capstone Lesson 40: Direct Preference Optimization from Scratch

DPO is a single supervised loss that fits a policy directly against preference pairs, replacing the reward model and PPO of the classical RLHF stack. Derive the loss from the reward-difference identity, build a frozen reference model and a trainable policy, compute per-token log-probabilities, and train a tiny transformer on chosen and rejected completions. Tests pin the loss math and the gradient direction, so you know the implementation matches the paper.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 30-37 (NLP LLM track: tokenizer, embedding table, attention block, transformer body, pre-training loop, checkpointing, generation, perplexity)

Learning Objectives

  • Derive the DPO loss as a sigmoid over a scaled log-ratio difference and connect it to the implicit reward.
  • Build a reference model + policy model pair with a frozen reference and a trainable policy.
  • Compute sequence-level log-probabilities under both models, masking prompt tokens.
  • Train the policy on `(prompt, chosen, rejected)` triples and watch the chosen log-prob rise relative to rejected.
  • Pin behaviour with tests on the loss math, the gradient sign, and the reference invariance.

The Problem

You have an SFT model. It follows instructions, but its outputs are uneven: some completions are clear, others are wordy or wrong. You also have a small dataset of preference pairs, where for the same prompt a human marked one completion as chosen and the other as rejected.

The classical RLHF answer is a two-stage pipeline. Train a reward model on the preferences, then optimise the policy against that reward with PPO. It works, but it is expensive. PPO keeps two models in memory, you need KL control to hold the policy near the reference, and a brittle reward model invites reward hacking.

DPO replaces both stages with a single supervised loss. The reward model never exists explicitly. The policy trains directly on the preference pairs, with an explicit KL penalty toward the SFT reference. Under the Bradley-Terry preference model it reaches the same optimal solution with far less code.

Warm-up0/3
1. A decoder-only language model gives next-token logits at every position. How do you get the log-probability of a whole completion given a prompt?Warm-up
2. In PyTorch, which setting stops an optimizer step from changing a model's weights?Warm-up
3. What does sigmoid(0) equal, and what range does sigmoid output?Warm-up
Check: The Problem0/3
1. What is a preference pair in preference training?Define
2. What does DPO remove from the classic RLHF pipeline?Explain
3. Two teams align the same SFT model with the same preference pairs. Team A fits a reward model and then runs PPO. Team B uses DPO. Which risk applies to Team A but not to Team B?Analyze

The Concept

Direct Preference Optimization (DPO) is a supervised loss that trains a policy straight from preference pairs, with no separate reward model and no PPO loop. It works by rewriting the RLHF objective so the reward can be expressed through the policy itself. The derivation starts from the Bradley-Terry model.

Given a prompt x and two completions y_w (chosen) and y_l (rejected), the probability the human prefers y_w is

text
P(y_w > y_l | x) = sigmoid( r(x, y_w) - r(x, y_l) )

where r is some latent reward function. RLHF first fits r from preferences, then trains a policy pi to maximise r with a KL anchor:

text
max_pi   E_{x, y~pi} [ r(x, y) ] - beta * KL(pi || pi_ref)

The DPO derivation observes that the optimal policy pi* under this objective has a closed form in terms of r:

text
pi*(y | x) = (1/Z(x)) * pi_ref(y | x) * exp( r(x, y) / beta )

Rearrange for r:

text
r(x, y) = beta * ( log pi*(y | x) - log pi_ref(y | x) ) + beta * log Z(x)

The log Z(x) term depends on x but not on y, so it is the same for y_w and y_l and cancels in the preference difference:

text
r(x, y_w) - r(x, y_l) = beta * ( log pi_theta(y_w|x) - log pi_ref(y_w|x)
                                - log pi_theta(y_l|x) + log pi_ref(y_l|x) )

Substitute into the Bradley-Terry sigmoid and take the negative log likelihood over preference pairs:

text
L_DPO(theta) = - E_{(x, y_w, y_l)} [
  log sigmoid( beta * ( log pi_theta(y_w|x) - log pi_ref(y_w|x)
                       - log pi_theta(y_l|x) + log pi_ref(y_l|x) ) )
]

That is the loss: a sigmoid over a single scalar per example, computed from four log-probabilities. There is no separate reward model and no PPO. The loss carries no KL term, because the KL constraint is baked into the closed-form derivation.

Mermaid diagram

Check: The Concept0/8
1. What is the implicit reward in DPO?Define
2. What is the Bradley-Terry model, as DPO uses it?Define
3. What does beta do in the DPO loss?Define
4. Why can log Z(x) be dropped when comparing the chosen and the rejected completion?Explain
5. The DPO loss for one triple is -log sigmoid( beta * ( (logp_w_pol - logp_w_ref) - (logp_l_pol - logp_l_ref) ) ). If both policy log-probs equal their reference log-probs, the loss equals what?Apply
6. dpo_loss gets logp_w_pol=-4, logp_l_pol=-6, logp_w_ref=-5, logp_l_ref=-5 and beta=0.5. What margin does it return?Apply
7. A triple has a chosen log-ratio of 3 and a rejected log-ratio of -7 (policy minus reference), with beta = 0.2. What is the DPO loss?Apply
8. dpo_loss runs twice with policy log-probs 2.0 (chosen) and 1.0 (rejected), beta = 1. In run A both reference log-probs are 0. In run B both are 5. How do the two margins compare?Apply

The Sign of the Gradient

Check the sign of the gradient before any training run. Take the gradient with respect to log pi_theta(y_w | x):

text
d L_DPO / d log pi_theta(y_w | x) = - beta * (1 - sigmoid(z))

where z is the argument to the sigmoid. This is negative for all z, so increasing the policy's log-probability of the chosen completion decreases the loss. By symmetry, the gradient with respect to log pi_theta(y_l | x) is positive: raising the rejected log-probability raises the loss. Training pushes the chosen up and the rejected down. The reference is frozen and does not move.

Check: The Sign of the Gradient0/3
1. Taking the gradient of L_DPO with respect to the policy's log-probability of the chosen completion, what is the sign?Explain
2. Late in training, some triples show a loss near zero and barely move, while others still learn. Which property of the loss explains it?Analyze
3. A gradient-sign test finds that raising the chosen log-prob raises the loss. Which bug fits that symptom?Analyze

The Data

The fixture holds twelve preference triples, each a (prompt, chosen, rejected). The chosen completion is short and precise. The rejected one is wordy, off-topic, or wrong. The pairs cover task families that also appear in lesson 39 (capital, arithmetic, list, define, Python snippet), so a policy that started from an SFT base has a reasonable starting point.

The fixture is small on purpose. DPO works on tens of thousands of pairs in production. Here the goal is to run the loss math and the loop end to end on a tiny dataset and watch the chosen-versus-rejected log-prob gap grow.

Reference Invariance

The reference model is the SFT model frozen in place, and a DPO implementation has to handle it carefully. Three properties must hold:

  • The reference parameters never receive gradients.
  • The reference log-probabilities never change between epochs.
  • The policy starts from the same weights as the reference. (The optimal theta is the reference plus a learned update, so initialising the policy as a copy of the reference is the well-defined start.)

The implementation enforces these by:

  • Computing the reference log-probabilities once, before training starts, under torch.no_grad(), and reusing those stored values every epoch.
  • Setting requires_grad=False on every reference parameter (build_models does this, and run_demo re-applies it after warm-up).
  • Constructing the policy via policy.load_state_dict(reference.state_dict()) after the reference is built.

One wrinkle: run_demo runs a short warm-up pretrain so the reference has non-trivial log-probabilities. For that step it temporarily sets requires_grad=True and reference.train(), then copies the warmed-up weights into the policy and re-freezes the reference with requires_grad=False and reference.eval() before DPO training begins.

Check: Reference Invariance0/5
1. What is the reference model in a DPO run?Define
2. In `train_dpo`, how do the reference log-probs enter the loss?Explain
3. Why is the policy initialised as a copy of the reference's weights?Explain
4. Before any training step, the first DPO loss is far from log(2). Which wiring mistake explains it?Analyze
5. You must write a test that pins reference invariance. Which design covers the three properties?Design

Architecture

Mermaid diagram

The model is the same TinyGPT architecture used in lesson 39 (decoder-only, causal, byte tokeniser), minus lesson 39's padding mask, since DPO here runs one sequence at a time. Reference and policy share one architecture. The policy's weights drift from the reference under training while the reference stays fixed.

What you will build

The implementation is one main.py plus tests.

  1. InstructionTokenizer: byte tokeniser with INST and RESP specials. Same special tokens and vocabulary as lesson 39.
  2. TinyGPT: decoder-only transformer. Same architecture as lesson 39 (minus the padding mask), so this build is self-contained even if you skipped 39.
  3. make_preferences: returns twelve (prompt, chosen, rejected) triples.
  4. sequence_log_prob: given the model, a prompt prefix, and a completion, returns the sum of next-token log-probabilities over the completion (no prompt-position contribution).
  5. dpo_loss: takes the four log-probabilities and beta, returns the per-example loss tensor and the margin (the implicit reward difference before the beta scaling) for logging.
  6. train_dpo: snapshots the reference log-probs once before training (under no_grad), then for each epoch loops over the triples, computes chosen and rejected log-probs under the policy, applies the loss, and steps Adam once per triple.
  7. evaluate_margins: returns the mean chosen-rejected log-probability margin under the policy at any point.
  8. run_demo: builds reference and policy from a small warm-up pretrain, copies weights, trains for 30 epochs (12 triples per epoch, one Adam step each, 360 optimizer steps in all), prints the per-epoch loss and margin, and exits zero on success.
Check: What you will build0/7
1. What is the margin that dpo_loss returns alongside the loss?Define
2. Why does sequence_log_prob sum only over the completion tokens?Explain
3. On the fixture, what shows that DPO training is working?Explain
4. `run_demo` trains for 30 epochs over the 12 fixture triples, taking one Adam step per triple. How many optimizer steps does DPO training take?Apply
5. A prompt encodes to 4 token ids and the completion has 6 tokens. How many next-token log-probs does sequence_log_prob add up?Apply
6. length_normalised_log_prob is called on a 4-token completion whose summed log-probability is -12. What does it return?Apply
7. One triple has policy log-probs of -3 (chosen) and -9 (rejected), and reference log-probs of -4 (chosen) and -8 (rejected). What do the policy-only gap from evaluate_margins and the margin from dpo_loss come to?Apply

Why DPO works

Under the Bradley-Terry preference model, DPO is mathematically equivalent to RLHF, up to the parameterisation of the reward. The implicit reward r(x, y) = beta * (log pi(y|x) - log pi_ref(y|x)) is identifiable from preferences up to a function of x, and that function cancels in the difference. The closed-form policy lets you skip the explicit reward model. The KL constraint is enforced structurally: any deviation of pi from pi_ref makes the log-ratio larger, and the sigmoid saturates, which damps the gradient when the policy moves too far. The reference is your safety net.

Check: Why DPO works0/2
1. The loss has no explicit KL term. According to Why DPO Works, how is the constraint toward the reference enforced?Explain
2. Under which preference model is DPO equivalent to RLHF, up to the parameterisation of the reward?Explain
Author's Note

My bet is that the gradient-sign check is a cheap check to run before a training run, so I'd do it first. If raising the chosen log-probability does not lower the loss, I'd treat that as the first thing to fix. When the margin refuses to grow after that, I'd look at the reference before the learning rate or beta: it has to be frozen, and the policy has to start as a copy of it. Those are the two wiring mistakes I'd suspect first. If the policy starts preferring shorter completions, I'd try the length normalisation under Stretch goals, since summed log-probabilities are a known source of that bias.

Stretch goals

  • Study and extend the existing length_normalised_log_prob (log-probability sum divided by completion length), for example by training on it. Length bias is a known DPO failure mode: the model preferentially chooses shorter completions because their log-probabilities are larger in absolute terms.
  • Study the existing ipo_loss, which replaces the sigmoid + log with a squared distance between the margin and a target of 1/(2*beta): (margin - 1/(2*beta))^2. Swap it into train_dpo and compare convergence on the fixture.
  • Add a label-smoothing parameter that interpolates between the hard chosen-rejected label and a uniform 0.5.
  • Replace the reference with a smaller cheaper model (knowledge distillation flavour).

main.py also ships margin_table and print_margin_table for per-triple debugging; extend them to report length-normalised margins.

The implementation gives you the loss, the reference invariance, and the training loop. The math is the point, and the code makes it concrete.

Check: Stretch goals0/5
1. `main.py` ships `ipo_loss`. How does it differ from `dpo_loss`, given the same four log-probs and beta?Explain
2. ipo_loss gets margin 5 and beta = 0.25. What loss does it return?Apply
3. On your fixture the DPO loss falls, but the policy now prefers shorter completions across the board, even when the short one is worse. What is the most likely cause?Analyze
4. Your new pairs have chosen answers much longer than the rejected ones, and the policy keeps favouring the short ones. Which change from the lesson fits best?Design
5. You may change only the loss, and you want the gradient to stop shrinking toward zero as the margin grows, whatever beta is. Which fits?Design

Code