RLHF
Reward models and reinforcement learning from human feedback.
Create a free account Try part of it here
Why it matters
ChatGPT and GPT-3 started from the same kind of pretrained model. What made ChatGPT feel so different was a training step called reinforcement learning from human feedback, or RLHF. It trains the model to write the answers people prefer. Its parts now show up all over post-training: a learned score, a model pushed to raise that score, and a fine for drifting too far from where it started. Its main weakness is a model that learns to game a flawed score. That problem is one of the central puzzles in AI safety.
What you'll be able to do
- Train a reward model on preference pairs with the Bradley-Terry loss
- Use a reward model to pick the best of several sampled answers
- Explain the KL-regularized objective that RLHF optimizes, and what β controls
- Recognize reward hacking and why RLHF needs a leash on the policy
What it covers
- The recipe
- Step 2: a reward model
- Worked example: the loss for four pairs
- Training a reward model on real preferences
- Step 3: reinforcement learning, on a leash
- The best policy has a simple shape
- Worked example: two answers, four leashes
- Watching reward hacking happen
- How the RL step actually runs
The recipe
Picture a new cook at a restaurant. First, they copy the house recipes. Next, the owner asks diners to taste two plates and say which they like better. A food critic studies thousands of those votes until they can guess what diners will pick. Last, the cook practices. They make dishes, the critic scores them, and the cook leans toward whatever scores well. The owner adds one rule: don't stray too far from the house recipes.
RLHF follows the same three steps. OpenAI laid them out in its InstructGPT paper in 2022, and most RLHF systems still use that recipe.
- SFT: fine-tune the base model on example conversations. That's supervised fine-tuning, which had its own lesson earlier in this phase.
- Reward model: collect preference pairs, where a person picked the better of two answers. Then train a model to score answers so the preferred ones score higher. This is the critic.
- Reinforcement learning: the model being trained is now called the policy. It writes answers, the reward model scores them, and the policy is updated to earn higher scores. It also pays a fine for drifting too far from the SFT model.
The paper's headline result is striking. The smallest InstructGPT had 1.3 billion parameters. GPT-3 had 175 billion, more than 100 times as many. Yet labelers preferred the small InstructGPT's answers. On that test, post-training beat a hundredfold jump in size.
The leash matters. Without it, the model finds ways to game the reward model.
Show the answer
The reward model, which learned from people's earlier choices
People only supply the preference pairs in step 2. During step 3 the reward model stands in for them, which is what makes it fast and cheap enough to run millions of times.
Step 2: a reward model
A reward model reads a prompt and an answer and gives back one number, its score. A higher score means "people would likely prefer this one."
You train it on preference pairs with the Bradley-Terry loss from the preference-data lesson. For each pair, the model scores both answers. Write the chosen answer's score as . Write the rejected answer's score as . The loss for that pair is:
Here (sigma) is the sigmoid from Phase 2. It squeezes any number into a chance between 0 and 1. The is the natural log, and the minus sign in front turns it into a cost.
In plain words: take the gap between the two scores. Turn the gap into the chance that the chosen answer wins. The loss is minus the log of that chance. A wide gap in the right direction gives a loss near 0. A gap in the wrong direction gives a big loss.
Training pushes the loss down over many pairs. So the model learns to give chosen answers higher scores than rejected ones, by as wide a margin as it can.
rewards = {"A": 0.9, "B": 0.4}
print(max(rewards, key=rewards.get))Show the answer
A
max with key=rewards.get picks the key with the highest value, so A, at 0.9, beats B. The reward model gives each answer a score, and training pushes toward the higher one.
Worked example: the loss for four pairs
A reward model scores the chosen answer 3 and the rejected answer 1. What's the loss? Then try three more pairs: a tie, a pair it gets backward, and the first pair with 10 added to both scores.
import math def bt_loss(r_chosen, r_rejected): gap = r_chosen - r_rejected chance = 1 / (1 + math.exp(-gap)) # sigmoid: the chance the chosen answer wins return -math.log(chance) for chosen, rejected in [(3.0, 1.0), (1.0, 1.0), (1.0, 3.0), (13.0, 11.0)]: print(chosen, rejected, round(bt_loss(chosen, rejected), 3))
For the first pair the gap is 3 − 1 = 2. The sigmoid turns 2 into a chance of 0.881. Minus the log of 0.881 is 0.127, a small loss: the model already ranks this pair the right way.
A tie gives a gap of 0, a chance of 0.5 and a loss of 0.693. The backward pair has a gap of −2. Its chance is only 0.119, so the loss jumps to 2.127. Training works hardest on pairs like that.
The last pair, 13 and 11, has the same gap of 2 as the first. So it has the same loss, 0.127. Only the gap between the scores matters, never the scores themselves.
Show the answer
Only differences matter: a higher score means 'more likely to be preferred' under Bradley-Terry
The loss depends only on r_chosen − r_rejected, so the scale and zero point are arbitrary. Adding 10 to every score changes nothing, as the worked example showed.
Training a reward model on real preferences
The practice API has a preference dataset built for this lesson and the next. The chat model, tiny-gpt-chat, wrote four answers to each of 360 product prompts. Then a brand reviewer, POST /v1/brand-check, judged each answer against four guidelines:
- name the product that was asked about;
- don't repeat a word in a sentence;
- use a material the catalog uses for that kind of product;
- mention the repair promise.
Each pair puts the best of the four answers against the worst. The reward model below never sees those guidelines. It only sees which answer in each pair won.
Once trained, the reward model is used for best-of-n sampling. Generate several answers (here, ), score each one, and keep the top scorer. It's like asking four people for a draft and keeping the one your editor likes most.
Here's what the code does, step by step:
- It loads the chat model and the 293 preference pairs. Pairs about six product names are held back for testing. The rest are for training.
- The
featuresfunction turns each prompt and answer into a long list of numbers. It uses the chat model's hidden states, the numbers the model builds inside itself as it reads. It averages them over the answer and also keeps the ones at the answer's last token. - The reward model is one
nn.Linearlayer, a weighted sum of those numbers. This layer is called the scoring head. It trains for 201 steps with the Bradley-Terry loss. - It checks how often the reward model prefers the chosen answer in the held-back pairs.
- For each held-back product, it writes four answers. The real brand reviewer counts each answer's flaws. Then it compares three ways of picking one answer: take the first, take the reward model's favorite, or take the reviewer's favorite.
It's slow, so the Run button counts the seconds.
import requests import torch import torch.nn as nn import torch.nn.functional as F from tinygpt import load_pretrained lm, tok = load_pretrained("tiny-gpt-chat") end = tok.token_id("<end>") pairs = requests.get("https://api.gradient.test/v1/preferences").json()["data"] held_out = {"Kestrel", "Lark", "River", "Sierra", "Summit", "Willow"} # product names kept for testing train = [p for p in pairs if p["prompt"].split()[2] not in held_out] test = [p for p in pairs if p["prompt"].split()[2] in held_out] print(f"{len(pairs)} preference pairs: {len(train)} to train on, {len(test)} held out") @torch.no_grad() def features(prompts, answers): # The chat model's final hidden states, averaged over the answer and taken at its last token. rows = [(tok.encode(f"<user> {p} <assistant>"), tok.encode(a) + [end]) for p, a in zip(prompts, answers)] rows = [((p + a)[:64], len(p)) for p, a in rows] T = max(len(ids) for ids, _ in rows) h = lm.hidden(torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in rows])) in_answer = torch.tensor([[float(start <= t < len(ids)) for t in range(T)] for ids, start in rows]).unsqueeze(-1) last = h[torch.arange(len(rows)), torch.tensor([len(ids) - 1 for ids, _ in rows])] return torch.cat([(h * in_answer).sum(dim=1) / in_answer.sum(dim=1), last], dim=-1) def side(ps, which): return features([p["prompt"] for p in ps], [p[which] for p in ps]) torch.manual_seed(0) reward = nn.Linear(2 * lm.head.weight.shape[1], 1) # the reward model's scoring head chosen, rejected = side(train, "chosen"), side(train, "rejected") opt = torch.optim.Adam(reward.parameters(), lr=1e-2) for step in range(201): loss = -F.logsigmoid(reward(chosen) - reward(rejected)).mean() # Bradley-Terry opt.zero_grad() loss.backward() opt.step() with torch.no_grad(): accuracy = (reward(side(test, "chosen")) > reward(side(test, "rejected"))).float().mean().item() print(f"reward model prefers the chosen answer in {accuracy:.0%} of held-out pairs") # best-of-4 on the held-out products, judged by the real brand reviewer kinds = sorted({" ".join(p["prompt"].split()[3:-1]) for p in pairs}) torch.manual_seed(1) picked = {"first answer": [], "reward model's best of 4": [], "reviewer's best of 4": []} for name in sorted(held_out): for kind in kinds: ids = torch.tensor([tok.encode(f"<user> Describe the {name} {kind} . <assistant>")] * 4) out = lm.generate(ids, 60, temperature=0.8, stop_id=end) answers = [tok.decode(row).split("<end>")[0].strip() for row in out[:, ids.shape[1]:].tolist()] review = requests.post("https://api.gradient.test/v1/brand-check", json={"items": [{"product": f"{name} {kind}", "text": a} for a in answers]}).json()["results"] flaws = [len(r["flaws"]) for r in review] with torch.no_grad(): scores = reward(features([f"Describe the {name} {kind} ."] * 4, answers)).squeeze(-1) picked["first answer"].append(flaws[0]) picked["reward model's best of 4"].append(flaws[int(scores.argmax())]) picked["reviewer's best of 4"].append(min(flaws)) for how, f in picked.items(): print(f"{how:<26} {sum(f) / len(f):.2f} guideline violations per answer")
The reward model agrees with the reviewer on 69% of the pairs it never trained on. That's in the range real reward models reach against held-out human judgments. It's a sobering number. The "reward" that RLHF pushes up is a noisy imitation of what people want.
Best-of-4 with the reward model cuts the flaws from 1.78 to 1.39 per answer. Picking with the reviewer itself would reach 0.99. The gap between 1.39 and 0.99 is the cost of the reward model's mistakes.
This reward model is a cheap version. It only trains a scoring head on top of the chat model's frozen hidden states. Production reward models are usually a full copy of the SFT model with a scoring head, and all of it is fine-tuned. (torch here is the course's in-browser PyTorch subset.)
Show the answer
It's an imperfect copy of the reviewer (69% agreement on held-out pairs), so it sometimes picks a worse answer
Both chose from the same four answers. The reward model learned the reviewer's taste from pairs alone, and it gets that taste wrong some of the time.
Step 3: reinforcement learning, on a leash
Best-of-n improves answers, but every request now costs answers instead of one. RLHF goes further. It trains the policy to write high-scoring answers in the first place.
There's a catch. The reward model is only about 70% right here, and a training process hunts for anything that raises the score. Left free, it will find the reward model's mistakes and use them. So RLHF keeps the policy on a leash, like a dog on a walk. The dog can explore, but only so far from you. Here, "you" is the SFT model.
The goal can be written as one line of math. First, the symbols, one at a time:
- is the prompt.
- is an answer the model writes.
- (the Greek letter pi) is the policy. Here it has nothing to do with 3.14.
- is the reference model: the SFT model, kept frozen.
- is the reward model's score for answer to prompt .
- means "the average over answers the policy writes."
- is the KL divergence from Phase 2. It measures how far apart two sets of chances are, and it's 0 when they match.
- (beta) is a number you choose. It sets how tight the leash is.
In plain words: find the policy with the best average score after you take off a fine for drifting from the SFT model. The fine is the drift times . A big means a short leash. A small lets the policy chase the score.
Show the answer
It fines the policy for drifting too far from the SFT model
That's the leash. Without it, the policy learns to exploit weaknesses in the reward model.
The best policy has a simple shape
This goal has an exact answer. Call the best policy (pi star). For one fixed prompt, the score depends only on the answer, so it's written .
Here is the number from the exponentials and logarithms lesson, about 2.718. The symbol means "is proportional to." You compute the right side for every answer. Then you divide each result by their total, so the chances add up to 1.
In plain words: start from how often the SFT model writes each answer. Multiply by a boost that grows with the answer's score. Then rescale so the chances add up to 1. Dividing the score by sets how strong the boost is.
Real models can't use this formula directly, because there are far too many possible answers to list. On a small made-up example, though, it shows exactly what does.
Go deeper Why that formula is the best policy
Let be the total you divide by in the formula above, so the chances in add up to 1. With a little algebra, the goal for any policy can be rewritten as:
Read it as: the goal equals a fixed amount, minus times how far is from . The first part doesn't depend on at all. A KL divergence is never negative, and it's 0 only when the two sets of chances match. So the goal is highest when equals .
To get there, write out the KL from to and swap in the formula for . The log of is , which pulls the reward out as its own term. Multiply through by and rearrange.
Worked example: two answers, four leashes
The SFT model writes a "plain" answer 80% of the time and a "punchy" one 20% of the time. The reward model scores plain 0 and punchy 1. How often does the best policy write each, at β = 10, 1, 0.5 and 0.25?
import math p_ref = [0.8, 0.2] # how often the SFT model writes "plain" and "punchy" reward = [0.0, 1.0] # the reward model's score for each for beta in [10, 1, 0.5, 0.25]: weights = [p * math.exp(r / beta) for p, r in zip(p_ref, reward)] total = sum(weights) print(beta, [round(w / total, 3) for w in weights])
Take . Plain gets 0.8 × e⁰ = 0.8. Punchy gets 0.2 × e¹ = 0.2 × 2.718 = 0.544. The total is 1.344. Dividing by it gives plain 0.595 and punchy 0.405. Punchy's share has about doubled.
At the boost is tiny, so the policy stays close to the SFT model: punchy only rises from 0.2 to 0.216. At the score is divided by a small number, so the boost is huge. Punchy jumps to 0.932.
That's the leash. A long leash, meaning a small , lets the reward model's opinion take over. That's fine when the reward model is right, and harmful when it's wrong.
Watching reward hacking happen
Now try five possible answers. One of them games the reward model: it repeats the repair promise three times. The reward model loves that, and gives it the top score of 1.6. The brand team hates it. So the code keeps two lists of scores, the reward model's and the brand team's true opinion. For each , it builds the best policy with the formula above and prints:
- the average reward model score;
- the average true quality;
- the KL, how far the policy has drifted from the SFT model;
- how often the policy writes the gaming answer.
import numpy as np answers = ["on brand, clean", "on brand, plain", "repeats a word", "wrong material", "repair promise three times"] p_ref = np.array([0.30, 0.35, 0.15, 0.15, 0.05]) # how likely the SFT model is to write each reward = np.array([1.0, 0.7, -0.5, -0.8, 1.6]) # the reward model's score true = np.array([1.0, 0.7, -0.5, -0.8, -1.0]) # what the brand team actually thinks print(f"{'beta':>5} {'reward score':>12} {'true quality':>12} {'KL':>5} share of the gaming answer") for beta in [10, 2, 1, 0.5, 0.25, 0.1]: p = p_ref * np.exp(reward / beta) # the optimal policy for this beta p /= p.sum() kl = float((p * np.log(p / p_ref)).sum()) print(f"{beta:>5} {p @ reward:>12.2f} {p @ true:>12.2f} {kl:>5.2f} {p[4]:.0%}")
Read the table from top to bottom, as shrinks and the leash gets longer. The reward model's score climbs the whole way, from 0.48 to 1.59. True quality rises at first and peaks at 0.50 when . Then it collapses to −0.97. By then the policy writes the gaming answer 98% of the time.
This is called reward hacking, or over-optimization. It's Goodhart's law in action: "when a measure becomes a target, it ceases to be a good measure." A school that's judged only on test scores can start teaching to the test. The scores go up, and the learning doesn't.
Researchers at OpenAI measured the same shape in real models. They used a much larger "gold" reward model as a stand-in for people. As training pushed the policy further from the reference, the trained reward model's score kept rising. The gold score peaked and then fell.
Show the answer
The reward model's score keeps rising, while true quality falls
The score goes from 0.83 to 1.59, while true quality drops from 0.50 to −0.97. A proxy score that rises while real quality falls is the sign of reward hacking.
How the RL step actually runs
With a real model, you can't list every possible answer, so you can't use the formula. Instead, RLHF uses a training method from reinforcement learning. The classic choice is PPO, short for proximal policy optimization. Each round goes like this:
- The policy writes a batch of answers.
- The reward model scores each one.
- The KL fine is subtracted token by token, so each token that drifts from the SFT model costs a little.
- The policy is nudged toward answers that scored better than expected. "Expected" comes from a fourth model, the value model. It predicts the score an answer is likely to get, from the part written so far.
- Each update is clipped, meaning capped, so no single round changes the policy too much.
That means four models in memory at once: the policy, the frozen reference, the reward model and the value model. It also means lots of answer writing and many settings to tune. PPO works, but it's famously fussy.
That's why the next lesson's method, DPO, became popular. It skips the reward model and the answer writing. It's also why reasoning models are trained with simpler variants, such as GRPO. Those use rewards you can check, like whether a math answer is right, instead of a learned reward model.
For marketers: you can use best-of-n with a scorer today, without training anything. Write several ad variants. Score them with a rubric-based LLM judge or a classifier, and keep the best. Spot-check the winners, though. A scorer can be gamed by copy that hits its keywords without being good.
Show the answer
It predicts the score an answer will get, so the policy can favor answers that beat that guess
The policy writes the answers and the frozen SFT model is the reference. The value model's guess is the "expected" score, and answers that beat it get reinforced.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
Why does RLHF penalize the KL divergence from the SFT model?