VKVlad KulikovPublic / lectures
RL for LLM8 / 13

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 KB

The 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 DKL≤δD_{\mathrm{KL}}\le\delta to penalized reward maximization with coefficient β\beta. Why are they related, yet β\beta 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 β\beta 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 −0.7-0.7, 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 −0.7-0.7 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 R−αCR-\alpha C and claims that choosing α\alpha removes the need for a separate safety constraint.

  1. Compare fixed scalarization with max⁡E[R]\max\mathbb E[R] subject to E[C]≤d\mathbb E[C]\le d.

  2. Explain the role of the dual variable λ\lambda and how adapting it differs from fixing α\alpha in advance.

  3. Why should reward and cost models be trained, logged, and validated separately?

  4. Why does an average-cost constraint fail to guarantee safety on every prompt?

  5. 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 R−αCR-\alpha C 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 E[C]≤d\mathbb E[C]\le d, while projected dual ascent adapts the price of violation:

λ←[λ+ηλ(E^[C]−d)]+.\lambda\leftarrow[\lambda+\eta_\lambda(\widehat{\mathbb E}[C]-d)]_+.

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 πref=(0.5,0.3,0.2)\pi_{\mathrm{ref}}=(0.5,0.3,0.2), r=(0,1,−0.5)r=(0,1,-0.5), and β=1\beta=1, compute unnormalized weights, π∗\pi^*, expected reward, KL, and regularized value. Compare with the reference.

Show answer and solution

The weights are (0.500000,0.815485,0.121306)(0.500000,0.815485,0.121306) and

π∗≈(0.347998,0.567574,0.084429).\pi^*\approx(0.347998,0.567574,0.084429).

Expected reward is 0.5253590.525359, KL is 0.1629470.162947, and the regularized value is 0.3624120.362412. The reference value is 0.20.2.

Reward shifts and reference support

Prove that a common reward shift leaves π∗(y)∝πref(y)er(y)/β\pi^*(y)\propto\pi_{\mathrm{ref}}(y)e^{r(y)/\beta} 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 ec/βe^{c/\beta}, 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 r(x,y)=βlog⁡(π∗/πref)+βlog⁡Z(x)r(x,y)=\beta\log(\pi^*/\pi_{\mathrm{ref}})+\beta\log Z(x) into Bradley–Terry, show cancellation of log⁡Z(x)\log Z(x), and derive the DPO logit and −log⁡σ(z)-\log\sigma(z) loss.

Show answer and solution

The two βlog⁡Z(x)\beta\log Z(x) terms cancel inside the Bradley–Terry reward difference, yielding the reference-corrected pair logit. The observed-choice negative log-likelihood is

−log⁡σ(z)=softplus⁡(−z).-\log\sigma(z)=\operatorname{softplus}(-z).

DPO loss and gradient weight

For policy log-probabilities −2.0,−3.0-2.0,-3.0, reference log-probabilities −2.2,−2.4-2.2,-2.4, and β=0.5\beta=0.5, compute two log-ratios, margin, logit, loss, and βσ(−z)\beta\sigma(-z). What happens at a large positive margin?

Show answer and solution

The chosen log-ratio is 0.20.2, the rejected log-ratio −0.6-0.6, so h=0.8h=0.8 and z=0.4z=0.4. The loss is 0.5130150.513015 and βσ(−z)=0.200656\beta\sigma(-z)=0.200656. The weight approaches zero for a large positive margin.

Finite targets in cDPO and IPO

Compute cDPO at z=2z=2, ε=0.1\varepsilon=0.1. For IPO with τ=0.25\tau=0.25, find the target and losses at margins 1,2,41,2,4. Explain the finite optimum.

Show answer and solution

At z=2z=2, the cDPO loss is 0.3269280.326928. With τ=0.25\tau=0.25, the IPO target is 22, and losses at 1,2,41,2,4 are 1,0,41,0,4. IPO penalizes both under- and overshooting a finite target.

Exact likelihood displacement

For logits (−t,−3t,4t)(-t,-3t,4t), prove the chosen–rejected log-margin is 2t2t, compute probabilities at t=0,0.5,1t=0,0.5,1, explain falling chosen probability, and propose an alert.

Show answer and solution

Softmax normalization cancels, giving log⁡p1−log⁡p2=2t\log p_1-\log p_2=2t.

ttp1p_1p2p_2p3p_3
00.3333330.3333330.333333
0.50.0737990.0271490.899052
10.0066870.0009050.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 0.60.6 and cost 00; the risky action has reward 1.01.0 and cost 11. The policy chooses the risky action with probability qq.

  1. Write E[R]\mathbb E[R] and E[C]\mathbb E[C].

  2. Find q∗q^* under E[C]≤0.25\mathbb E[C]\le0.25 and compute expected reward.

  3. For

L(q,λ)=0.6+0.4q−λ(q−0.25),\mathcal L(q,\lambda)=0.6+0.4q-\lambda(q-0.25),

which q∈[0,1]q\in[0,1] maximizes the Lagrangian for λ<0.4\lambda<0.4, λ>0.4\lambda>0.4, and λ=0.4\lambda=0.4?

  1. Take one dual step from λ=0.2\lambda=0.2 with estimated cost 0.60.6, limit 0.250.25, and step size 0.50.5.

  2. Explain why an average budget and a hard prohibition on the risky action express different requirements.

Show answer and solution

The mixture gives

E[C]=q,E[R]=0.6+0.4q.\mathbb E[C]=q, \qquad \mathbb E[R]=0.6+0.4q.

Reward increases with qq and the constraint requires q≤0.25q\le0.25, so q∗=0.25q^*=0.25 and expected reward is 0.70.7.

Also

L(q,λ)=0.6+0.25λ+(0.4−λ)q.\mathcal L(q,\lambda)=0.6+0.25\lambda+(0.4-\lambda)q.

Thus q=1q=1 for λ<0.4\lambda<0.4, q=0q=0 for λ>0.4\lambda>0.4, and every q∈[0,1]q\in[0,1] is optimal at λ=0.4\lambda=0.4. One dual step is

[0.2+0.5(0.6−0.25)]+=0.375.[0.2+0.5(0.6-0.25)]_+=0.375.

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 β>0\beta>0:

Python
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:

Python
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
Python
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:

Python
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:

Python
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-12
Show answer and solution
Python
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 out

Check:

Python
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:

Python
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:

Python
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-6
Show answer and solution
Python
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:

Python
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:

Python
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-12
Show answer and solution
Python
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)) ** 2

SimPO and ORPO

Implement length-normalized SimPO scoring and loss, stable log-odds from logp, and ORPO total loss:

Python

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:

Python
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-12
Show answer and solution
Python
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:

Python
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:

Python
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
Python
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:

Python
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 q∗=0.25q^*=0.25, 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:

Python
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
Python
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 0.250.25. 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.