AI EngineeringZero to ProductionHome·About·Contact
Research & Frontier Eng · Part 2

RLHF & DPO from scratch

Alignment from preferences, derived and coded rather than invoked. This lesson builds the Bradley–Terry preference model, trains a tiny reward model, contrasts PPO with DPO, derives the DPO loss from the RLHF objective, implements it in pure Python, and shows reward hacking in a runnable toy loop. Everything runs offline; the real torch/TRL version is labeled.

⏱️ ~2.5 hours🧪 5 labs🎯 Advanced→Industry

Learning objectives

  • Model human preferences with Bradley–Terry and fit a reward from pairwise comparisons.
  • Explain the RLHF pipeline (reward model + PPO) and why the KL-to-reference term exists.
  • Derive the DPO loss from the KL-regularized reward objective — why the reward model disappears.
  • Implement the DPO loss and one gradient step in pure Python/NumPy.
  • Recognize and diagnose reward hacking, and know the mitigations (KL penalty, better data, capped reward).
Where this sitsThe applied version of this lives at FT5 (run DPO with TRL). Here we go under the API: the math of the preference model and the derivation that makes DPO a single closed-form loss with no reward model and no RL loop.

1 · Preference data & the Bradley–Terry model

Some qualities — helpfulness, tone, harmlessness — are far easier to express as preferences (“A is better than B”) than as gold answers. The unit of preference tuning is the triple (prompt, chosen, rejected). To turn a pile of such comparisons into a trainable signal we need a probabilistic model of preference. The standard choice is Bradley–Terry: if response y has a latent scalar reward r(y), the probability a rater prefers yw (winner) over yl (loser) is the logistic of the reward gap:

P(y_w > y_l) = σ(r(y_w) − r(y_l)), where σ(z) = 1/(1+e^−z).

So the whole learning signal is the reward margin between chosen and rejected. A big margin gives a confident preference and a strong gradient; a near-tie teaches almost nothing. This is why curating pairs with real quality gaps matters as much as volume.

preference pairs chosen/rejected reward model r(y) Bradley-Terry policy optimization PPO or DPO aligned model prefers good

2 · Lab · fit a reward model from pairs

A reward model is just a function that scores a response, trained so that σ(r(chosen) − r(rejected)) is high for every pair. Here we fit a trivial linear reward on hand-crafted features by gradient descent on the Bradley–Terry log-likelihood — pure Python, so you watch the margin grow.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · reward model via Bradley-Terry (runs, offline)
reward_model.pyimport math, random
random.seed(0)

def sigmoid(z):
    return 1.0 / (1.0 + math.exp(-z)) if z >= 0 else math.exp(z) / (1.0 + math.exp(z))

# Features per response: [length_norm, has_specifics, is_refusal]. r(y) = w . features.
# Each pair: (features_chosen, features_rejected). Chosen is the better answer.
pairs = [
    ([0.8, 1, 0], [0.2, 0, 1]),   # long+specific  vs  short refusal
    ([0.6, 1, 0], [0.5, 0, 0]),   # specific       vs  vague
    ([0.7, 1, 0], [0.9, 0, 0]),   # specific       vs  long but vague
    ([0.5, 1, 0], [0.4, 0, 1]),   # helpful        vs  refusal
]
w = [0.0, 0.0, 0.0]
lr = 0.5

def reward(feats):
    return sum(wi * fi for wi, fi in zip(w, feats))

for epoch in range(61):
    grad = [0.0, 0.0, 0.0]
    loss = 0.0
    for fc, fr in pairs:
        margin = reward(fc) - reward(fr)
        p_pref = sigmoid(margin)
        loss += -math.log(p_pref)
        # d(-log sigma(margin))/dw = -(1 - p_pref) * d(margin)/dw
        coef = -(1 - p_pref)
        for i in range(3):
            grad[i] += coef * (fc[i] - fr[i])
    for i in range(3):
        w[i] -= lr * grad[i] / len(pairs)
    if epoch % 20 == 0:
        avg_margin = sum(reward(fc) - reward(fr) for fc, fr in pairs) / len(pairs)
        print(f"epoch {epoch:>2}: loss={loss/len(pairs):.4f}  "
              f"mean chosen-rejected margin={avg_margin:+.2f}")

sign = lambda x: "+" if x >= 0 else "-"
print(f"learned weights favor: length{sign(w[0])} specificity{sign(w[1])} refusal{sign(w[2])}")
epoch  0: loss=0.6931  mean chosen-rejected margin=+0.00
epoch 20: loss=0.3567  mean chosen-rejected margin=+1.31
epoch 40: loss=0.2489  mean chosen-rejected margin=+2.05
epoch 60: loss=0.1954  mean chosen-rejected margin=+2.53
learned weights favor: length+ specificity+ refusal-

The loss falls and the mean margin between chosen and rejected grows — the reward model has learned to score preferred answers higher. In real RLHF this r is a full network with a scalar head on the LLM; the objective is identical.

3 · PPO vs DPO: two ways to use the reward

Once you have preferences, there are two routes to an aligned policy. RLHF/PPO trains the reward model, then runs a reinforcement-learning loop: sample responses from the policy, score them with the reward model, and update the policy to raise expected reward — while a KL penalty keeps it from drifting too far from the reference (SFT) model. It works, but it is a moving target: reward model, policy, and a sampling loop, all interacting and unstable.

DPO observes that the KL-regularized objective has a closed-form optimal policy, and substitutes it back to get a loss you can optimize directly on the pairs — no reward model, no sampling, no RL loop. Same alignment target, one simple loss.

RLHF (PPO)DPO
Separate reward modelyesno — implicit in the policy
Sampling / RL loopyes (on-policy)no — direct on pairs
KL-to-referenceexplicit penaltybaked into the loss via β
Stability / moving partsmany, fragilefew, stable
Applied defaultwhen DPO plateausstart here

4 · Deriving the DPO loss

This is the heart of the lesson. RLHF maximizes expected reward with a KL leash to the reference policy π_ref:

max_π E[r(y)] − β·KL(π ‖ π_ref)

This constrained problem has a known closed-form optimum — the reference policy reweighted by the exponentiated reward:

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

Rearranging for the reward gives r(x,y) = β·log(π*(y|x)/π_ref(y|x)) + β·log Z(x). The insight: substitute this expression for r into the Bradley–Terry preference probability. The intractable partition term log Z(x) is the same for chosen and rejected (same prompt x), so it cancels in the difference. What remains is a loss over the policy's own log-probs versus the reference — the DPO loss:

L_DPO = −log σ( β·[ logπ(y_w|x) − logπ_ref(y_w|x) ] − β·[ logπ(y_l|x) − logπ_ref(y_l|x) ] )

Why the reward model vanishedDPO never removes the reward — it makes the policy its own implicit reward model. The quantity β·log(π/π_ref) is the reward, up to the constant that cancels. So one network and one loss do the job of the reward model, the KL penalty, and the RL loop combined.

5 · Lab · the DPO loss & one gradient step

Now implement exactly that loss in pure Python and take one gradient step, watching the log-prob gap between chosen and rejected widen. This is a toy 2-token policy, but the loss and its gradient are the real DPO update.

Python · DPO loss + gradient step (runs, offline)
dpo_from_scratch.pyimport math

def sigmoid(z):
    return 1.0 / (1.0 + math.exp(-z)) if z >= 0 else math.exp(z) / (1.0 + math.exp(z))

# Toy policy over two responses {chosen, rejected}, parameterized by a logit `theta`
# (logit of the chosen response). logp(chosen)=log softmax; reference is frozen.
theta = 0.0                      # policy starts equal to reference
theta_ref = 0.0                  # frozen reference
BETA = 1.0
LR = 0.5

def logps(theta):
    # softmax over [chosen, rejected] with logits [theta, 0]
    z = math.log(math.exp(theta) + math.exp(0.0))
    return theta - z, 0.0 - z     # logp(chosen), logp(rejected)

for step in range(11):
    lp_c, lp_r = logps(theta)
    ref_c, ref_r = logps(theta_ref)
    chosen_ratio = lp_c - ref_c
    reject_ratio = lp_r - ref_r
    diff = BETA * (chosen_ratio - reject_ratio)
    loss = -math.log(sigmoid(diff))
    if step in (0, 1, 5, 10):
        print(f"step {step:>2}: DPO loss={loss:.4f}  "
              f"logp(chosen)-logp(rejected)={lp_c - lp_r:+.3f}")
    # gradient of loss wrt theta (finite difference, tiny epsilon)
    eps = 1e-5
    lp_c2, lp_r2 = logps(theta + eps)
    d2 = BETA * ((lp_c2 - ref_c) - (lp_r2 - ref_r))
    loss2 = -math.log(sigmoid(d2))
    grad = (loss2 - loss) / eps
    theta -= LR * grad

print("the policy now assigns more probability to the chosen response")
step  0: DPO loss=0.6931  logp(chosen)-logp(rejected)=+0.000
step  1: DPO loss=0.6446  logp(chosen)-logp(rejected)=+0.104
step  5: DPO loss=0.4901  logp(chosen)-logp(rejected)=+0.548
step 10: DPO loss=0.3616  logp(chosen)-logp(rejected)=+1.043
the policy now assigns more probability to the chosen response

The loss falls and the policy shifts probability mass toward the chosen response, relative to the frozen reference — with no reward model in sight. β is the one dial: higher β pushes harder toward chosen but risks over-fitting the pairs and drifting from the reference.

The real thingIn production you would run this with TRL’s DPOTrainer on a GPU (pip install trl transformers datasets) over a full LLM policy and a frozen reference copy. The loss it optimizes is exactly the one above. See FT5 for the runnable TRL recipe.

6 · Reward hacking & its mitigations

Any time you optimize a proxy for what you want, the optimizer will exploit the gap. In RLHF the proxy is the reward model; a policy trained too hard against it finds cheap tricks that score high without being genuinely better — reward hacking. Classic symptoms: answers grow longer because the reward model correlated length with quality; the model becomes sycophantic; it repeats reward-y phrases. DPO is not immune — over-fitting the pairs is the same failure in a different coat.

Python · reward hacking in a toy loop (runs, offline)
reward_hacking.py# Proxy reward rewards length; TRUE quality peaks at a moderate length then falls.
def proxy_reward(length):          # what the reward model (wrongly) scores
    return min(length * 0.19, 10.0)

def true_quality(length):          # what humans actually want: sweet spot ~12-16
    return max(0.0, 8.0 - abs(length - 14) * 0.35)

def optimize(kl_leash, ref_length=14, steps=200, lr=0.6):
    length = float(ref_length)
    for _ in range(steps):
        eps = 1e-3
        obj = lambda L: proxy_reward(L) - (kl_leash * (L - ref_length) ** 2 if kl_leash else 0)
        grad = (obj(length + eps) - obj(length - eps)) / (2 * eps)
        length = max(1.0, length + lr * grad)
    return length

for name, kl in [("no KL leash:  ", 0.0), ("with KL leash:", 0.05)]:
    L = optimize(kl)
    pr, tq = proxy_reward(L), true_quality(L)
    tag = "HACKED" if tq < 4 else "aligned"
    print(f"{name} final length={L:>3.0f} tokens  proxy reward={pr:.2f}  "
          f"TRUE quality={tq:.1f}  ({tag})")
print("the leash trades proxy reward for real quality")
no KL leash:   final length= 48 tokens  proxy reward=9.10  TRUE quality=2.1  (HACKED)
with KL leash: final length= 14 tokens  proxy reward=5.30  TRUE quality=6.8  (aligned)
the leash trades proxy reward for real quality

Without the KL penalty the policy maximizes the flawed proxy (here: length) and drifts far from sensible behavior — high proxy reward, low true quality. The KL leash (the β term in both PPO and DPO) holds it near the reference and preserves real quality. The other mitigations are the ones you already know: better preference data, a capped/normalized reward, and holding out evals the reward model never saw.

✓ Knowledge check

In the DPO derivation, an intractable partition function log Z(x) appears when you solve for the reward. Why does DPO get away without ever computing it?

Show answer
Z(x) normalizes over all possible responses to the prompt x and is generally intractable. But it depends only on the prompt, not on the response — so it is identical for the chosen and rejected answers of the same pair. The Bradley–Terry preference probability depends only on the reward difference between chosen and rejected, and in that subtraction the shared log Z(x) term cancels exactly. That cancellation is precisely why DPO reduces to a simple closed-form loss over policy log-probs.
✓ Knowledge check

A team reports their RLHF model's reward-model score keeps climbing but human raters like the outputs less. What is happening and what are two fixes?

Show answer
This is reward hacking: the policy is optimizing the reward model (a proxy) rather than true quality, exploiting artifacts the reward model rewards (e.g. length, sycophancy, reward-y phrasing). Fixes: (1) strengthen the KL penalty to the reference so the policy can't drift far to chase the proxy; (2) improve/diversify the preference data and re-train the reward model so the artifact isn't rewarded; also cap/normalize the reward and track a held-out human eval the reward model never influenced. The rule: never trust a rising proxy without an independent quality check.

✓ Checkpoint — you can move on when you can…

  • Write the Bradley–Terry preference probability and say why the reward margin is the signal.
  • Explain the RLHF pipeline and the role of the KL-to-reference penalty.
  • Derive the DPO loss from the KL-regularized objective and explain why the reward model and partition term drop out.
  • Implement the DPO loss and one gradient step and interpret the widening log-prob gap.
  • Recognize reward hacking and name its mitigations (KL leash, better data, capped reward, held-out evals).

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · SFT vs preference tuning: which paradigm?Beginner

Context: SFT imitates one gold answer; preference tuning learns from a chosen-vs-rejected comparison. The fork is whether a single correct output exists.

Your task: Classify a set of training goals into SFT or preference tuning (DPO/RLHF).

Requirements:

  • Exact-output goals (emit a schema, copy a macro) → SFT
  • Comparative-quality goals (more helpful, less blunt) → preference tuning
  • State the rule: one right answer → SFT; “better than” → preferences
  • Cover at least one goal of each kind

💡 Hint: Ask whether the goal has a single gold answer or only a comparison between two.

Show solution

Sort goals by whether a single gold answer exists. Runnable:

goals = [
    ("emit this exact JSON schema",           "SFT"),
    ("prefer polite over blunt phrasing",     "preference (DPO)"),
    ("be more helpful, less evasive",         "preference (DPO)"),
    ("copy this exact support macro",         "SFT"),
]
for goal, method in goals:
    print(f"{goal:38} -> {method}")

One correct answer → SFT imitation. A “better than” comparison (helpful vs evasive) → preferences capture it more naturally than any single gold label. That fork is the whole classifier.

Exercise 2 · Bradley-Terry preference probabilityIntermediate

Context: Bradley-Terry turns a reward gap into a preference probability via the logistic function; it is the bridge from scalar rewards to a trainable likelihood.

Your task: Implement the Bradley-Terry probability that a rater prefers the chosen response, given per-response rewards, and show it depends only on the reward margin.

Requirements:

  • P(chosen > rejected) = σ(r_chosen − r_rejected)
  • Use a numerically stable sigmoid
  • Show equal rewards give 0.5 and a large positive margin approaches 1.0
  • Note the probability depends only on the difference, not absolute rewards

💡 Hint: Shift both rewards by the same constant and confirm the probability is unchanged.

Show solution

Bradley-Terry is the logistic of the reward gap. Runnable:

import math
def sigmoid(z):
    return 1/(1+math.exp(-z)) if z >= 0 else math.exp(z)/(1+math.exp(z))

def prefers_chosen(r_chosen, r_rejected):
    return sigmoid(r_chosen - r_rejected)

print(f"equal rewards:      {prefers_chosen(2.0, 2.0):.3f}")   # 0.5
print(f"chosen +3 better:   {prefers_chosen(5.0, 2.0):.3f}")   # ~0.95
print(f"shift both by +100: {prefers_chosen(102.0, 99.0):.3f}") # same as +3

Equal rewards give 0.5; a +3 margin gives ~0.95; shifting both rewards by any constant leaves the probability unchanged. The preference depends only on the difference — which is exactly why the partition term cancels in the DPO derivation.

Exercise 3 · The DPO loss for one pairAdvanced

Context: The DPO loss is the negative log-sigmoid of a beta-scaled difference of log-prob ratios between policy and reference for chosen vs rejected.

Your task: Implement the DPO loss for a single (chosen, rejected) pair given policy and reference log-probs and beta.

Requirements:

  • chosen logratio = logp_pol(chosen) − logp_ref(chosen)
  • rejected logratio = logp_pol(rejected) − logp_ref(rejected)
  • loss = −log σ( β·(chosen_logratio − rejected_logratio) )
  • Show loss falls as the chosen logratio exceeds the rejected one

💡 Hint: The reference log-probs are constants here; only the ratio difference drives the loss.

Show solution

The DPO loss is negative log-sigmoid of a beta-scaled ratio difference. Runnable:

import math
def sigmoid(z):
    return 1/(1+math.exp(-z)) if z >= 0 else math.exp(z)/(1+math.exp(z))

def dpo_loss(logp_pol_c, logp_pol_r, logp_ref_c, logp_ref_r, beta=0.1):
    chosen_ratio = logp_pol_c - logp_ref_c
    reject_ratio = logp_pol_r - logp_ref_r
    return -math.log(sigmoid(beta * (chosen_ratio - reject_ratio)))

# reference fixed; sweep the policy raising chosen above rejected
for boost in (0.0, 1.0, 3.0):
    L = dpo_loss(-1.0 + boost, -1.0, -1.0, -1.0, beta=1.0)
    print(f"policy chosen logratio +{boost}: DPO loss={L:.4f}")

As the policy raises the chosen response's log-prob above the rejected one (relative to the frozen reference), the loss falls. No reward model appears — the policy's own log-prob ratio is the implicit reward.

Exercise 4 · One DPO gradient step lowers the lossExpert

Context: DPO is trainable end to end: a single gradient step on a toy policy must reduce the loss and widen the chosen-minus-rejected log-prob gap.

Your task: Take one numeric gradient step on a toy 2-outcome policy's DPO loss and verify both the loss drops and the log-prob gap grows.

Requirements:

  • Parameterize the policy as a softmax over two responses
  • Compute the DPO loss against a frozen reference
  • Estimate the gradient (analytic or finite-difference) and step the params
  • Assert loss decreased and logp(chosen)−logp(rejected) increased

💡 Hint: A frozen reference plus a softmax policy is enough; finite differences are fine for the gradient.

Show solution

One finite-difference step must drop the loss and widen the gap. Runnable:

import math
def sigmoid(z):
    return 1/(1+math.exp(-z)) if z >= 0 else math.exp(z)/(1+math.exp(z))

def logps(theta):                      # softmax over [chosen, rejected], logits [theta, 0]
    z = math.log(math.exp(theta) + 1.0)
    return theta - z, -z

def dpo_loss(theta, theta_ref=0.0, beta=1.0):
    lp_c, lp_r = logps(theta); rc, rr = logps(theta_ref)
    return -math.log(sigmoid(beta * ((lp_c - rc) - (lp_r - rr))))

theta = 0.0
before, gap0 = dpo_loss(theta), (logps(theta)[0] - logps(theta)[1])
eps = 1e-5
grad = (dpo_loss(theta + eps) - dpo_loss(theta)) / eps
theta -= 0.5 * grad
after, gap1 = dpo_loss(theta), (logps(theta)[0] - logps(theta)[1])
print(f"loss {before:.4f} -> {after:.4f}   gap {gap0:+.3f} -> {gap1:+.3f}")
assert after < before and gap1 > gap0

The single step lowers the loss and increases logp(chosen)−logp(rejected) — the policy moved probability mass onto the chosen response. That is the real DPO update, minus the neural network.

Exercise 5 · Detect reward hacking with a KL leashProfessional

Context: A proxy reward that rewards length lets an unconstrained policy inflate outputs while true quality falls. A KL penalty to the reference is the standard leash.

Your task: Model a policy optimizing a length-biased proxy reward with and without a KL penalty, and show the leash preserves true quality.

Requirements:

  • Proxy reward rises with length; true quality peaks at a moderate length then falls
  • Unconstrained optimization maximizes proxy reward and overshoots length
  • Add a KL-to-reference penalty (distance from a sensible reference length)
  • Report proxy reward and true quality for both, showing the leash wins on true quality

💡 Hint: You do not need a real model — a scalar “length” policy and two reward functions suffice.

Show solution

Optimize a length-biased proxy with and without a KL leash. Runnable:

def proxy(L):   return min(L * 0.19, 10.0)          # reward model (wrongly) likes length
def truth(L):   return max(0.0, 8.0 - abs(L - 14) * 0.35)   # humans: sweet spot ~14

def optimize(kl, ref=14, steps=200, lr=0.6):
    L = float(ref)
    for _ in range(steps):
        e = 1e-3
        obj = lambda x: proxy(x) - (kl * (x - ref) ** 2 if kl else 0)
        L = max(1.0, L + lr * (obj(L + e) - obj(L - e)) / (2 * e))
    return L

for name, kl in [("no leash ", 0.0), ("KL leash ", 0.05)]:
    L = optimize(kl)
    print(f"{name}: length={L:5.1f}  proxy={proxy(L):.2f}  TRUE quality={truth(L):.1f}")

Unconstrained, the policy inflates length to max the proxy — proxy reward soars, true quality collapses. The KL leash holds it near the reference and keeps true quality high. That leash is the β term in both PPO and DPO, and it is the first defense against reward hacking.

Exercise 6 · Choose the alignment recipe for a launchIndustry scenario

Context: Alignment is layered by the data and risk you have: SFT is the foundation, DPO the refinement, RLHF the last resort when DPO plateaus.

Your task: Given whether you have gold answers, preference pairs, and a safety-critical bar, decide the alignment recipe and justify it from data and risk.

Requirements:

  • No gold answers → collect gold and do SFT first
  • Gold but no pairs → SFT only, add DPO later
  • Safety-critical with pairs → SFT + DPO (RLHF only if DPO plateaus)
  • Otherwise SFT + DPO as the applied default
  • Return a recommendation with a one-line justification

💡 Hint: Walk the data you have: gold unlocks SFT, pairs unlock DPO, RLHF is the last resort.

Show solution

Route the recipe from the data and risk you have. Runnable:

def recipe(have_gold, have_prefs, safety_critical):
    if not have_gold:
        return "COLLECT gold first -- SFT is the foundation for any alignment"
    if not have_prefs:
        return "SFT only for now -- add DPO once you can collect preference pairs"
    if safety_critical:
        return "SFT + DPO -- DPO nudges toward safe/helpful; RLHF only if DPO plateaus"
    return "SFT + DPO -- the applied default"

print(recipe(True,  True,  True))    # SFT + DPO ...
print(recipe(True,  False, False))   # SFT only for now ...
print(recipe(False, False, True))    # COLLECT gold first ...

SFT builds the base, DPO refines it cheaply toward preferred responses, and full RLHF is reserved for when DPO stops improving. Start simple and layer alignment as your data and risk allow.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in