arena-in-12-weeks
glossary about

Site feedback

What should we improve?

Your email app will open with this message and the current page URL.

week 08 / 12

PPO and RLHF

Optimize policies from rollouts, then apply the same update to generated tokens.

companion notebook · ARENA 2.3 and 2.4

new terms this week · 14

Reinforcement learning

PPO
Proximal Policy Optimization: a method that collects a rollout, computes fixed learning targets, then clips the incentive for large favorable sampled-action probability changes.
Rollout
A frozen batch collected under the current policy: states, sampled actions, rewards, termination and truncation flags, old-policy log probabilities, and critic values.
CartPole
A control environment where a cart keeps a pole balanced by pushing left or right. CartPole-v1 observes position, velocity, pole angle, and angular velocity.
Actor
The policy network that produces the action distribution.
Critic
The value network that estimates expected future return from the current state.
Advantage
How much better or worse an action is than the critic's expected return from the same state.
GAE
Generalized advantage estimation: a method that carries critic prediction errors backward through a rollout to estimate advantages.
Clipped objective
PPO's sampled-action surrogate that clips the current-to-old probability ratio, removing extra incentive after a large favorable change.
Entropy bonus
A term in the PPO objective that rewards a broader action distribution.
RLHF
Reinforcement Learning from Human Feedback: a pipeline that trains a model on demonstrations, learns a reward model from ranked responses, then optimizes the policy against that reward.
Reference model
A frozen copy of the starting model whose token distribution anchors the current policy.
KL penalty
A term that penalizes the current policy for moving away from the reference model's token distribution.
Value head
A small network that reads the residual stream and produces one scalar value per position.
Reward hacking
Maximizing a scorer in a way that defeats the intended purpose, such as producing punctuation instead of useful text.

Week 8 notebooks

Week 8 notebooks

  • PPO: Read Section 0. Complete Sections 1 and 2. In Section 3, complete the five probes and the first unshaped CartPole-v1 training run. Stop before the Reward Shaping subsection. Do not start Atari (Section 4) or MuJoCo (Section 5). Open the exercise notebook and use the solutions notebook to check your work.
  • RLHF: Complete Section 1 through RLHFTrainer and the period-count reward run. Stop before the complex sentiment reward. Do not start LoRA (Section 2) or GRPO (Section 3). Open the exercise notebook and use the solutions notebook to check your work.
  • Submit: Passing tests, evidence from all five PPO probes, CartPole learning curves with diagnostics, and the period-count run.
  • Optional: Compare matched period-count runs with KL enabled and disabled. Hold the initial checkpoint, prompts, seed, sampling settings, gen_len, batch and update budget, and logging definitions fixed.

Slides: PPO and RLHF

Tabular methods store one value per state-action pair: enough for a handful of grid cells and a handful of moves. A language model's state is every possible token prefix, and its next action is a token from a large vocabulary: too many pairs to store. RLHF scores the finished text and updates the model toward higher-scoring completions. PPO is the update that keeps those steps from rewriting the starting model into fluent nonsense.

From DQN to policy gradients

A policy maps a state to an action or a distribution over actions. Following it produces a rollout: the states, actions, and rewards along the way. DQN estimates a Q-value for each state-action pair with a neural network and picks the action with the highest score. PPO learns the policy itself.

Value learning and policy learning

DQNPPO
Learns An action-value function, Q(s,a)A stochastic policy, π(as)
Chooses actions Takes an argmax over estimated Q-valuesSamples from the learned action distribution
Explores Adds a rule such as ε-greedy explorationRepresents exploration in the stochastic policy
Fits Naturally fits a finite set of discrete actionsSupports discrete or continuous action distributions

DQN stores past transitions in a replay buffer and later samples them for training. Those older transitions remain useful because the Q-learning target does not require the current policy (off-policy). PPO trains on a recent rollout: it reuses that batch for a few updates, then collects again, because older experience stops matching the policy being updated.

PPO in practice: CartPole

PPO's policy is easiest to see on a small control task. CartPole-v1 balances a pole upright on a cart that slides left or right. It is fast to simulate, and every state, action, and reward stays visible. Each observation contains the cart position, cart velocity, pole angle, and pole angular velocity. The policy network maps those four numbers to two logits, which define a categorical distribution over a left push and a right push.

A black rectangular cart sits on a thin track with a tan pole standing on a small blue pivot. The cart slides left and right while the pole sways.
The CartPole policy samples left or right at every step. The environment returns +1 per step, including the terminating step. Physical thresholds end the episode; the CartPole-v1 time limit truncates it at 500 steps.
Sample one CartPole action

This actor is untrained. CartPole's +1 reward only says that it survived one step; it does not mean the sampled action was good.

import gymnasium as gym
import torch
from torch import nn
from torch.distributions import Categorical

torch.manual_seed(8)
sample_generator = torch.Generator().manual_seed(8)
actor = nn.Sequential(nn.Linear(4, 16), nn.Tanh(), nn.Linear(16, 2))
env = gym.make("CartPole-v1")

observation, reset_info = env.reset(seed=8)
observation_tensor = torch.tensor(observation, dtype=torch.float32).unsqueeze(0)
with torch.no_grad():
    logits = actor(observation_tensor)
    policy = Categorical(logits=logits)
    probabilities = policy.probs
    action = torch.multinomial(probabilities[0], 1, generator=sample_generator).squeeze(0)
    log_probability = policy.log_prob(action.unsqueeze(0)).squeeze(0)

next_observation, reward, terminated, truncated, step_info = env.step(action.item())
env.close()

assert observation.shape == (4,)
assert observation_tensor.shape == (1, 4)
assert logits.shape == probabilities.shape == (1, 2)
assert torch.all(probabilities >= 0) and torch.allclose(probabilities.sum(-1), torch.ones(1))
assert action.shape == torch.Size([]) and action.item() in (0, 1)
assert log_probability.shape == torch.Size([]) and torch.isfinite(log_probability)
assert next_observation.shape == (4,)
assert isinstance(reward, float) and reward == 1.0
assert isinstance(terminated, bool) and isinstance(truncated, bool)

print("observation:", observation.round(4).tolist())
print("action probabilities [left, right]:", probabilities[0].numpy().round(4).tolist())
print("sampled action:", "left" if action.item() == 0 else "right")
print("stored log probability:", round(log_probability.item(), 4))
print("reward:", reward)
print("terminated:", terminated, "truncated:", truncated)

A language model makes the same kind of stochastic choice over a larger action set. The CartPole observation becomes the prompt and generated prefix, the two action logits become vocabulary logits, and the sampled left or right push becomes a sampled token.

The cart fails when it leaves the track or the pole tilts more than 12 degrees. That episode is over: the pole fell, and there is no more reward. Hitting the 500-step timer is different. Recording stops, but the pole may still be up. A policy that lasts that long is succeeding. If PPO treated the timer like a fall, those best runs would look like failures, and the update would punish the actions that kept the pole balanced.

Actor, critic, and advantage

A policy is only useful once something tells it which actions to make more likely. The reward comes from the environment, and an environment may not be differentiable, so there may be no gradient to send through it. The log-probability trick sidesteps that: raise the log-probability of actions that showed up in high-return trajectories, and lower it for actions in low-return ones. The goal is to maximize expected return along a trajectory τ:

J(θ)=Eτπθ[R(τ)]

Treat expected return as height over all possible policy weights. The current policy is one point on that landscape. Its gradient points in the steepest local uphill direction.

A dark blue point marks the current policy on the slope of a wireframe hill. A bold blue arrow curves uphill toward higher expected return, with lighter points showing earlier policy updates behind it.
Gradient ascent updates the weights in the direction θJ(θ), scaled by the learning rate α: θθ+αθJ(θ). Each step follows the local direction that most increases expected return.

The policy gradient writes that uphill direction in terms of actions from sampled trajectories:

θJ(θ)=E[tθlogπθ(atst)R(τ)]

That estimate is noisy. A CartPole episode can succeed or fail for reasons that have little to do with any single action, so weighting every action by the same overall return mixes "was this action good" with "did this episode go well." The same return can be impressive from one state and unremarkable from another. Compare the action to what was expected from that state.

PPO trains two networks. The actor is the policy: it picks actions. The critic estimates V(s), how much return to expect from a state, and judges the states the actor lands in. On CartPole these can be small, separate networks. Later, on a language model, they share almost the entire network.

An environment sends observations and rewards to an agent that contains an actor network and a critic network. The actor samples actions back to the environment.
The agent contains an actor network and a critic network. On CartPole they can be small separate networks.

Subtracting the critic's judgment from the return G gives the advantage:

At=GtV(st)

A positive advantage means the action did better than the critic expected, so the update makes it more likely. A negative advantage means it did worse, so the update makes it less likely. Zero advantage means the return matched the prediction: the action was unsurprising, even if the return itself was large. Subtracting a baseline like this does not change the expected gradient, as long as the baseline does not depend on the action taken. It only cuts noise: it compares an action to what was expected from that specific state. In practice, PPO estimates the advantage from a finite rollout rather than calculating the true return and V.

A number line centered on the critic's expectation V(s). Left of center, a return worse than expected gives negative advantage and decreases the sampled action's probability. Right of center, a return better than expected gives positive advantage and increases that probability. Below, the policy-gradient sum is annotated: the log-probability gradient is the direction that makes this action more likely, and the advantage says how surprisingly good or bad it was.
Advantage is surprise relative to V(s), not raw return. The same return can be a pleasant surprise from one state and a disappointment from another.

The policy gradient now weights each sampled action by that surprise:

θJ(θ)tθlogπθ(atst)A^t

The log-probability gradient is the direction that makes this action more likely. The advantage scales it: how surprisingly good or bad the action was.

PPO rollouts

PPO collects each rollout with a frozen snapshot of the policy, the old policy. Each stored step keeps the state, the sampled action, the reward, termination and truncation flags, the old-policy log probability, and the critic value. Advantages and return targets are computed once from that batch and stay fixed while the current policy updates.

Six fields stored in a PPO rollout: states from the environment so the current networks can be re-evaluated on the same inputs; sampled actions from the old policy, whose probabilities the update will change; rewards from the environment, used to build advantages; termination and truncation flags, which stop bootstrapping on a true episode end without treating a time limit as a failure; old-policy log probabilities, the fixed denominator of the PPO ratio; and critic values from collection time, the baseline for advantage and the return target.
A rollout is the frozen batch PPO learns from, not a replay buffer of leftover DQN transitions. Advantages and return targets are computed from these fields after collection, then held fixed.
Rollout phase: the environment stores experience, memory is sampled into an agent with an actor network and a critic network, and the actor sends actions sampled from the current policy back to the environment. Learning phase: entropy bonus, clipped surrogate objective, and value function loss combine into a total objective applied as a gradient ascent step.
Rollout phase: sample from the current policy, generate experience, store it. Learning phase: combine entropy bonus, clipped objective, and value loss into one update.

The sampled actions still came from the old policy. Their stored probabilities let PPO measure how far the current policy has moved on those actions.

Generalized advantage estimation

Rewards can arrive long after the action that helped earn them. The critic also gives a faster, one-step judgment: the TD residual. It compares one transition to the critic's expectation, using only the next reward instead of the rest of the episode.

δt=rt+1+γ(1dt+1)V(st+1)V(st)

Here dt+1 is 1 when the pole fell or the cart left the track, and 0 otherwise. A fall zeros the next value. A 500-step cutoff does not: the last observation can still have value.

TD residuals are quick and less noisy, but only as accurate as the critic. Full returns use real rewards to the end of the episode: less biased, noisier. Generalized advantage estimation (GAE) blends the two. It sends later critic surprises back to earlier actions, discounted by γ (how much future reward matters) and λ (how far ahead to trust the critic):

A^t=δt+γλ(1dt+1)A^t+1

A two-tone bar from lambda equals 0, one-step temporal difference, low variance but biased, to lambda equals 1, Monte-Carlo, unbiased but noisy. A pointer near the right end marks lambda approximately 0.95 as the best of both.
GAE sends later critic surprises back to earlier actions. λ=0 uses one-step residuals; larger λ carries observed outcomes farther back.

λ=0 uses only the one-step residual. λ=1 moves toward the full return. Values near 0.95 keep most of the return and smooth most of the noise. Credit never crosses from one episode into the next.

GAE stops at episode boundaries

The middle transition terminates. Its placeholder next value is masked out, and credit cannot cross into the reset episode.

import numpy as np

rewards = np.array([0.0, 1.0, 0.0])
values = np.array([0.2, 0.4, 0.3])
next_values = np.array([0.4, 123.0, 0.5])
next_terminated = np.array([0.0, 1.0, 0.0])
gamma, lam = 0.9, 0.8

def gae(rewards, values, next_values, next_terminated, gamma, lam):
    advantages = np.zeros_like(rewards)
    later = 0.0
    for t in reversed(range(len(rewards))):
        continues = 1.0 - next_terminated[t]
        delta = rewards[t] + gamma * continues * next_values[t] - values[t]
        later = delta + gamma * lam * continues * later
        advantages[t] = later
    return advantages

advantages = gae(rewards, values, next_values, next_terminated, gamma, lam)
returns = advantages + values
changed_terminal_value = next_values.copy()
changed_terminal_value[1] = -999.0

assert np.allclose(advantages, [0.592, 0.6, 0.15])
assert np.allclose(returns, [0.792, 1.0, 0.45])
assert np.allclose(
    advantages,
    gae(rewards, values, changed_terminal_value, next_terminated, gamma, lam),
)
print("advantages:", advantages.round(3).tolist())
print("returns:", returns.round(3).tolist())

The first advantage includes part of the second step's residual because both belong to the same episode. The terminal transition masks its placeholder next value and stops the later reset episode from affecting it.

PPO clipping

The advantage says which sampled actions to make more or less likely. It does not cap how far the update can go. PPO reuses one rollout for several updates, so the policy doing the updating is no longer the policy that collected the data. The probability ratio compares them for each sampled action:

rt(θ)=πθ(atst)πold(atst)

A ratio of 1 means the current policy assigns that action the same probability as the old one. Above 1, it is now more likely; below 1, less likely. Multiply by the advantage and a positive advantage rewards raising the ratio with no limit: one noisy batch can swing the policy a long way. The clipped objective takes the smaller of that product and a version with the ratio clamped near 1:

Lclip=Et[min(rtA^t,clip(rt,1ε,1+ε)A^t)]

For a positive advantage, the objective stops improving once the ratio passes 1+ε. For a negative advantage, it stops improving once the ratio drops below 1ε. The policy can still change. A batch just stops getting extra credit for changing it a lot.

Two plots show the PPO clipped objective against the current-to-old policy ratio. For positive advantage it rises then plateaus at the upper clipping boundary. For negative advantage it plateaus at the lower clipping boundary then falls.
Clipping removes extra incentive after a large favorable probability change. A change in the harmful direction keeps a corrective slope. Adapted from Schulman et al., Proximal Policy Optimization Algorithms.
The clipped surrogate depends on advantage sign

Sweep the same probability ratios with one positive and one negative advantage.

import numpy as np

ratios = np.array([0.5, 0.8, 1.0, 1.2, 1.5])
eps = 0.2

def clipped_objective(ratios, advantage):
    original = ratios * advantage
    clipped = np.clip(ratios, 1 - eps, 1 + eps) * advantage
    return np.minimum(original, clipped)

positive = clipped_objective(ratios, 1.0)
negative = clipped_objective(ratios, -1.0)
assert np.allclose(positive, [0.5, 0.8, 1.0, 1.2, 1.2])
assert np.allclose(negative, [-0.8, -0.8, -1.0, -1.2, -1.5])
print("positive advantage:", positive.tolist())
print("negative advantage:", negative.tolist())

Approximate KL and clip fraction measure how far the policy moved on this batch.

PPO objective and learning phase

Two more terms complete the objective. A squared value loss trains the critic to predict return. An entropy bonus rewards keeping the action distribution spread out, so the policy does not collapse to one action too early.

Two categorical policies over five actions. The peaked policy puts almost all mass on one action and repeats it, a low-entropy distribution. The spread policy keeps several alternatives available, a high-entropy distribution that the entropy bonus rewards.
Entropy measures how spread the current action distribution is. The bonus pays for the right-hand policy; it slows collapse, and it does not forbid a peaked policy later.

LclipcvLvalue+cHH(πθ)

During each learning phase the trainer shuffles the rollout into minibatches and recomputes current log probabilities, values, and entropy. Stored old log probabilities, advantages, and returns stay fixed.

Run PPO updates on a fixed rollout

The isolated synthetic observations make each probability change testable. This demonstrates update mechanics rather than CartPole task learning.

import torch
from torch import nn
from torch.distributions import Categorical

torch.manual_seed(8)

class TinyAgent(nn.Module):
    def __init__(self):
        super().__init__()
        # With one-hot observations and no biases, each state updates its own weights.
        self.actor = nn.Linear(4, 2, bias=False)
        self.critic = nn.Linear(4, 1, bias=False)
        nn.init.zeros_(self.actor.weight)
        nn.init.zeros_(self.critic.weight)

    def forward(self, observations):
        return Categorical(logits=self.actor(observations)), self.critic(observations).squeeze(-1)

agent = TinyAgent()
observations = torch.eye(4)
actions = torch.tensor([0, 1, 0, 1])
advantages = torch.tensor([1.0, 1.0, -1.0, -1.0])

with torch.no_grad():
    old_distribution, old_values = agent(observations)
    old_log_probs = old_distribution.log_prob(actions)
    returns = advantages + old_values
    before_probabilities = old_distribution.probs[range(4), actions]
    before_mse = ((old_values - returns) ** 2).mean()

# These tensors are rollout records. Optimization must never rewrite them.
fixed_rollout = tuple(t.clone() for t in (old_log_probs, old_values, advantages, returns))
actor_before = agent.actor.weight.detach().clone()
critic_before = agent.critic.weight.detach().clone()
optimizer = torch.optim.SGD(agent.parameters(), lr=0.1)
epsilon = 0.2

for _ in range(6):
    distribution, values = agent(observations)
    ratios = (distribution.log_prob(actions) - old_log_probs).exp()
    surrogate = torch.minimum(
        ratios * advantages,
        ratios.clamp(1 - epsilon, 1 + epsilon) * advantages,
    ).mean()
    value_loss = ((values - returns) ** 2).mean()
    loss = -surrogate + 0.5 * value_loss
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    assert all(torch.equal(current, saved) for current, saved in zip(
        (old_log_probs, old_values, advantages, returns), fixed_rollout
    ))

with torch.no_grad():
    final_distribution, final_values = agent(observations)
    after_probabilities = final_distribution.probs[range(4), actions]
    after_mse = ((final_values - returns) ** 2).mean()
actor_change = torch.linalg.vector_norm(agent.actor.weight - actor_before)
critic_change = torch.linalg.vector_norm(agent.critic.weight - critic_before)

assert torch.all(after_probabilities[:2] > before_probabilities[:2])
assert torch.all(after_probabilities[2:] < before_probabilities[2:])
assert actor_change > 0 and critic_change > 0
assert after_mse < before_mse
assert all(torch.equal(current, saved) for current, saved in zip(
    (old_log_probs, old_values, advantages, returns), fixed_rollout
))

print("sampled-action probabilities before:", before_probabilities.tolist())
print("sampled-action probabilities after: ", after_probabilities.tolist())
print("actor parameter-change norm:", round(actor_change.item(), 6))
print("critic parameter-change norm:", round(critic_change.item(), 6))
print("critic MSE:", round(before_mse.item(), 6), "->", round(after_mse.item(), 6))
print("fixed rollout unchanged across epochs: True")

Language generation as reinforcement learning

Autoregressive generation defines an RL process. The language model reads the prompt and generated prefix, produces a distribution over the vocabulary, samples the next token, and appends it to form the next state. The completed sequence receives a score.

A language model reads a prompt and generated prefix, samples the next token from its policy, appends that token to form the next state, and repeats until the completion receives a reward.
Each generated token is an action chosen from the prefix immediately before it. Prompt tokens provide context and do not count as policy actions.

Control task to language model

Language-generation equivalent
Agent An autoregressive language model
State The prompt and generated prefix so far
Action The next sampled token
Episode One generated completion
Reward A score for the completed sequence

Two things change from CartPole. CartPole scores every step; a language model is scored once, after the whole completion. Nothing so far stops the policy from wandering far from where it started. For text, that is dangerous.

RLHF pipeline

A full RLHF pipeline first trains a model on demonstrations, then learns a reward model from ranked responses, then optimizes the language-model policy against that reward. The third step is the policy update. A fixed scoring function stands in for the learned reward model.

OpenAI's three-step InstructGPT method: collect demonstration data and train a supervised policy, collect comparison data and train a reward model, then optimize a policy against the reward model with PPO.
Full RLHF trains a supervised policy and a reward model before policy optimization. From Ouyang et al., Training language models to follow instructions with human feedback (2022).

Give the model a reward that counts periods, and it learns "U.S.", "Python 2.7.12", and short choppy sentences until the text stops reading like language. That is reward hacking: the model does what the reward measures, not what anyone wanted. A frozen reference model is the leash back to the starting point.

Rollout, current, and reference policies

RLHF adds that leash: a frozen copy of the starting model. A KL penalty measures how far the trained distribution has moved from that reference and subtracts it from the objective. The policy can still chase reward; each step away from the reference costs something.

Language-model PPO uses three policy roles:

  • The old rollout policy πold generated the current batch. Its stored sampled-token log probabilities form the denominator of each PPO ratio and refresh with every rollout.
  • The current policy πθ receives gradients during minibatch updates.
  • The reference policy πref is that frozen starting model. Its full next-token distribution anchors the current policy across the run.
The old rollout policy and current policy provide the denominator and numerator of the PPO sampled-token ratio. The current and frozen reference policies provide the two full token distributions used for the KL penalty.
The old and reference policies stay fixed on different timescales and support different comparisons. Only the current policy receives gradients.

Stored old log probabilities measure how far this sampled token has moved since collection. The reference distribution measures how far the whole current distribution has moved from the starting model.

Transformer actor and critic

The critic needs a value from the same network that generates text. A value head reads the final residual stream and outputs one number per position. The policy still produces vocabulary logits; the two heads share almost the entire transformer. The head reads ln_final.hook_normalized, then applies a linear layer from dmodel to 4dmodel features, ReLU, and a linear layer from 4dmodel features to one scalar.

A residual-stream diagram from tokens through embed, attention heads, MLP, final residual, and layernorm to unembed and logits. A value head branches after layernorm: linear, ReLU, linear, then a value estimate.
The language-model and value heads read the final normalized residual at every position. Learning selects the positions immediately before generated tokens.
Wire GPT-2 logits and values to generated tokens

The frozen GPT-2 actor is pretrained, while the value head is randomly initialized. Its values demonstrate the wiring rather than learned quality.

import torch
from torch import nn
from transformer_lens import HookedTransformer

torch.manual_seed(8)
generator = torch.Generator(device="cpu").manual_seed(8)
model = HookedTransformer.from_pretrained("gpt2-small", device="cpu")
model.eval()
for parameter in model.parameters():
    parameter.requires_grad_(False)

value_head = nn.Sequential(
    nn.Linear(model.cfg.d_model, 4 * model.cfg.d_model),
    nn.ReLU(),
    nn.Linear(4 * model.cfg.d_model, 1),
).eval()

prompt = "Learning from feedback"
tokens = model.to_tokens(prompt, prepend_bos=True)
prefix_len = tokens.shape[1]
sampled_probabilities = []
with torch.no_grad():
    for _ in range(3):
        next_token_probabilities = model(tokens)[:, -1].softmax(dim=-1)
        next_token = torch.multinomial(next_token_probabilities, 1, generator=generator)
        sampled_probabilities.append(next_token_probabilities.gather(1, next_token).item())
        tokens = torch.cat([tokens, next_token], dim=1)

    _, cache = model.run_with_cache(tokens)
    hidden = cache["ln_final.hook_normalized"]
    token_logits = model.unembed(hidden)
    values = value_head(hidden).squeeze(-1)
    prediction_positions = torch.arange(prefix_len - 1, tokens.shape[1] - 1)
    generated_tokens = tokens[:, prefix_len:]
    generated_logits = token_logits[:, prediction_positions]
    generated_values = values[:, prediction_positions]
    replayed_probabilities = generated_logits.softmax(dim=-1).gather(
        -1, generated_tokens.unsqueeze(-1)
    ).squeeze(-1)

assert hidden.shape == (1, tokens.shape[1], model.cfg.d_model)
assert token_logits.shape == (1, tokens.shape[1], model.cfg.d_vocab)
assert values.shape == (1, tokens.shape[1])
assert generated_logits.shape == (1, 3, model.cfg.d_vocab)
assert generated_values.shape == replayed_probabilities.shape == (1, 3)
assert torch.isfinite(generated_logits).all() and torch.isfinite(generated_values).all()
assert torch.allclose(
    replayed_probabilities[0], torch.tensor(sampled_probabilities), atol=1e-6, rtol=1e-5
)

print("sampled completion:", repr(model.to_string(generated_tokens[0])))
print("hidden shape:", tuple(hidden.shape))
print("generated logits shape:", tuple(generated_logits.shape))
print("generated values shape:", tuple(generated_values.shape))
for token, probability, value in zip(
    model.to_str_tokens(generated_tokens[0]), replayed_probabilities[0], generated_values[0]
):
    print(f"token={token!r} probability={probability.item():.6f} value={value.item():.6f}")

The one-position shift matters. A residual at position j1 predicts the action token at position j. The final generated token's own residual would predict another token, so it does not score the action that produced it.

Align generated actions with prediction positions

Three generated tokens use logits and values from the three preceding positions.

import numpy as np

tokens = ["<bos>", "Write", "Hi", ".", "!"]
prefix_len = 2
generated_actions = tokens[prefix_len:]
prediction_positions = list(range(prefix_len - 1, len(tokens) - 1))
old_values = np.array([0.2, 0.5, 0.4])
terminal_reward = 2.0

action_values = np.append(old_values[1:], terminal_reward)
advantages = action_values - old_values
pairs = [(tokens[position], action) for position, action in zip(prediction_positions, generated_actions)]

assert prediction_positions == [1, 2, 3]
assert generated_actions == ["Hi", ".", "!"]
assert pairs == [("Write", "Hi"), ("Hi", "."), (".", "!")]
assert np.allclose(action_values, [0.5, 0.4, 2.0])
assert np.allclose(advantages, [0.3, -0.1, 1.6])
print("prediction positions:", prediction_positions)
print("Q:", action_values.tolist())
print("advantages:", advantages.round(3).tolist())

Intermediate generated positions use the next old value as the action-value estimate. The final token uses the sequence reward. This one-step construction differs from running CartPole GAE backward through a rollout.

RLHF objective

Each generated token carries fixed rollout data: its old log probability, advantage, and return target. During learning, the current model recomputes that token's probability, value, and entropy, while the reference model supplies a frozen comparison distribution.

The PPO ratio compares the current and old probabilities of the sampled token. The reference penalty compares the full current and reference distributions at each generated-token prediction position:

DKL(πθπref)=xπθ(xs)logπθ(xs)πref(xs)

PPO ratio and reference KL use different comparisons

The ratio uses one sampled token. Forward KL sums over the current and reference distributions.

import numpy as np

old_sampled_probability = 0.4
current_sampled_probability = 0.5
current_distribution = np.array([0.5, 0.5])
reference_distribution = np.array([0.8, 0.2])

ppo_ratio = current_sampled_probability / old_sampled_probability
forward_kl = np.sum(
    current_distribution * np.log(current_distribution / reference_distribution)
)

assert np.isclose(ppo_ratio, 1.25)
assert np.isclose(forward_kl, 0.22314355)
print(f"PPO ratio: {ppo_ratio:.2f}")
print(f"forward KL: {forward_kl:.6f}")

The complete language-model objective adds the reference penalty to the actor-critic objective:

JRLHF=LclipcvLvalue+cHHβDKL(πθπref)

Hugging Face RLHF diagram: a prompt feeds an initial language model and a tuned RL policy. The tuned completion is scored by a reward model, a KL penalty measures shift from the base model, and a PPO update feeds back into the tuned policy.
The tuned policy is scored by the reward model and kept close to the base model with a KL penalty. Only the tuned policy is updated. From Hugging Face, Illustrating Reinforcement Learning from Human Feedback.

Only generated-token positions enter the policy, value, entropy, and KL terms. Old log probabilities, advantages, and return targets stay fixed during each update.

A period-count reward

The fixed reward counts period characters in each decoded completion. Without a KL penalty the model chases that count into abbreviations, decimals, and fragments. With the penalty it can raise the score while still reading like the starting model. The generated text belongs beside the raw reward and the reference KL: the number going up is only half the story.

Further reading

this week's practice

core

  • Complete 2.3 through the probes and CartPole
  • Complete 2.4 Section 1 through RLHFTrainer and the period-count smoke test
  • Submit passing tests and PPO learning curves with diagnostics

stretch

  • Run a matched period-count comparison if your runtime permits