Question: RLHF take-home aligning a small open model
Companies: Hugging Face
Problem statement
A take-home over a few days: align a small open model with preference data and a parameter-efficient adapter on a single GPU, push the result to the Hugging Face Hub, and write up what happened, including what failed and why.
Choose the model, the data and the method yourself and justify them. The review covers your evaluation, your failure analysis and your reasoning about the loss.
As a warm-up, fill in the core of a DPO step without a framework:
"""The core of the DPO step, without a framework, so it can be unit-tested.
Fill in the three functions; the training script calls them per batch."""
def sequence_logp(token_logps, loss_mask):
"""Sum of log-probabilities over the response tokens of one sequence.
loss_mask is 1 on response tokens and 0 on prompt and padding tokens."""
raise NotImplementedError
def dpo_loss_and_grad(policy_chosen, policy_rejected, ref_chosen, ref_rejected, beta=0.1):
"""DPO loss for one preference pair from sequence log-probabilities.
Returns (loss, d_loss/d_policy_chosen, d_loss/d_policy_rejected).
Must stay finite for very large margins."""
raise NotImplementedError
def dpo_metrics(pairs, beta=0.1):
"""pairs: [(policy_chosen, policy_rejected, ref_chosen, ref_rejected)].
Returns {"loss", "accuracy", "margin", "chosen_reward", "rejected_reward"}
averaged over pairs; a reward is beta * (policy - ref) and accuracy is the
share of pairs whose chosen reward beats the rejected one."""
raise NotImplementedError
Scope it before you start
The take-home is graded on judgment more than on the final score: did you pick a setup that fits one GPU and a few days, did you measure the right things, and is the write-up honest about what failed. Decide these four things in the first hour and write them at the top of your README.
| Decision | Pick | Why |
|---|---|---|
| Model | A 0.5B instruct model (for example Qwen2.5-0.5B-Instruct) | Fits on one 16–24 GB GPU with room for long sequences; already follows a chat template, so you skip SFT |
| Data | A public binarized preference set (for example UltraFeedback binarized), 10k training pairs, 1k held out | Big enough to move the model, small enough to train in minutes |
| Method | DPO with a LoRA adapter | No reward model, no value network, no sampling during training; one forward pass of the reference per pair |
| Success | Win rate against the base model on 300 held-out prompts, length-controlled, plus no regression on a small capability check | A preference win without a capability check can hide a broken model |
Why DPO, not PPO. PPO needs a reward model, a value head, rollouts and four models in memory. DPO turns the same objective into a classification loss on pairs you already have. For a take-home, the honest answer is "DPO, because it is cheap and stable; PPO or GRPO if I had a verifiable reward or wanted on-policy data", and then say what DPO gives up: it never sees the model's own outputs, so it can't fix mistakes the data doesn't cover.
The pipeline
{"type":"excalidraw","elements":[
{"type":"ellipse","id":"data","x":0,"y":-5,"width":260,"height":80,"label":{"text":"Preference pairs\n10k, filtered"}},
{"type":"rectangle","id":"prep","x":340,"y":0,"width":220,"height":70,"label":{"text":"Chat template\n+ loss mask"}},
{"type":"rectangle","id":"train","x":640,"y":0,"width":240,"height":70,"label":{"text":"DPO trainer\nbase + LoRA r=16"}},
{"type":"rectangle","id":"eval","x":960,"y":0,"width":180,"height":70,"label":{"text":"Evals"}},
{"type":"rectangle","id":"hub","x":1220,"y":0,"width":200,"height":70,"label":{"text":"Hub: adapter\n+ model card"}},
{"type":"ellipse","id":"ref","x":640,"y":170,"width":240,"height":80,"label":{"text":"Reference\n(adapter off)"}},
{"type":"arrow","id":"a1","x":260,"y":35,"width":80,"height":0,"start":{"id":"data"},"end":{"id":"prep"}},
{"type":"arrow","id":"a2","x":560,"y":35,"width":80,"height":0,"start":{"id":"prep"},"end":{"id":"train"}},
{"type":"arrow","id":"a3","x":880,"y":35,"width":80,"height":0,"start":{"id":"train"},"end":{"id":"eval"}},
{"type":"arrow","id":"a4","x":1140,"y":35,"width":80,"height":0,"start":{"id":"eval"},"end":{"id":"hub"}},
{"type":"arrow","id":"a5","x":760,"y":70,"width":1,"height":100,"start":{"id":"train"},"end":{"id":"ref"},"strokeStyle":"dashed"}
]}
The dashed arrow is the trick that halves memory: with LoRA, the reference model is the same weights with the adapter switched off, so you never load a second copy.
- Filter the data. Drop pairs where chosen and rejected scores tie or differ by less than 1 point (noise), pairs longer than the length limit, and exact duplicates of eval prompts. Log how many you dropped and why.
- Format. Apply the model's own chat template. Build a loss mask that is 1 only on response tokens. Check one example by decoding it with the mask shown; this catches most bugs before training.
- Train (settings below), logging loss, reward accuracy, reward margin, chosen and rejected log-probabilities, and mean response length every 10 steps.
- Evaluate base versus tuned on held-out prompts.
- Publish the adapter with a model card: data and filtering, hyperparameters, eval results with intervals, and known failures.
The numbers
LoRA size. For a 0.5B model with hidden size 896, 24 layers, an MLP width of 4,864 and grouped-query attention (key and value projections 896 → 128), rank 16 on every linear layer adds per layer: query and output 2 × 16 × (896 + 896) = 57,344; key and value 2 × 16 × (896 + 128) = 32,768; the three MLP matrices 3 × 16 × (896 + 4,864) = 276,480. That is 366,592 per layer, 8.8M in total, under 2% of the model.
Memory. Base weights in bf16: about 1 GB. Adapter, its gradients and Adam states: under 0.2 GB. The rest is activations: with 1,024-token sequences and both responses of a pair in one batch, gradient checkpointing keeps a micro-batch of 4 pairs well under 16 GB.
Compute. 10k pairs × 2 responses × about 600 tokens = 12M tokens per epoch. A LoRA step costs about 4 × params × tokens (forward, plus backward through the activations but not the frozen weight gradients): 4 × 0.5B × 12M = 2.4 × 10^16 FLOPs. The reference pass adds 2 × 0.5B × 12M = 1.2 × 10^16. Total ≈ 3.6 × 10^16. On one A100 at 30% of its 312 TFLOP/s peak, that is about 6 minutes per epoch; on a T4 at about 15 TFLOP/s effective, about 40 minutes. Either way you can afford three or four runs, which you need.
Settings to start from.
| Setting | Value | Note |
|---|---|---|
| β | 0.1 | Lower drifts further from the reference; try 0.05 and 0.3 if you have time |
| Learning rate | 5e-6 to 2e-5 with LoRA, cosine, 10% warmup | Too high shows as reward accuracy at 100% within 50 steps and falling log-probs |
| Effective batch | 64 pairs (micro-batch 4 × accumulation 16) | |
| Epochs | 1 | A second epoch on 10k pairs mostly overfits |
| LoRA | r = 16, α = 32, dropout 0.05, all linear layers | |
| Max length | 1,024 total, 512 prompt | Truncate the prompt from the left, never the response |
The DPO step, tested
The training library computes this for you, but the review will ask you to explain it, and writing it once catches the most common bug (summing log-probs over the prompt too). The starter in the prompt has the three functions to fill in.
"""The core of the DPO step, without a framework, so it can be unit-tested."""
import math
def sequence_logp(token_logps, loss_mask):
"""Sum of log-probabilities over the response tokens of one sequence.
loss_mask is 1 on response tokens and 0 on prompt and padding tokens."""
return sum(lp for lp, m in zip(token_logps, loss_mask) if m)
def softplus(x):
"""log(1 + e^x) without overflow."""
return max(x, 0.0) + math.log1p(math.exp(-abs(x)))
def sigmoid(x):
if x >= 0:
return 1.0 / (1.0 + math.exp(-x))
e = math.exp(x)
return e / (1.0 + e)
def dpo_loss_and_grad(policy_chosen, policy_rejected, ref_chosen, ref_rejected, beta=0.1):
"""loss = -log sigmoid(beta * ((pc - rc) - (pr - rr))) = softplus(-z).
Returns (loss, d_loss/d_policy_chosen, d_loss/d_policy_rejected)."""
z = beta * ((policy_chosen - ref_chosen) - (policy_rejected - ref_rejected))
weight = sigmoid(-z) # how wrong the pair still is: 0.5 at z = 0, -> 0 once it is learned
return softplus(-z), -beta * weight, beta * weight
def dpo_metrics(pairs, beta=0.1):
n = len(pairs)
totals = {"loss": 0.0, "accuracy": 0.0, "margin": 0.0, "chosen_reward": 0.0, "rejected_reward": 0.0}
for pc, pr, rc, rr in pairs:
chosen, rejected = beta * (pc - rc), beta * (pr - rr)
totals["loss"] += dpo_loss_and_grad(pc, pr, rc, rr, beta)[0]
totals["accuracy"] += chosen > rejected
totals["margin"] += chosen - rejected
totals["chosen_reward"] += chosen
totals["rejected_reward"] += rejected
return {k: v / n for k, v in totals.items()}
What the gradient says: each pair pushes the chosen response up and the rejected one down with weight β · σ(−z). That weight is 0.5β for a pair the model has not learned yet and goes to 0 once the implicit reward margin is large. DPO is a self-weighting classifier: it stops spending effort on pairs it already gets right.
import math
from dpo import dpo_loss_and_grad, dpo_metrics, sequence_logp
def close(a, b, tol=1e-9):
return abs(a - b) < tol
def test_only_response_tokens_count():
logps = [-5.0, -4.0, -0.5, -0.25, -9.0] # prompt, prompt, response, response, padding
assert close(sequence_logp(logps, [0, 0, 1, 1, 0]), -0.75)
def test_untrained_policy_loss_is_log_two():
loss, d_chosen, d_rejected = dpo_loss_and_grad(-12.0, -15.0, -12.0, -15.0, beta=0.1)
assert close(loss, math.log(2))
assert close(d_chosen, -0.05) and close(d_rejected, 0.05)
def test_loss_falls_as_the_margin_grows():
losses = [dpo_loss_and_grad(-10.0 + m, -10.0, -10.0, -10.0)[0] for m in (0, 1, 5, 20)]
assert losses == sorted(losses, reverse=True)
def test_huge_margins_stay_finite():
good = dpo_loss_and_grad(0.0, -1e4, -100.0, -100.0)
bad = dpo_loss_and_grad(-1e4, 0.0, -100.0, -100.0)
assert close(good[0], 0.0, 1e-6) and math.isfinite(bad[0])
assert close(bad[0], 0.1 * 1e4, 1e-6) # softplus(z) ~ z for large z
def test_metrics():
pairs = [(-10.0, -20.0, -12.0, -18.0), (-9.0, -8.0, -10.0, -10.0)]
m = dpo_metrics(pairs, beta=0.5)
# pair 1: chosen reward 1.0, rejected -1.0; pair 2: chosen 0.5, rejected 1.0
assert close(m["accuracy"], 0.5)
assert close(m["chosen_reward"], 0.75) and close(m["rejected_reward"], 0.0)
assert close(m["margin"], 0.75)
from dpo import dpo_loss_and_grad
def test_gradient_matches_finite_differences():
h = 1e-6
for pc, pr, rc, rr, beta in [(-10, -12, -11, -11, 0.1), (-3, -40, -20, -20, 0.5), (-50, -5, -9, -9, 0.05)]:
_, d_chosen, d_rejected = dpo_loss_and_grad(pc, pr, rc, rr, beta)
num_c = (dpo_loss_and_grad(pc + h, pr, rc, rr, beta)[0] - dpo_loss_and_grad(pc - h, pr, rc, rr, beta)[0]) / (2 * h)
num_r = (dpo_loss_and_grad(pc, pr + h, rc, rr, beta)[0] - dpo_loss_and_grad(pc, pr - h, rc, rr, beta)[0]) / (2 * h)
assert abs(d_chosen - num_c) < 1e-6 and abs(d_rejected - num_r) < 1e-6
The level 3 test runs DPO end to end on a toy: one prompt, four responses with hidden quality 0–3, 400 noisy labels, and a reference that prefers the worst response. Training on the labels alone recovers the true order.
import math
import random
from dpo import dpo_loss_and_grad
def softmax(xs):
top = max(xs)
es = [math.exp(x - top) for x in xs]
return [e / sum(es) for e in es]
def test_dpo_recovers_the_preference_order():
# One prompt, four possible responses with hidden quality 3 > 2 > 1 > 0.
# Labelers prefer the better response with Bradley-Terry probability.
rng = random.Random(0)
quality = [0.0, 1.0, 2.0, 3.0]
pairs = []
for _ in range(400):
a, b = rng.sample(range(4), 2)
p_a = 1 / (1 + math.exp(quality[b] - quality[a]))
pairs.append((a, b) if rng.random() < p_a else (b, a))
ref = [math.log(p) for p in softmax([0.5, 0.0, 0.0, -0.5])] # reference likes the worst one
theta = [0.5, 0.0, 0.0, -0.5]
for _ in range(200):
logp = [math.log(p) for p in softmax(theta)]
probs = softmax(theta)
grad = [0.0] * 4
for chosen, rejected in pairs:
_, gc, gr = dpo_loss_and_grad(logp[chosen], logp[rejected], ref[chosen], ref[rejected], beta=0.5)
for k in range(4): # d log p(y) / d theta_k = [y == k] - p_k
grad[k] += gc * ((chosen == k) - probs[k]) + gr * ((rejected == k) - probs[k])
theta = [t - 0.5 * g / len(pairs) for t, g in zip(theta, grad)]
final = softmax(theta)
assert final[3] > final[2] > final[1] > final[0]
Evaluation
- Held-out preference accuracy and margin. The cheapest check, from the same metrics as training. Expect 65–75% on held-out pairs from this kind of data; 95% means you overfit or leaked.
- Win rate against the base model. 300 held-out prompts, generate with both models at the same sampling settings, ask a strong judge model which answer is better, and run each comparison twice with the order swapped (judges favor the first position). Count a split decision as a tie. With 300 prompts, the 95% interval on a win rate near 50% is about ±5.7 points (1.96 × √(0.25 / 300)), so a 54% result is not a win. Say so.
- Length control. Report mean response length for both models and the win rate within length buckets. If the tuned model is 40% longer and only wins in the long bucket, the judge is rewarding length.
- Capability check. A few hundred items from a math or knowledge benchmark the base model already handles. A drop of more than a couple of points means β is too low or the learning rate too high.
- Read 20 outputs. Pick at random and write down what changed. Graders weight this heavily, because it is where you find the failure the metrics missed.
Failure analysis to expect and write up
| What you see | Likely cause | What to try |
|---|---|---|
| Responses get longer every step | Chosen answers in the data are longer on average; DPO learns length | Report length-controlled win rate; filter or balance pairs by length; add a length penalty |
| Chosen and rejected log-probs both fall, margin still grows | Normal for DPO: it widens the gap by pushing both down | Watch that chosen does not fall far (more than about 20 nats per response); raise β or lower the learning rate |
| Accuracy hits 100% in 50 steps | Learning rate too high, or a label leak (chosen always formatted differently) | Check that a classifier on surface features alone can't separate chosen from rejected |
| Model never stops generating | End-of-turn token missing from the response or masked out | Decode a training example with the mask; make the end token part of the response |
| Loss starts well below log 2 | Prompt tokens in the summed log-probs, or reference and policy differ at step 0 | Assert loss == log 2 on the first batch |
| Win rate up, capability check down | Over-optimization | Higher β, fewer steps, or mix in some SFT data |
Reviewer rubric
| Area | Weak | Strong |
|---|---|---|
| Scoping | Picks a 7B model and runs out of time | Chooses model, data and method from a stated budget, with the arithmetic |
| Correctness | Trusts the library defaults | Verifies the template, the loss mask and loss = log 2 at step 0 |
| Measurement | One win-rate number | Win rate with an interval, position swapping, length control, capability check |
| Honesty | Only the good result | Failures, what they cost, and what was tried |
| Artifact | Weights on the Hub, nothing else | Model card with data, settings, eval, limits; the run reproduces from one command |
Interviewer expectations
Mid-level (L4)
- Gets a DPO run with LoRA working end to end and publishes the adapter with a model card.
- Explains the DPO loss and why the reference model is there.
- Reports a win rate and a few example outputs.
Senior (L5)
- Scopes model, data and settings from a compute estimate, and checks the template, loss mask and step-0 loss before training.
- Evaluates with position-swapped judging, a confidence interval, length control and a capability check.
- Writes up at least two real failures (length growth, falling log-probs) with the evidence and the fix tried.
Staff (L6+)
- Compares DPO with PPO, GRPO and online DPO for this data and says what each would change and cost.
- Designs the ablation that matters (β, data filtering, length balancing) inside the budget, with seeds.
- Connects the result to what a team would do next: on-policy preference data, a reward model to label it, and the eval that would catch reward hacking.