Capstone Lesson 40: Direct Preference Optimization from Scratch
Reward models and PPO are the classical RLHF stack. DPO collapses that stack into a single supervised loss that fits a policy directly against preference pairs. This lesson derives the DPO loss from the reward-difference identity, ships a working reference model plus policy model, computes per-token log-probabilities, and trains a tiny transformer on a preference fixture of chosen and rejected completions. Tests pin the loss math and the gradient direction so you know the implementation matches the paper.
Lesson 38 of 85Build~90 minutesPython (torch, numpy)
Before this: Lessons 28 to 35 of Phase 19 (NLP LLM track: tokenizer, embedding table, attention block, transformer body, pre-training loop, checkpointing, generation, perplexity)
On this page
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, some are wordy or wrong. You also have a small dataset of preference pairs: 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. Optimise the policy against the reward with PPO. This works but is expensive: two models in memory during PPO, KL control to keep the policy near the reference, reward hacking when the reward model is brittle.
DPO replaces both stages with a single supervised loss. The reward model never exists explicitly. The policy is trained directly on the preference pairs, with an explicit KL penalty toward the SFT reference. Same optimal solution under the Bradley-Terry preference model, far less code.
The Concept
Start 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
P(y_w > y_l \mid x) = \sigma\big( r(x, y_w) - r(x, y_l) \big)
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:
\max_{\pi} \; \mathbb{E}_{x, y \sim \pi} \big[ r(x, y) \big] - \beta \, \mathrm{KL}(\pi \,\|\, \pi_{\text{ref}})
The DPO derivation observes that the optimal policy pi* under this objective has a closed form in terms of r:
\pi^*(y \mid x) = \frac{1}{Z(x)} \, \pi_{\text{ref}}(y \mid x) \exp\!\left( \frac{r(x, y)}{\beta} \right)
Re-arrange for r:
r(x, y) = \beta \big( \log \pi^*(y \mid x) - \log \pi_{\text{ref}}(y \mid x) \big) + \beta \log Z(x)
The log Z(x) term is the same for both y_w and y_l (it depends on x, not y), so it cancels when you compute the preference difference:
\begin{aligned}
r(x, y_w) - r(x, y_l) = \beta \big( &\log \pi_\theta(y_w \mid x) - \log \pi_{\text{ref}}(y_w \mid x) \\
&- \log \pi_\theta(y_l \mid x) + \log \pi_{\text{ref}}(y_l \mid x) \big)
\end{aligned}
Substitute into the Bradley-Terry sigmoid and take negative log likelihood over preference pairs:
\begin{aligned}
L_{\text{DPO}}(\theta) = -\mathbb{E}_{(x, y_w, y_l)} \Big[
\log \sigma\big( \beta \big( &\log \pi_\theta(y_w \mid x) - \log \pi_{\text{ref}}(y_w \mid x) \\
&- \log \pi_\theta(y_l \mid x) + \log \pi_{\text{ref}}(y_l \mid x) \big) \big)
\Big]
\end{aligned}
This is the loss. It is a sigmoid over a single scalar per example, computed from four log-probabilities. No separate reward model. No PPO. No KL term in the loss; the KL constraint is baked into the closed-form derivation.
The Sign of the Gradient
A useful sanity check before any training run. Take the gradient with respect to log pi_theta(y_w | x):
\frac{\partial L_{\text{DPO}}}{\partial \log \pi_\theta(y_w \mid x)} = -\beta \big(1 - \sigma(z)\big)
where z is the argument to the sigmoid. This is negative for all z, which means: increasing the policy's log-probability of the chosen completion decreases the loss. Symmetrically, the gradient with respect to log pi_theta(y_l | x) is positive: increasing the rejected log-probability increases the loss. Training pushes the chosen up and the rejected down. The reference is frozen; it does not move.
The Data
Twelve preference triples ship with the lesson. Each is (prompt, chosen, rejected). The chosen completion is short and precise. The rejected is wordy, off-topic, or wrong. The pairs cover the same task families as lesson 39 (capital, arithmetic, list) so a policy that started from an SFT base has a reasonable starting point.
The fixture is intentionally small. DPO works on tens of thousands of pairs in production; here, the point is that the loss math and the loop run end-to-end on a tiny dataset and the chosen-versus-rejected log-prob gap visibly grows.
Reference Invariance
A DPO implementation has to handle the reference model carefully. The reference is the SFT model frozen in place. Three properties have to 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
thetais the reference plus a learned update; initialising the policy as a copy of the reference is the well-defined start.)
The implementation enforces these by:
- Wrapping the reference in
torch.no_grad()during forward passes. - Setting
requires_grad=Falseon every reference parameter. - Constructing the policy via
policy.load_state_dict(reference.state_dict())after the reference is built.
cap-dpo-preference
Architecture
The model is the same TinyGPT used in lesson 39 (decoder-only, causal, byte tokeniser). The reference and policy share the 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.
InstructionTokenizer: byte tokeniser withINSTandRESPspecials. Same shape as lesson 39.TinyGPT: decoder-only transformer. Same shape as lesson 39 so the lesson is self-contained even if you skipped 39.make_preferences: returns twelve(prompt, chosen, rejected)triples.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).dpo_loss: takes the four log-probabilities andbeta, returns the per-example loss tensor and the implicit reward delta for logging.train_dpo: per-epoch loop that computes chosen and rejected log-probs under policy and reference, applies the loss, and steps Adam.evaluate_margins: returns the mean chosen-rejected log-probability margin under the policy at any point.run_demo: builds reference and policy from a small warm-up pretrain, copies weights, trains for thirty steps, prints the per-step loss and margin, and exits zero on success.
Why DPO works
DPO is mathematically equivalent to RLHF under the Bradley-Terry preference model, 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, which 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.
Stretch goals
- Add a length normalisation to the log-probability sum: divide by completion length. Length bias is a known DPO failure mode where the model preferentially chooses shorter completions because their log-probabilities are larger in absolute terms.
- Add the IPO variant of the loss: replace the sigmoid + log with
(z - 1)^2. 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).
The implementation gives you the loss, the reference invariance, and the training loop. The math is the lesson. The code makes the math concrete.
Check yourself
Quick quiz
Before reading: under the Bradley-Terry preference model, the probability a human prefers completion y_w to y_l given prompt x is sigmoid(r(x, y_w) - r(x, y_l)). What is the implication?
- Only the difference in reward matters; any function added to both r(x, y_w) and r(x, y_l) cancels.
- You must fit r before training the policy.
- Bradley-Terry requires a normalising constant Z(x) that does not vanish.
- Sigmoid is incompatible with maximum likelihood.
Show the answer
A. Only the difference in reward matters; any function added to both r(x, y_w) and r(x, y_l) cancels. The Bradley-Terry preference depends only on the reward difference. Anything common to both completions cancels. This is the structural reason DPO can avoid an explicit reward model: the prompt-only term in the optimal policy formula cancels.
The DPO loss for a single triple is -log sigmoid( beta * ( (logp_w_pol - logp_w_ref) - (logp_l_pol - logp_l_ref) ) ). If logp_w_pol = logp_w_ref and logp_l_pol = logp_l_ref, the loss equals?
- beta.
- -1.
- log(2).
- 0.
Show the answer
C. log(2). Both differences are zero, so the sigmoid argument is zero, the sigmoid value is 0.5, and -log(0.5) = log(2). This is the loss when the policy has not moved from the reference at all.
During DPO training, the chosen log-probability under the reference model changes between epochs because the policy diverges from it. True or false?
- True only when beta is non-zero.
- False unless gradients are explicitly disabled with no_grad.
- True: the reference drifts as the policy updates.
- False: the reference is frozen; reference log-probs are computed once and reused.
Show the answer
D. False: the reference is frozen; reference log-probs are computed once and reused. The reference is frozen for the entire DPO run. Its parameters never update. The implementation either snapshots reference log-probs once or wraps the reference forward pass in no_grad with requires_grad=False set on its parameters.
Taking the gradient of L_DPO with respect to logp_w_pol (the policy's log-probability of the chosen completion), the sign is?
- Positive (loss increases as chosen log-prob increases).
- Zero (loss is independent of policy log-probs).
- Indeterminate (depends on the reference).
- Negative (loss decreases as chosen log-prob increases).
Show the answer
D. Negative (loss decreases as chosen log-prob increases). The gradient is -beta * (1 - sigmoid(z)), which is non-positive. Increasing the chosen log-probability decreases the loss, which is exactly the training signal we want.
On your fixture the DPO loss decreases but the policy starts preferring shorter completions across the board, even when shorter is worse. The most likely cause?
- Length bias: longer sequences accumulate more log-probability terms (each negative), so sum-of-log-probs penalises length structurally.
- The optimiser is using the wrong learning rate.
- The reference is not properly frozen.
- The beta is too low.
Show the answer
A. Length bias: longer sequences accumulate more log-probability terms (each negative), so sum-of-log-probs penalises length structurally. Sum-of-log-probabilities is biased toward shorter sequences because every additional token adds a negative log-probability term. The standard fix is to normalise log-probs by completion length when training and evaluating.
You want to replace the sigmoid + log loss with the IPO variant (z - 1)^2. What is the practical effect on training dynamics?
- It removes the need for the reference model entirely.
- It does not saturate the way the sigmoid loss does, which holds up better under noisy preference labels.
- It requires no beta parameter.
- It is mathematically equivalent at all points.
Show the answer
B. It does not saturate the way the sigmoid loss does, which holds up better under noisy preference labels. IPO's squared loss does not saturate the way the sigmoid does, which helps when preference labels are noisy or when some triples are mis-labelled. Empirically it reduces over-optimisation on small preference datasets.
Questions people ask
What is DPO in AI?
Direct preference optimization is a way to align a model using pairs of answers where one is preferred. It raises the likelihood of the preferred answer compared to a frozen reference model. It replaces the reward model and reinforcement learning steps of classic RLHF with one loss.
What is the difference between DPO and RLHF?
Classic RLHF trains a reward model on preferences, then uses reinforcement learning such as PPO to optimize the policy against it. DPO skips the reward model and the RL loop and trains directly on preference pairs. It is usually simpler and more stable to run.
Lessons from AI Engineering from Scratch by Rohit Ghumare and contributors, MIT license. Notes for owners by Lozo. License