Skip to main content

[LLM 3/10] RLHF and PPO: Training a Model on a Reward You Cannot Differentiate

· 28 min read
Kobkrit Viriyayudhakorn
CEO, iApp Technology

In chapter 2 we taught the model by "imitating the answer key" one token at a time. But the qualities that make an AI assistant genuinely usable — correct, polite, not making things up, not sliding into English — have no answer key to imitate, and cannot be written directly as a loss function. This chapter is the most classical answer to that problem: RLHF (Reinforcement Learning from Human Feedback) with PPO. We will train a real reward model from Thai preference pairs, then write the PPO loop ourselves from scratch in about 120 lines, and close with my favorite experiment in the series: taking off the KL leash and watching the model cheat the reward live. This is deliberately the heaviest chapter of the series, because chapter 4 (DPO) and chapter 5 (GRPO) both start from this chapter's equations and each choose a different piece to delete.

Open in Colab03_rlhf_ppo.ipynb

1. The Problem

SFT in chapter 2 carries one hidden assumption: there must be an answer key to imitate. But think about what we actually want — say, "solve the math problem correctly, and explain it in readable Thai." That sentence has no single answer key: good answers come in a hundred shapes, and "readable" cannot be written as an equation.

Try to optimize these things directly and you always run into two walls:

Wall one — quality cannot be written as a loss. "Better" cannot be defined as a function, but humans are very good at comparing: show them two answers and ask which one they prefer, and they can do it immediately and fairly consistently. So the data you can actually collect comes in threes: a prompt xx, the chosen answer ywy_w, the rejected answer yly_l.

Wall two — even with a score, you cannot backprop. Suppose a magical function r(x,y)r(x,y) scored every answer. You still couldn't train supervised, because the answer yy comes from sampling tokens one at a time. The score arrives after the sampling is done, and derivatives cannot travel back through sampling — the path from rr back to the weights θ\theta breaks exactly there.

The road you'd like to takeThe wall it hits
Write a loss for "a good answer" directly"Good" has no equation — there are only comparisons
Have humans score, then backpropThe score sits behind token sampling — gradients can't walk through sampling
Have humans score live during trainingHumans can't keep pace with even a sliver of the rollouts

Hence the chapter's title: we are about to optimize a reward we cannot differentiate. The tool that can do that is called reinforcement learning.

2. What We're Going to Do

RLHF gets past both walls with a two-step walk:

  • Stage A — Reward Model: train a model rϕ(x,y)r_\phi(x,y) to imitate human comparisons from preference pairs (defeats wall one, and stands in for humans who can't score fast enough)
  • Stage B — PPO: use policy-gradient RL to push the policy toward rϕr_\phi's scores without differentiating through the sampling (defeats wall two), with a KL leash holding it back from fleeing the starting model

The price you pay is complexity: during training there are four models resident in VRAM at once — the policy πθ\pi_\theta (the one being trained), the reference πref\pi_{\text{ref}} (the frozen starting model), the reward model rϕr_\phi, and a value network VψV_\psi that hasn't introduced itself yet (wait for section 3.4).

The core idea of this chapter

RLHF is the optimization of a reward you cannot differentiate, through an imperfect proxy (the reward model). And optimizing a proxy hard, with nothing to restrain you, always fails by Goodhart's law: when a measure becomes a target, it ceases to be a good measure.

The KL term in equation 3.2 is therefore not a regularizer sprinkled in for good luck — it is the only thing standing between you and reward hacking. Section 8 will prove that sentence by taking it out before your eyes.

And one more sentence to hold on to for the whole series: this chapter's objective is the master equation of the second half of the series. Chapter 4 (DPO) solves it in closed form until the reward model and the RL loop cancel out. Chapter 5 (GRPO) changes how the advantage is estimated until the value network disappears. Understand this one chapter, and the next two become "deleting parts" — readable at sight.

3. The Equations

3.1 The reward model: Bradley–Terry

Stage A trains rϕr_\phi with one short loss:

LRM(ϕ)=E(x,yw,yl)D[logσ(rϕ(x,yw)rϕ(x,yl))]\mathcal{L}_{\text{RM}}(\phi) = -\mathbb{E}_{(x,y_w,y_l)\sim\mathcal{D}}\Big[\log\sigma\big(r_\phi(x,y_w) - r_\phi(x,y_l)\big)\Big]
  • rϕ(x,y)r_\phi(x,y) = a single scalar score per text — in practice a language model whose head is swapped for a single linear layer (num_labels=1)
  • σ\sigma = the sigmoid, turning the score difference into the probability a human picks ywy_w (the Bradley–Terry model)
  • the wider the gap rϕ(x,yw)rϕ(x,yl)r_\phi(x,y_w) - r_\phi(x,y_l), the lower the loss

The point people overlook and pay for later: this loss sees only the difference of the scores. Replace rϕr_\phi with rϕ+cr_\phi + c for any constant cc — the loss doesn't change at all. Which means the absolute scale of a reward model is meaningless and is not pinned down by training. Two runs can yield mean scores of 3.7 and −12.4 that rank identically. This is why you must always standardize rewards before feeding them into PPO (subtract the mean, divide by the std) — hold onto this; it returns in sections 7 and 9.

3.2 The master equation: the RLHF objective

If you memorize a single equation from this whole series, memorize this one:

maxθ ExD,yπθ(x)[rϕ(x,y)]    βDKL(πθ(x)πref(x))\max_\theta\ \mathbb{E}_{x\sim\mathcal{D},\,y\sim\pi_\theta(\cdot|x)}\big[r_\phi(x,y)\big] \;-\; \beta\,\mathbb{D}_{\text{KL}}\big(\pi_\theta(\cdot|x)\,\|\,\pi_{\text{ref}}(\cdot|x)\big)

In plain language: "collect as much reward as you can, but every step you walk away from the starting model costs a fine."

  • πθ\pi_\theta = the policy, the model being trained — note that yy is sampled from πθ\pi_\theta itself; this is the structural difference from SFT, which learns from static data sitting in a file
  • πref\pi_{\text{ref}} = the reference, the starting model (the post-SFT model from chapter 2), frozen throughout training
  • β\beta = the price per nat of wandering off — the tightness of the leash
  • DKL\mathbb{D}_{\text{KL}} = the distributional distance between the policy and the reference
Why this is the master equation of the second half of the series

Chapter 4 (DPO) will prove this equation has a closed-form solution, then flip it inside out until rϕr_\phi and the RL loop both vanish. Chapter 5 (GRPO) will keep the RL skeleton but change how the advantage is computed until VψV_\psi vanishes. Neither chapter proposes a new objective — they solve this same equation with different tools.

3.3 The PPO clipped surrogate: Stage B's engine

Raw policy gradient (REINFORCE) can use a batch of rollouts for exactly one update before throwing it away — very expensive, because generation is the bottleneck. PPO wants to squeeze the same rollouts for several epochs, so it needs a correction factor (the importance sampling ratio):

ρt=πθ(atst)πθold(atst)\rho_t = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\theta_{\text{old}}}(a_t \mid s_t)}
  • sts_t = the state at position tt: the prompt plus every token sampled so far
  • ata_t = the "action": the next token, already sampled during the rollout
  • πθold\pi_{\theta_{\text{old}}} = a snapshot of the policy at rollout time — computed once and frozen

Then ρt\rho_t gets pinched with a clip:

LCLIP(θ)=Et[min(ρtA^t, clip(ρt,1ϵ,1+ϵ)A^t)]\mathcal{L}^{\text{CLIP}}(\theta) = \mathbb{E}_t\Big[\min\big(\rho_t\,\hat A_t,\ \text{clip}(\rho_t,\,1-\epsilon,\,1+\epsilon)\,\hat A_t\big)\Big]
  • A^t\hat A_t = the advantage: "how much better than expected was this token" (defined in the next subsection)
  • ϵ\epsilon = the width of the trust region (standard value 0.2)

The heart is that min + clip work together with deliberate pessimism: if A^t\hat A_t is positive (a good token), the payoff from pushing ρt\rho_t is capped at 1+ϵ1+\epsilon — pushing beyond that earns nothing, gradient zero. But if A^t\hat A_t is negative (a bad token), the min always picks the worse branch — the penalty has no floor. One-sentence summary: gains capped, losses uncapped. The policy therefore moves in small steps that stay close to where it already is.

Don't get confused: there are two "old models," and they are not the same model

πref\pi_{\text{ref}} in equation 3.2 is frozen for the entire run and serves as the KL leash. πθold\pi_{\theta_{\text{old}}} in equation 3.3 is a snapshot at the latest rollout, refreshed every round, and serves as the trust region. The number-one bug among people writing their own PPO is storing these two in the same variable.

3.4 GAE: computing the advantage without drowning in noise

The advantage is built from the TD error of the value network VψV_\psi:

δt=rt+γVψ(st+1)Vψ(st)\delta_t = r_t + \gamma V_\psi(s_{t+1}) - V_\psi(s_t) A^t=l=0(γλ)lδt+l\hat A_t = \sum_{l=0}^{\infty} (\gamma\lambda)^l\,\delta_{t+l}
  • Vψ(st)V_\psi(s_t) = the value network's prediction of "from here to the end, how much more reward will be collected" — this is model number four
  • rtr_t = the per-token reward (in our task: a KL penalty at every position, plus the task score at the final token)
  • γ\gamma = the discount factor (LLM work usually uses 1.0)
  • λ\lambda = the bias–variance dial: λ=0\lambda = 0 trusts VψV_\psi wholeheartedly (high bias if VψV_\psi mispredicts), λ=1\lambda = 1 doesn't trust it at all and waits for the real outcome to the very end (high variance, carrying the noise of the whole trajectory); the popular value is 0.95
Remember this V_ψ well — it is the one GRPO will kill

VψV_\psi is a model roughly the size of the policy that must be trained alongside it with a loss of its own. If VψV_\psi predicts garbage, the advantage is garbage, and the policy learns from a garbage signal — PPO's classic failure point. Chapter 5 will answer the question "what if we replaced VψV_\psi with the mean of a group of answers sampled from the same prompt?" That is the entire GRPO algorithm — deleting the fourth model with a single average.

3.5 The full PPO loss: three terms, two models

Assemble every piece into the one loss the optimizer actually sees (written as a minimization):

LPPO=LCLIP  +  c1Et[(Vψ(st)R^t)2]    c2Et[H[πθ(st)]]\mathcal{L}_{\text{PPO}} = -\mathcal{L}^{\text{CLIP}} \;+\; c_1\,\mathbb{E}_t\Big[\big(V_\psi(s_t) - \hat R_t\big)^2\Big] \;-\; c_2\,\mathbb{E}_t\Big[\mathcal{H}\big[\pi_\theta(\cdot \mid s_t)\big]\Big]
  • first term = the clipped surrogate from 3.3 (negated because we want to maximize it)
  • second term = the value loss teaching VψV_\psi to predict close to the actual return R^t\hat R_t; c1c_1 is usually 0.5
  • third term = the entropy bonus H\mathcal{H}, keeping the distribution from collapsing too fast; c2c_2 is usually 0.01
  • as for the KL leash from equation 3.2, standard practice folds it into the per-token reward: rtrtβ(logπθlogπref)r_t \leftarrow r_t - \beta\,(\log\pi_\theta - \log\pi_{\text{ref}}), which is the approach we take in section 7

Count all the toys that need tuning: 4 models, plus ϵ,β,γ,λ,c1,c2\epsilon, \beta, \gamma, \lambda, c_1, c_2, and two separate learning rates. This is why PPO is famous for "run it twice with different seeds, get two completely different stories" — and it is the reason for chapter 4's entire existence.

4. Seeing the Equations

Bradley–Terry: the gradient piles onto the pairs still ranked wrong

Pick the Bradley-Terry mode in the tool below and drag the margin: pairs the reward model already ranks confidently right (large positive margin) have almost no gradient left — Stage A training automatically spends its budget on the pairs it still ranks wrong.

Loss family
Positive means the model already prefers the chosen response.
Bradley-Terry has no temperature term.
-6-4-20246reward margin Δlossgradient weight

Drag anywhere on the plot, or use the Δ slider with the arrow keys.

Δ2.00
Loss0.1269
Gradient weight0.119211.9% of maximum
σ(−βΔ)0.119211.92%

This pair teaches almost nothing.The model already ranks this pair correctly, so σ(−βΔ) is only 11.92% and the gradient is 11.9% of what a hard pair would give. This is why preference datasets full of obvious wins barely move the model: the easy pairs are silently ignored, and the few genuinely confusing pairs do all the work.

If this plot reminds you of DPO's gradient in chapter 4 — that is no coincidence. DPO lifts this Bradley–Terry model wholesale, merely changing what plays the role of the reward.

The trust region made visible: min + clip

A two-panel plot of the clipped surrogate objective against the probability ratio for positive and negative advantages, with the clipped regions shadedA two-panel plot of the clipped surrogate objective against the probability ratio for positive and negative advantages, with the clipped regions shaded

Figure 3.1Equation 3.3 drawn directly at ε = 0.2 — on the positive-Â side, the payoff is capped at 1+ε (the flat region = zero gradient), while on the negative-Â side the penalty has no floor, because min always picks the worse branch

Study the asymmetry closely — it is PPO's entire personality: moving toward something good earns at most 20% per step, but hand too much probability to a bad token and you are hauled back at full force, every time. The "flat region" in the figure is the trust region that lets PPO reuse old rollouts for several epochs without exploding.

GAE's bias–variance dial

Two panels: a synthetic rollout with a terminal reward, and GAE advantage curves at lambda 0, 0.5, 0.95 and 1.0Two panels: a synthetic rollout with a terminal reward, and GAE advantage curves at lambda 0, 0.5, 0.95 and 1.0

Figure 3.2GAE on a synthetic 20-step rollout (γ = 1): per-step rewards are small noise, the real score arrives at the end, and V_ψ is deliberately set to under-predict by about 0.4 — at λ = 0 the signal never reaches the early tokens; at λ = 1 every token gets full credit along with full noise (an illustration of the mechanism, not real training data)

Read the lines from the bottom up: at λ=0\lambda = 0 (green) the advantage hugs zero nearly everywhere — the end-of-episode score never reaches the early tokens, because everything is filtered through a VψV_\psi that mispredicts. At λ=1\lambda = 1 (red), every token gets full credit from the ending but carries the accumulated noise of the whole trajectory with it. λ=0.95\lambda = 0.95 (blue) is the middle ground the whole field has settled on — the signal travels far, but the noise is damped.

Before moving on, build your intuition for the word advantage — "how much better than expected" — by hand: this tool uses the group mean as its baseline instead of VψV_\psi (a full-blown spoiler of chapter 5). Press the All correct preset and watch what happens when every answer earns the same reward:

Try a group:

Rewards r_i (G = 8)

Unchecked is the Dr.GRPO variant: it keeps the centring but drops the std, removing the bias toward low-variance groups.
r1 = 1.001.00r2 = 0.00-1.00r3 = 1.001.00r4 = 1.001.00r5 = 0.00-1.00r6 = 0.00-1.00r7 = 1.001.00r8 = 0.00-1.00Â = 0 (no update)
mean(r)0.5000
std(r)0.5000
max |Â|1.000
Formula(r − μ) / σGRPO

Watch the std term.Dividing by std(r) = 0.500 rescales this whole group. A group that happened to be near-unanimous gets a large multiplier and dominates the update, even though it carries less information than a group that genuinely disagreed. Untick the box to see the same rewards without the rescaling.

Advantage zero across the whole group = no signal to learn from — hold onto that feeling for section 9.

The KL leash: equation 3.2 drawn as a picture

A plot of proxy reward against KL showing a beta 0.05 path that stops at an equilibrium point and a beta 0 path that runs into the reward-hacking regionA plot of proxy reward against KL showing a beta 0.05 path that stops at an equilibrium point and a beta 0 path that runs into the reward-hacking region

Figure 3.3Two training trajectories in the (KL, reward) plane — β = 0.05 climbs and then stops at the point where the marginal gain equals the penalty, while β = 0 has no stopping point and runs right into reward-hacking territory (an illustration of the failure mode's mechanism — the actual measured curves are in section 8)

The green line's stopping point is not a guess — it is the mathematics of equation 3.2: optimization stops exactly where the reward gained per nat equals β; walking further loses money. When β=0\beta = 0 that stopping condition doesn't exist — every nat of wandering that buys even a sliver of reward is "profit," so the model keeps running out of natural language for as long as the reward number still twitches upward.

5. Setting Up the Environment

The real scale of what we are miniaturizing — read before running

Production RLHF holds 4 models of which the largest is usually 7B or more, uses real human preference pairs numbering in the tens of thousands to millions, and splits generation (a rollout fleet) onto machines separate from training. This notebook uses Qwen3-0.6B in every slot, 100 preference pairs, and 64 math problems. What it demonstrates is the algorithm with every part present — not real RLHF. The results will prove the mechanism, not that the model got better for real use.

Open Colab and pick Runtime → Change runtime type → T4 GPU (the free tier is enough).

The series-wide warning worth re-reading every chapter

The Colab T4 is Turing architecture (SM 7.5), which does not support bfloat16 and does not support FlashAttention-2.

But Qwen3-0.6B's config.json declares torch_dtype: bfloat16. So torch_dtype="auto" is a trap — your code will crash or run bizarrely slowly without telling you why.

torch_dtype=torch.float16      # not bfloat16
attn_implementation="sdpa" # not flash_attention_2

And this chapter in particular carries one extra fp16 bomb, named ratio overflow — wait for section 7.

cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (counts emulation!)
is_bf16_supported() lies on a T4

Recent torch returns True on a T4 because it counts emulation as support — which is far slower than fp16. Gate on compute capability ≥ 8.0 (Ampere and up) instead. This was a real bug, caught only by running the notebook on Colab.

Why RLHF is expensive, in one picture

A horizontal bar chart comparing the VRAM of PPO with four fully loaded models against a LoRA setup where the policy and reference share base weightsA horizontal bar chart comparing the VRAM of PPO with four fully loaded models against a LoRA setup where the policy and reference share base weights

Figure 3.4The four models that must sit in VRAM at once during one PPO step, computed from Qwen3-0.6B's real parameter count (fp16, weights only) — LoRA lets the policy and reference share a single base, saving one entire model

The numbers in the figure are just the weights — before activations, the KV cache during generation, gradients, and optimizer state. And at 0.6B everything still looks tiny, but the ×4 multiplier goes nowhere as you scale: at 7B it is 56 GB before you've done anything at all.

The payoff from chapter 2 (round two)

Our policy is the base + the LoRA adapter from chapter 2, and πref\pi_{\text{ref}} is that same base with the adapter off — called inside policy.disable_adapter(). The reference model costs zero additional bytes of VRAM. Chapter 4 will use this same move again for DPO; it is the architectural reason this series chose LoRA from the start.

6. Preparing the Data

Stage A — preference pairs for the reward model

We use iapp/dpo_thai_tutorial (100 pairs, Apache-2.0) — a Thai preference dataset I built myself for this series and released for free reuse. Each row has prompt, chosen, rejected, hand-curated with an emphasis on politeness and natural-sounding Thai.

Split 80/20: train on 80 pairs, hold 20 pairs out — never touched during training. Stage A's passing bar is pairwise ranking accuracy on the 20 held-out pairs, with a Wilson 95% CI that does not straddle 0.5 — "significantly better than a coin flip" already proves the mechanism, because 20 pairs genuinely cannot make the CI any tighter.

Stage B — problems a rule can grade

For the PPO loop we use 64 math problems from VISAI-AI/gsm8k-thai (a Thai translation of GSM8K) and score them with a verifiable rule instead of the Stage A reward model:

import re

def rule_reward(response: str, gold: int) -> float:
"""+1.0 if the last integer in the answer is correct, +0.2 if the answer is actually in Thai"""
nums = re.findall(r"-?\d+", response.replace(",", ""))
correct = 1.0 if nums and int(nums[-1]) == gold else 0.0
thai = sum("\u0e01" <= ch <= "\u0e5b" for ch in response) # the Thai Unicode block, U+0E01..U+0E5B
thai_bonus = 0.2 if thai / max(len(response), 1) >= 0.5 else 0.0
return correct + thai_bonus
Why Stage B doesn't use the reward model from Stage A

In a real system, Stage B consumes Stage A's output directly — that is the definition of RLHF. But an RM trained on 100 pairs is far too weak to withstand PPO's pressure: it would get hacked within a few updates, and then we couldn't tell whether our PPO loop was wrong or the RM was merely weak — the experiment would prove nothing at all.

So the rule reward serves as a verifiable stand-in for the RM: when the reward climbs, we know for certain the loop is working. But it remains "imperfect" just like every RM — it measures only the final number and the fraction of Thai characters, not the readability of anything around them, and that is exactly the loophole the β=0\beta = 0 experiment in section 8 will poke at. (This idea of rule-verifiable rewards returns as the full-time protagonist in chapter 5.)

7. The Main Code

7.1 Stage A — training a real reward model (~6 minutes)

Turn a language model into a scoring machine: the LM head is replaced with a single linear layer returning one scalar.

import torch, torch.nn.functional as F
from transformers import AutoModelForSequenceClassification, AutoTokenizer
from peft import LoraConfig, get_peft_model

tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
rm = AutoModelForSequenceClassification.from_pretrained(
"Qwen/Qwen3-0.6B",
num_labels=1, # scalar head: one score per text
torch_dtype=torch.float16, # the T4 has no bf16
attn_implementation="sdpa",
).cuda()
rm.config.pad_token_id = tok.pad_token_id

rm = get_peft_model(rm, LoraConfig(
task_type="SEQ_CLS", r=8, lora_alpha=16,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
modules_to_save=["score"], # the score head starts random — must be trained in full
))
for p in rm.parameters():
if p.requires_grad:
p.data = p.data.float() # the fp16 lesson from chapter 1: train in fp32

def rm_loss(chosen, rejected):
s_w = rm(**chosen).logits.squeeze(-1) # r_φ(x, y_w)
s_l = rm(**rejected).logits.squeeze(-1) # r_φ(x, y_l)
return -F.logsigmoid(s_w - s_l).mean() # equation 3.1, literally

Train 3 epochs on 80 pairs, then measure pairwise accuracy on the 20 held-out pairs with a Wilson CI. If the CI clears 0.5 — you have just trained the first reward model of your life, on 80 rows of data.

7.2 Stage B — PPO written from scratch, ~120 lines (~10 minutes)

We will not use TRL's PPOTrainer, and this is a deliberate decision, not stubbornness: TRL has moved PPOTrainer into trl.experimental and announced plans to remove it in 0.29.0 — code taught against that library will stop running within a few months. A self-written PPO loop of about 120 lines will run for as long as PyTorch exists. And more important still: write it yourself and you will know what every line does, just like the 25-line DPO loss in chapter 4.

The pieces on the board: policy = base + LoRA adapter (from chapter 2, is_trainable=True), reference = the same base with the adapter off, and a value head — a two-layer MLP plugged into the final hidden state:

value_head = torch.nn.Sequential(
torch.nn.Linear(1024, 1024), torch.nn.Tanh(),
torch.nn.Linear(1024, 1),
).float().cuda() # ~1M parameters — tiny next to the other three

The rollout: sample answers 8 prompts at a time (max_new_tokens=200, do_sample=True), score with rule_reward, standardize the scores within the batch, and store the rollout-time log-probs, detached, immediately. Then enter this update loop — the 40 lines that are the heart of the whole chapter (the full loop is in the notebook):

def compute_gae(rewards, values, gamma=1.0, lam=0.95):
"""rewards: [T] for one response, values: [T+1] (last slot = 0 after the end)"""
adv, acc = torch.zeros_like(rewards), 0.0
for t in reversed(range(rewards.shape[0])):
delta = rewards[t] + gamma * values[t + 1] - values[t] # equation 3.4
acc = delta + gamma * lam * acc # Â_t = δ_t + γλ Â_{t+1}
adv[t] = acc
return adv

def ppo_update(rollout, eps=0.2, beta=0.05):
out = policy(rollout.ids, output_hidden_states=True) # one forward, two outputs
logp = gather_logprobs(out.logits, rollout.ids) # [B, T], carries gradient
with torch.no_grad(), policy.disable_adapter():
logp_ref = gather_logprobs(policy(rollout.ids).logits, rollout.ids)

# per-token reward = KL penalty at every position + task score at the last token (equation 3.2)
rew = -beta * (logp.detach() - logp_ref)
rew[:, -1] += rollout.scores_std # the standardized rule score

hidden = out.hidden_states[-1].detach().float() # detach = cut the gradient into the trunk
values = value_head(hidden).squeeze(-1)
adv = torch.stack([compute_gae(r, F.pad(v, (0, 1))) for r, v in zip(rew, values)])
adv = (adv - adv.mean()) / (adv.std() + 1e-8) # standardize the advantage once more

log_ratio = (logp - rollout.logp_old).clamp(-10, 10) # prevents fp16 overflow!
ratio = log_ratio.exp() # ρ_t (equation 3.3)
surr = torch.min(ratio * adv.detach(),
ratio.clamp(1 - eps, 1 + eps) * adv.detach())

returns = (adv + values).detach() # the value head's target
m = rollout.resp_mask # count response tokens only
loss = (-(surr * m).sum() + 0.5 * ((values - returns).pow(2) * m).sum()) / m.sum()
return loss # (the entropy term is in the notebook)

Settings: ϵ=0.2\epsilon = 0.2, β=0.05\beta = 0.05, γ=1.0\gamma = 1.0, λ=0.95\lambda = 0.95, 4 epochs per rollout on 64 prompts, adapter LR 1e-5, value head LR 1e-4. Roughly 10 minutes per run on a T4 (the notebook runs twice: β=0.05\beta = 0.05 and β=0\beta = 0).

Three lines that void the entire experiment if you miss them

1. .clamp(-10, 10) before .exp() — in fp16, exp(12) = 162,754, past the fp16 ceiling (65,504). The result is inf, which turns into NaN and spreads through the whole batch within a single step.

2. logp_old must be computed once at rollout time and stored detached — never recomputed inside the epoch loop. Recompute it and ρt=1\rho_t = 1 always, the clip never fires, and your PPO silently degrades into REINFORCE with no error whatsoever.

3. Standardize scores before use — equation 3.1 already told you the reward's scale is undefined. Yes, our rule reward lives between 0 and 1.2, but this habit has to stick for when you use a real RM whose scale can be anything it likes.

8. Results

The notebook tracks three curves simultaneously at every update and writes them to results.json:

  1. Mean reward per rollout — should climb (this is what we're buying)
  2. Per-token KL against πref\pi_{\text{ref}} — should grow and then saturate under the ceiling that β\beta sets (this is the price we pay)
  3. Mean answer length — the disease detector: length spiking or cratering abnormally is the first sign of a policy going strange
Never read the reward curve without the KL curve beside it — ever

A climbing reward means nothing at all if you don't know what the model paid in exchange. Reward up + KL saturating = learning under the leash. Reward up + KL climbing without limit = fleeing language itself, heading for a loophole in the judge. The same curve in a different context means precisely the opposite thing — this is the measured version of figure 3.3.

Catching reward hacking red-handed

The notebook's second run sets β=0\beta = 0 and touches absolutely nothing else — no leash. The pattern expected: reward climbs as fast or faster, but KL soars with no ceiling, and the answers start to degenerate — repetitive, oddly short, or collapsing into a fixed formula with a number stuffed at the end of the sentence. Because rule_reward sees only the final number and the fraction of Thai characters, everything it cannot see is free for the model to throw away.

Samples of degenerate answers from the β=0\beta = 0 run are printed by the notebook's final cell:

[This space is filled only from the notebook's real run — I will not invent degenerate
examples myself, because this whole series stands on the rule that no number or output
is ever made up. Run the notebook and the "hacking exhibits" cell will show 2-3 real
answers along with their KL.]
Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. Comparison

The notebook runs the same battery over three systems on held-out Thai math problems (TH-MATH):

ModelTH-MATH acc (95% CI)Final mean KLMean answer lengthTraining time
starting policy70.0% (52.1–83.3)0182 tokens
PPO, β = 0.0570.0% (52.1–83.3)0.011172 tokens5.6 min
PPO, β = 0 (ablation)~unchanged0.012159 tokens5.6 min
Measured on a Colab T4 — Stage A reward model: pairwise accuracy 80% (16/20, Wilson 95% CI 58.4–91.9), passing (CI excludes 0.5), loss 1.00 → 0.29. Every number from results.json.
The real result did not match the prediction — and we say so

The article predicted β = 0 would make KL soar and the language collapse (reward hacking). On a real demo-scale run (FAST_MODE: 32 prompts × 16 updates) it did not: β=0's KL (0.012) is barely different from β=0.05's (0.011), reward stayed flat in both, and McNemar on TH-MATH gives p = 1.0 (no change).

The reason is that 16 updates on a 0.6B model is far too few to induce reward hacking, which needs hundreds of updates. What this chapter genuinely proves is (1) the reward model trains — 80%, and (2) the ~120-line PPO loop runs end to end with its math matching hand calculation (section 6). Seeing reward hacking clearly requires turning off FAST_MODE and running long on a larger GPU — so we report what was measured, not what we wanted.

Note: the starting policy is a fresh LoRA (fallback), not chapter 2's adapter, which is not yet pushed to the Hub; once pushed it will start from the real SFT model as intended.

10. Summary

  • RLHF = a two-step detour to optimize what cannot be differentiated: train a judge (rϕr_\phi), then use RL to run toward the judge's score
  • Bradley–Terry sees only differences — a reward's absolute scale is undefined, so always standardize before use
  • The master equation max E[r]βDKL\max\ \mathbb{E}[r] - \beta\,\mathbb{D}_{\text{KL}} is the one equation to memorize — chapters 4 and 5 are this same equation solved by other means
  • KL is not a regularizer — it is the system's only stopping condition; the moment you remove it, Goodhart goes to work
  • min + clip = pessimism by design: gains capped, losses uncapped — the trust region that makes old rollouts reusable
  • GAE is the bias–variance dial, and the VψV_\psi it leans on is the fourth model — the one GRPO deletes in chapter 5
  • PPO is expensive by structure, not by badly written code: 4 models + around ten hyperparameters is the sticker price
Limitations of this experiment

64 prompts and a rule-based reward are a demonstration of the algorithm, not RLHF. Our rule reward is only a stand-in for the Stage A reward model — real RLHF uses RMs at the 7B+ level trained on tens of thousands to millions of human preference pairs, and needs a separate rollout fleet, because generation consumes several times more compute than the updates.

What this experiment genuinely proves is two things: the hand-written PPO loop works correctly (reward climbs under the KL leash), and the mechanism of reward hacking is real (remove β and it is measured, not merely narrated). Don't cite these results as an aligned Thai model — what you gain is an understanding of how the entire machine turns, which is exactly what the next two chapters require.

Next chapter: DPO — DPO deletes both the reward model and the RL loop with pure algebra, starting from master equation 3.2, the one you just memorized.

References

  1. Schulman et al. (2017). Proximal Policy Optimization Algorithms — the original PPO paper: the clipped surrogate in section 3
  2. Schulman et al. (2015). High-Dimensional Continuous Control Using Generalized Advantage Estimation — GAE: the advantage estimator PPO uses
  3. Christiano et al. (2017). Deep reinforcement learning from human preferences — the paper that started RL from human preferences
  4. Stiennon et al. (2020). Learning to summarize from human feedback — the first convincingly working RLHF, on summarization
  5. Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT: the origin of the whole SFT -> RM -> PPO pipeline
  6. Bai et al. (2022). Training a Helpful and Harmless Assistant with Reinforcement Learning from Human Feedback — the helpful/harmless dataset and its lessons about KL
  7. Zheng et al. (2023). Secrets of RLHF in Large Language Models Part I: PPO — the PPO implementation details other papers omit
  8. Bradley & Terry (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons — the Bradley-Terry model every reward model rests on

The writing, code and notebooks in this series are licensed under CC BY-NC-SA 4.0 — reuse and adapt them freely with attribution, for non-commercial purposes, and share your adaptations under the same terms. Third-party models and datasets referenced here keep their own licences.