Module 8. RLHF and direct preference optimization
Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.
Download course files (.zip) 382.1 KBThe archive preserves the source folder structure, both languages, and supporting files.
Four roles and physical implementation
A report says that RLHF always requires four full LLM copies in memory. Separate logical roles from physical copies, state what each role computes and whether it updates, give four memory-saving implementations, and explain which RLHF costs remain.
Show answer and solution
The updated policy generates responses, the frozen reference anchors KL, the reward model scores responses, and the critic estimates future return. These are roles, not four mandatory independent full copies. Sharing a policy/value backbone, LoRA, quantizing frozen models, caching reference log-probabilities, CPU offload, process separation, and smaller reward/value models can reduce memory. Fresh generation, forward passes, rollout storage, value estimation, and policy backpropagation remain.
KL constraint and KL penalty
Compare constrained reward maximization with to penalized reward maximization with coefficient . Why are they related, yet does not predetermine realized neural-training KL? What does KL regularization guarantee, and what does it not guarantee about reward correctness or retained capabilities?
Show answer and solution
The penalty is a Lagrangian form of the constraint. In ideal convex optimization an active constraint corresponds to a multiplier. Neural optimization is finite, stochastic, and non-convex, so selected does not predetermine realized KL. KL prices movement and preserves support; it does not validate the reward model or preserve every capability.
Sampled ratio and exact KL
A sampled token has log-ratio , and an engineer calls it negative KL. Correct the statement. Distinguish one sampled log-ratio, its current-policy expectation, exact categorical KL, and a sequence sum. Explain why response masks and the actual decoding policy belong to the contract.
Show answer and solution
The value is one sampled log-ratio and may be negative. Its expectation under the current policy is forward KL and is non-negative. Exact categorical KL sums over the vocabulary; a sampled sequence sum follows one response and may also be negative. Masks define policy actions, and the actual decoding distribution defines the correct probability after temperature and truncation.
Assumptions behind DPO
List the assumptions connecting the KL-regularized optimum, implicit reward, and DPO loss: same-prompt pairs, Bradley–Terry, fixed reference, support, pair quality, and the distinction between optimizing a loss and proving that preferences are correct.
Show answer and solution
The derivation uses same-prompt pairs, Bradley–Terry likelihood, the exact KL-regularized optimum, a fixed reference, compatible support, and consistent preprocessing. Labels must represent the intended rubric. If Bradley–Terry is misspecified, DPO remains a valid pairwise loss, but exact implicit-reward recovery becomes only an approximation.
DPO and classical offline RL
Compare DPO with classical offline RL by data unit, transitions, explicit Q-functions, Bellman bootstrapping, reference policy, and weak-coverage risk. State a more precise relationship.
Show answer and solution
DPO consumes prompt–completion pairs and uses no explicit Q-function, Bellman target, or bootstrapping. Classical offline RL typically consumes transitions or trajectories. Both face coverage risk, but offline RL may amplify value errors through bootstrapping while DPO may exploit limited pairs, length, style, or unlabelled alternatives. DPO is related fixed-data policy optimization, not literally classical value-based offline RL.
Margin growth without winner-probability growth
Does a growing DPO margin imply a growing chosen-completion probability? Explain using a third unlabelled completion and list at least four diagnostics that must accompany the loss.
Show answer and solution
No. Both paired probabilities can fall while the rejected one falls faster and a third completion absorbs mass. Monitor chosen/rejected log-probabilities, separate reference ratios, margin, KL, lengths, gradient weights, and fresh-generation quality.
Auditing data, length, and templates
Chosen responses are longer, truncation differs by label, and reference log-probabilities were cached before a chat-template change. List independent faults, required metadata, and why dividing by length is not a universal repair.
Show answer and solution
The faults are a length shortcut, asymmetric truncation, and stale reference log-probabilities. Preserve raw text, token IDs, masks, pre/post-truncation lengths, stop reason, tokenizer/template/checkpoint versions, decoding settings, provenance, and preprocessing version. Dividing by length changes the objective and does not repair stale caches, truncation, or bad labels.
Choose a method from the data contract
Choose DPO, IPO, SimPO, KTO, ORPO, PPO/RLOO, or RLVR for: high-quality pairs with a reference; unpaired desirable/undesirable labels; a one-stage reference-free run; runaway margins after quality plateaus; verifiable reward with fresh current-policy samples; and strongly differing lengths with explicit length normalization. Name one key assumption and one required metric for each.
Show answer and solution
Use DPO for good pairs with a reference; KTO for unpaired binary labels; ORPO for a one-stage SFT-plus-pair objective; IPO/cDPO/early stopping for runaway margins or label noise; RLVR with an online update for verifiable fresh rewards; and SimPO when explicit length-normalized scoring is desired. Each still requires independent quality and method-specific diagnostics.
Scalarization or an explicit constraint
A team combines utility and risk into and claims that choosing removes the need for a separate safety constraint.
-
Compare fixed scalarization with subject to .
-
Explain the role of the dual variable and how adapting it differs from fixing in advance.
-
Why should reward and cost models be trained, logged, and validated separately?
-
Why does an average-cost constraint fail to guarantee safety on every prompt?
-
Name at least three additional mechanisms for critical actions and explain why Module 13 still needs an independent release gate.
Show answer and solution
A fixed sum commits to one price of risk. If the cost scale or violation rate changes, the objective does not require the policy to return to a declared budget. A constrained objective states , while projected dual ascent adapts the price of violation:
The multiplier rises above budget and falls when the constraint has slack. Reward and cost signals may use different data, features, failure modes, and thresholds, so they should remain separately auditable.
An average constraint still permits rare severe violations. Critical actions need hard filters or safe action sets, isolated tools, refusal or abort paths, and scenario-level checks. Module 13's release gate remains an independent layer.
Exact KL-regularized optimum
For , , and , compute unnormalized weights, , expected reward, KL, and regularized value. Compare with the reference.
Show answer and solution
The weights are and
Expected reward is , KL is , and the regularized value is . The reference value is .
Reward shifts and reference support
Prove that a common reward shift leaves unchanged and that zero reference support remains zero for finite reward. Interpret both properties.
Show answer and solution
A common shift multiplies all unnormalized weights by , which cancels in normalization. Zero reference mass gives zero tilted mass for finite reward. These express reward-offset non-identifiability and the forward-KL support restriction.
From implicit reward to DPO
Substitute into Bradley–Terry, show cancellation of , and derive the DPO logit and loss.
Show answer and solution
The two terms cancel inside the Bradley–Terry reward difference, yielding the reference-corrected pair logit. The observed-choice negative log-likelihood is
DPO loss and gradient weight
For policy log-probabilities , reference log-probabilities , and , compute two log-ratios, margin, logit, loss, and . What happens at a large positive margin?
Show answer and solution
The chosen log-ratio is , the rejected log-ratio , so and . The loss is and . The weight approaches zero for a large positive margin.
Finite targets in cDPO and IPO
Compute cDPO at , . For IPO with , find the target and losses at margins . Explain the finite optimum.
Show answer and solution
At , the cDPO loss is . With , the IPO target is , and losses at are . IPO penalizes both under- and overshooting a finite target.
Exact likelihood displacement
For logits , prove the chosen–rejected log-margin is , compute probabilities at , explain falling chosen probability, and propose an alert.
Show answer and solution
Softmax normalization cancels, giving .
| 0 | 0.333333 | 0.333333 | 0.333333 |
| 0.5 | 0.073799 | 0.027149 | 0.899052 |
| 1 | 0.006687 | 0.000905 | 0.992408 |
The third logit captures mass while the chosen logit declines more slowly than the rejected. Alert on growing margin with falling chosen log-probability, then check semantic quality independently.
Two actions and a cost budget
The safe action has reward and cost ; the risky action has reward and cost . The policy chooses the risky action with probability .
-
Write and .
-
Find under and compute expected reward.
-
For
which maximizes the Lagrangian for , , and ?
-
Take one dual step from with estimated cost , limit , and step size .
-
Explain why an average budget and a hard prohibition on the risky action express different requirements.
Show answer and solution
The mixture gives
Reward increases with and the constraint requires , so and expected reward is .
Also
Thus for , for , and every is optimal at . One dual step is
An average budget permits the risky action at a limited frequency; a hard prohibition requires zero probability in the relevant states.
Optimal policy and reward–KL frontier
Implement a stable function that preserves reference support and validates :
import numpy as np
def optimal_kl_policy(reference: np.ndarray, rewards: np.ndarray, beta: float) -> np.ndarray:
"""Return argmax E_pi[r] - beta * KL(pi || reference)."""
...Sweep beta and tabulate reward and KL.
Open checks:
p = optimal_kl_policy([1/3, 1/3, 1/3], [1.0, 0.0, -1.0], 1.0)
np.testing.assert_allclose(p, [0.66524096, 0.24472847, 0.09003057], rtol=1e-7)Show answer and solution
import numpy as np
def optimal_kl_policy(reference, rewards, beta):
reference = np.asarray(reference, dtype=np.float64)
rewards = np.asarray(rewards, dtype=np.float64)
if reference.ndim != 1 or rewards.shape != reference.shape or beta <= 0:
raise ValueError("invalid inputs")
if np.any(reference < 0) or not np.isclose(reference.sum(), 1.0):
raise ValueError("reference must be a distribution")
support = reference > 0
logits = np.full_like(reference, -np.inf)
logits[support] = np.log(reference[support]) + rewards[support] / beta
maximum = np.max(logits[support])
weights = np.zeros_like(reference)
weights[support] = np.exp(logits[support] - maximum)
return weights / weights.sum()Token-level KL shaping
Implement response-masked token rewards with terminal reward on the last active token:
import numpy as np
def shape_token_rewards(policy_logprobs, reference_logprobs, terminal_reward, beta, mask):
...Verify the sequence identity and reject an empty mask.
Open checks:
policy = np.array([-0.5, -0.7])
reference = np.array([-0.4, -0.9])
shaped = shape_token_rewards(policy, reference, terminal_reward=1.0, beta=0.5, mask=np.ones_like(policy))
assert shaped.shape == (2,)
assert abs(float(shaped[0]) - (-0.5 * (-0.5 - -0.4))) < 1e-12
assert abs(float(shaped.sum()) - (1.0 - 0.5 * float((policy - reference).sum()))) < 1e-12Show answer and solution
import numpy as np
def shape_token_rewards(p, q, terminal_reward, beta, mask):
p, q, mask = [np.asarray(x, dtype=np.float64) for x in (p, q, mask)]
if p.ndim != 1 or q.shape != p.shape or mask.shape != p.shape:
raise ValueError("aligned vectors required")
if np.any((mask != 0) & (mask != 1)) or mask.sum() == 0:
raise ValueError("non-empty binary mask required")
out = -beta * (p - q) * mask
out[np.flatnonzero(mask)[-1]] += terminal_reward
return outCheck:
out = shape_token_rewards([-0.2, -1.0, -2.0], [-0.2, -1.3, -1.8], 1.5, 0.2, [0, 1, 1])
assert np.isclose(out.sum(), 1.5 - 0.2 * (0.3 - 0.2))Batch DPO diagnostics
Implement mean loss, pair accuracy, margin, chosen/rejected log-probabilities, implicit rewards, and gradient weight:
import numpy as np
def summarize_pairs(pc, pr, rc, rr, beta) -> dict[str, float]:
...Test a case where pair accuracy rises while chosen probability falls.
Open checks:
report = summarize_pairs([-2.0, -1.0], [-3.0, -1.5], [-2.5, -1.2], [-2.6, -1.4], 0.1)
assert abs(report["pair_accuracy"] - 1.0) < 1e-12
assert abs(report["mean_margin"] - 0.6) < 1e-12
assert abs(report["mean_chosen_logp"] - (-1.5)) < 1e-12
assert abs(report["mean_loss"] - 0.6637095) < 1e-6Show answer and solution
import numpy as np
def softplus(x):
x = np.asarray(x, dtype=np.float64)
return np.maximum(x, 0.0) + np.log1p(np.exp(-np.abs(x)))
def summarize_pairs(pc, pr, rc, rr, beta):
pc, pr, rc, rr = [np.asarray(x, dtype=np.float64) for x in (pc, pr, rc, rr)]
margin = (pc - rc) - (pr - rr)
z = beta * margin
return {
"mean_loss": float(np.mean(softplus(-z))),
"pair_accuracy": float(np.mean(margin > 0)),
"mean_margin": float(np.mean(margin)),
"mean_chosen_logp": float(np.mean(pc)),
"mean_rejected_logp": float(np.mean(pr)),
"mean_gradient_weight": float(np.mean(beta / (1.0 + np.exp(z)))),
}cDPO and IPO
Implement vectorized cDPO and IPO:
import numpy as np
def conservative_dpo_loss(margin, beta, label_smoothing):
...
def ipo_loss(margin, tau):
...Check the zero-smoothing reduction and IPO minimum.
Open checks:
margins = np.array([-1.0, 0.0, 2.0])
beta = 0.3
conservative = conservative_dpo_loss(margins, beta, label_smoothing=0.0)
ordinary_dpo = np.logaddexp(0.0, -beta * margins)
np.testing.assert_allclose(conservative, ordinary_dpo)
assert abs(float(ipo_loss(np.array([2.0]), tau=0.25)[0])) < 1e-12Show answer and solution
import numpy as np
def softplus(x):
x = np.asarray(x, dtype=np.float64)
return np.maximum(x, 0.0) + np.log1p(np.exp(-np.abs(x)))
def conservative_dpo_loss(margin, beta, label_smoothing):
eps = float(label_smoothing)
if beta <= 0 or not 0 <= eps < 0.5:
raise ValueError("invalid beta or label_smoothing")
z = beta * np.asarray(margin, dtype=np.float64)
return (1.0 - eps) * softplus(-z) + eps * softplus(z)
def ipo_loss(margin, tau):
if tau <= 0:
raise ValueError("tau must be positive")
return (np.asarray(margin, dtype=np.float64) - 1 / (2 * tau)) ** 2SimPO and ORPO
Implement length-normalized SimPO scoring and loss, stable log-odds from logp, and ORPO total loss:
def simpo_reward(sequence_logp: float, length: int, beta: float) -> float:
...
def orpo_total_loss(chosen_mean_nll, chosen_logp, rejected_logp, lam) -> float:
...Explain why equal numerical hyperparameters do not imply equal update semantics.
Open checks:
assert abs(simpo_reward(-4.0, 2, 1.0) - (-2.0)) < 1e-12
assert abs(simpo_reward(-4.0, 4, 1.0) - (-1.0)) < 1e-12
assert abs(simpo_reward(-4.0, 2, 2.0) - (-4.0)) < 1e-12Show answer and solution
import math
def simpo_reward(sequence_logp, length, beta):
if length <= 0 or beta <= 0:
raise ValueError("positive inputs required")
return beta * sequence_logp / length
def log_odds_from_logprob(logp):
p = math.exp(logp)
return logp - math.log1p(-p)
def orpo_total_loss(chosen_mean_nll, chosen_logp, rejected_logp, lam):
odds_gap = log_odds_from_logprob(chosen_logp) - log_odds_from_logprob(rejected_logp)
return chosen_mean_nll + lam * math.log1p(math.exp(-odds_gap))Likelihood-displacement experiment
Implement the three-logit curve:
import numpy as np
def likelihood_displacement_curve(ts: np.ndarray) -> dict[str, np.ndarray]:
"""Softmax probabilities for logits [-t, -3t, 4t]."""
...Plot all probabilities and pair margin, and trigger an alert when margin grows while chosen log-probability falls. Explain why this is only a diagnostic.
Open checks:
ts = np.array([0.0, 0.2, 1.0, 3.0])
curve = likelihood_displacement_curve(ts)
np.testing.assert_allclose(
np.log(curve["chosen"]) - np.log(curve["rejected"]),
2.0 * ts,
atol=1e-12,
)
assert displacement_alert(curve)Show answer and solution
import numpy as np
def likelihood_displacement_curve(ts):
ts = np.asarray(ts, dtype=np.float64)
logits = np.stack([-ts, -3 * ts, 4 * ts], axis=1)
logits -= logits.max(axis=1, keepdims=True)
p = np.exp(logits); p /= p.sum(axis=1, keepdims=True)
return {"t": ts, "chosen": p[:, 0], "rejected": p[:, 1], "other": p[:, 2], "log_margin": 2 * ts}
def displacement_alert(curve):
return bool(np.all(np.diff(curve["log_margin"]) > 0) and np.mean(np.diff(np.log(curve["chosen"]))) < 0)The alert detects probability geometry, not semantic quality, so independent generation evaluation is still required.
Constrained objective and dual update
Implement:
import numpy as np
def dual_ascent_step(multipliers, expected_costs, limits, step_size):
"""Return projected dual ascent: [lambda + eta * (cost - limit)]_+."""
...
def two_action_constrained_optimum(
safe_reward,
risky_reward,
safe_cost,
risky_cost,
cost_limit,
):
"""Optimal risky-action probability for a two-action linear mixture."""
...Verify , reject an infeasible budget, and show that a multiplier rises under violation and falls otherwise. Explain why a numerically correct dual update does not validate the learned cost model.
Open checks:
updated = dual_ascent_step([0.2, 0.1], [0.6, 0.0], [0.25, 0.2], 0.5)
np.testing.assert_allclose(updated, [0.375, 0.0])Show answer and solution
import numpy as np
def dual_ascent_step(multipliers, expected_costs, limits, step_size):
lam = np.asarray(multipliers, dtype=np.float64)
costs = np.asarray(expected_costs, dtype=np.float64)
bounds = np.asarray(limits, dtype=np.float64)
if lam.shape != costs.shape or lam.shape != bounds.shape:
raise ValueError("all arrays must have the same shape")
if np.any(lam < 0) or not np.all(np.isfinite(lam)):
raise ValueError("multipliers must be finite and non-negative")
if step_size <= 0 or not np.isfinite(step_size):
raise ValueError("step_size must be finite and positive")
return np.maximum(0.0, lam + step_size * (costs - bounds))
def two_action_constrained_optimum(
safe_reward,
risky_reward,
safe_cost,
risky_cost,
cost_limit,
):
values = np.asarray(
[safe_reward, risky_reward, safe_cost, risky_cost, cost_limit],
dtype=np.float64,
)
if not np.all(np.isfinite(values)):
raise ValueError("all inputs must be finite")
if risky_cost <= safe_cost:
raise ValueError("the teaching contract requires risky_cost > safe_cost")
if cost_limit < safe_cost:
raise ValueError("the cost budget is infeasible even for the safe action")
q_max = np.clip(
(cost_limit - safe_cost) / (risky_cost - safe_cost),
0.0,
1.0,
)
return float(q_max if risky_reward > safe_reward else 0.0)The teaching example returns . The multiplier rises under violation and decreases, with projection at zero, when the constraint has slack. Correct code validates the update algebra, not the semantics or coverage of the cost model.