PPO for Language Models: Adapting RL to Text Generation

Michael BrenndoerferDecember 28, 202564 min read

Part of Language AI Handbook

Explains how PPO applies to language models. Topics include policy mapping, token action spaces, KL divergence penalties, and advantage estimation for RLHF.

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

PPO for Language Models

In the previous chapter, we explored the PPO algorithm as a general-purpose policy gradient method with clipped objectives and value function estimation. Now we face a important question: how do we apply these ideas to language models? The translation is difficult because traditional reinforcement learning and text generation have basic differences. Language models were designed as next-token predictors, not as agents acting in environments. They were trained to model the statistical patterns of human language, not to maximize cumulative rewards over time. Yet with some careful reframing, we can view text generation through the lens of sequential decision-making and apply PPO to steer models toward human-preferred outputs.

To appreciate why this translation matters, think about what we want from a language model assistant. It should answer accurately and help the user without causing harm. Supervised fine-tuning on human-written demonstrations can get us partway there, but it has a basic limitation: it trains the model to imitate what humans wrote rather than directly optimizing how people evaluate its responses. Reinforcement learning from human feedback (RLHF) provides a framework for that optimization, and PPO is the optimization engine that makes it work.

The central challenge is bridging two very different worlds. In classical RL, an agent acts in a well-defined environment with clear states, actions, and transitions. In language generation, the "environment" is the open-ended space of human communication. There are no physics, no game rules, and no terminal conditions beyond the end-of-sequence token. The reward signal comes not from an environment simulator but from a separate neural network that has learned to predict human preferences. Everything must be carefully adapted to handle the peculiarities of text: the enormous vocabulary, the sequential nature of generation, the delayed and sparse feedback, and the ever-present risk of the model drifting into incoherent or harmful behaviors.

Think of the adaptation problem as similar to teaching a chess-playing algorithm to write poetry. The chess algorithm knows how to optimize sequences of decisions to maximize a score, and poetry writing also involves sequences of decisions toward a goal. But the action space, state representation, reward structure, and constraints are completely different. Translating the optimization machinery requires both conceptual insight about the parallels and careful engineering to handle the practical differences.

This chapter connects reinforcement learning to the structure of language generation step by step. We will see how an LLM naturally is a stochastic policy, why the vocabulary forms a massive discrete action space, and how rewards propagate through generated sequences. Most importantly, we will understand why constraining the policy to stay close to its original behavior is needed for stable training. Each of these concepts builds on the last, forming a complete picture of how PPO turns language model behavior.

Historical Context

The application of PPO to language model alignment was popularized by the InstructGPT paper from OpenAI (Ouyang et al., 2022), which demonstrated that RLHF training could substantially improve model helpfulness and safety without sacrificing capability. Before InstructGPT, earlier RLHF work by Ziegler et al. (2019) had shown the feasibility of the approach on smaller models and simpler tasks like summarization. The key algorithmic insight in these systems was not PPO itself, which had been developed by Schulman et al. in 2017 for robotic control, but rather the adaptation of PPO's trust region mechanism as a natural defense against the reward hacking that plagues unconstrained RL optimization of neural networks. The InstructGPT results surprised many researchers: a 1.3B parameter model trained with RLHF was preferred by human raters over a 175B model trained with supervised fine-tuning alone. This shows that alignment quality can matter more than raw scale.

The Language Model as a Policy

In reinforcement learning, a policy maps states to action probabilities. Given a state ss, the policy π(a∣s)\pi(a|s) tells us the probability of taking action aa. This definition describes decision-making: the agent observes its state, checks its policy, and chooses an action. Language models do the same thing: given a context, the model outputs a probability distribution over the next token. The parallel is exact, and recognizing it is the conceptual foundation for everything that follows.

Think of the language model's forward pass as the policy evaluation step in RL. Every time you call the model with a sequence of tokens, it is computing the policy, reading the current state and determining how to act. The transformer layers process the input, build rich contextual representations through self-attention, and project the final hidden state to a distribution over vocabulary tokens. This distribution is the policy output. Sampling from it is the action selection step. Appending the sampled token to the context creates the next state, and the cycle repeats until the model generates a stop token.

To see this clearly, consider what happens when you type a prompt. The model processes your input, builds internal representations through its transformer layers, and produces a probability distribution over its vocabulary. This distribution assigns higher probabilities to tokens that would naturally continue the text and lower probabilities to tokens that would seem out of place. When the model generates a response, it samples from this distribution (or selects greedily), appends the chosen token to the context, and repeats the process. Each step involves observing a state and selecting an action according to a probability distribution, which is exactly what a policy does.

The key insight is that autoregressive generation is policy rollout in disguise. Starting from the initial state consisting of the prompt, we sample actions according to the policy, each action extends the state, and we continue until reaching a terminal condition. This is a precise mathematical correspondence that allows us to apply policy gradient theorems and optimization techniques to language generation.

Let x=(x1,x2,…,xn)x = (x_1, x_2, \ldots, x_n) denote the input prompt and y=(y1,y2,…,yT)y = (y_1, y_2, \ldots, y_T) the generated response. At each generation step tt, the language model computes the conditional probability of the next token given everything that came before:

πθ(yt∣x,y<t)\pi_\theta(y_t | x, y_{<t})

where:

  • πθ\pi_\theta: the policy defined by model parameters θ\theta
  • yty_t: the token generated at the current step tt
  • xx: the input prompt sequence
  • y<ty_{<t}: the sequence of tokens generated prior to step tt, that is (y1,…,yt−1)(y_1, \ldots, y_{t-1})

This formula represents the probability of generating token yty_t given the full context of the prompt and previous tokens. The notation emphasizes that the policy depends on everything that came before: the original prompt establishes the task, and each previously generated token shapes what should come next. The model's parameters θ\theta encode the learned patterns that determine how context maps to token probabilities.

The probability of a complete response yy given prompt xx is the product of these conditional probabilities across all timesteps. This product structure means that the probability of a response decomposes multiplicatively into the probability of each individual token choice. Understanding this decomposition is needed for understanding how policy gradient methods can work here: updating the parameters θ\theta changes the probability of individual tokens at each step, and those changes compound across the response to shift the overall probability of different response types.

The RL Correspondence

Comparing language models to standard RL shows a direct correspondence between the two frameworks. The state at each step is the concatenation of the prompt and all tokens generated so far: st=(x,y1,…,yt−1)s_t = (x, y_1, \ldots, y_{t-1}). The action is the next token to generate: at=yta_t = y_t. The policy is the LLM's softmax output distribution over the vocabulary: πθ(at∣st)\pi_\theta(a_t|s_t). The trajectory is the complete prompt-response pair: τ=(x,y)\tau = (x, y).

Each of these mappings changes the interpretation for how we set up the optimization. The state includes the entire generation history, so the policy is not Markovian in the traditional sense. The model cannot simply look at one step and decide what to do; it must consider the full context. This is why transformer architectures, which can attend to arbitrarily distant tokens, are well-suited for this role. Older architectures like RNNs, which compress history into a fixed-size vector, are at a disadvantage here.

The autoregressive generation process that produces a response is exactly a policy rollout. Starting from the initial state s1=xs_1 = x, we sample actions according to the policy, each action extends the state, and we continue until generating a stop token. This precise mathematical correspondence allows us to apply policy gradient methods to language generation. Policy optimization theorems and techniques apply to steering language models toward desired behaviors.

The Token Action Space

The action space in language generation is the model's vocabulary. This discrete set of tokens defines every possible action the policy can take at each step. Unlike continuous control problems where actions might be forces or velocities, or even discrete games where actions represent button presses, the language model's action space consists of linguistic units. These might be complete words, word fragments, punctuation marks, or special tokens that signal the end of generation.

Think of the vocabulary as a massive library of building blocks, and generating a response as repeatedly choosing which block to place next. Each choice is constrained by what came before: you cannot place a closing parenthesis before an opening one without creating grammatical nonsense, and you cannot begin a response mid-sentence. The model has learned an enormous amount about which sequences of choices produce coherent, meaningful text, but at each step it must select from the entire library.

For modern language models, this vocabulary typically contains between 30,000 and 100,000 tokens. The exact size depends on the tokenization algorithm used during pretraining. Byte-pair encoding, the most common approach, creates vocabularies that balance coverage of common words with the ability to represent rare words through subword decomposition. The vocabulary must be large enough to efficiently represent common text patterns while remaining small enough for the softmax computation to be tractable. This tradeoff explains why vocabulary sizes cluster around 32,000 to 65,000 tokens for most production models.

This scale is much larger than traditional RL domains. In classic control, action spaces are small. For example, a robot arm might have six joints. In Atari games, agents choose among roughly 18 discrete actions representing joystick directions and button combinations. Language models must select from tens of thousands of possible tokens at every single step. This represents an increase of three to four orders of magnitude in the number of discrete choices.

Out[3]:
Visualization
Bar chart comparing action space sizes across reinforcement learning domains on a log scale. CartPole has 2 actions, Atari around 18, MuJoCo around 100, Go 361, while LLM vocabulary has 50,000.
Comparison of action space sizes across common reinforcement learning domains on a log scale. While traditional benchmarks like Atari or CartPole involve fewer than 100 discrete actions, language models must select from vocabularies of 50,000 tokens or more, presenting a significantly higher-dimensional decision problem at every single generation step.

Implications for Exploration and Credit Assignment

The enormous action space has several important consequences for how PPO behaves when applied to language models. Each of these implications changes how we design and tune the algorithm.

Exploration is implicit in language generation. With such a large action space, the model cannot systematically try all options. There is no way to enumerate the consequences of every possible token choice. Instead, exploration emerges from the inherent stochasticity of sampling from high-entropy distributions during generation. When the model is uncertain, it assigns probability mass to many tokens, and sampling naturally explores these alternatives. This is quite different from classical RL, where explicit exploration strategies like epsilon-greedy or optimistic initialization are necessary. In language generation, sampling temperature takes the place of explicit exploration: higher temperature spreads probability mass more evenly, encouraging exploration; lower temperature concentrates it on the most probable tokens, encouraging exploitation.

Credit assignment is diffuse across the token sequence. When a response receives a reward, determining which specific token choices contributed to that reward is challenging. Did the response succeed because of a precise word choice in the third sentence, or because of the overall argument structure established from the first token? The signal must somehow propagate back through dozens or hundreds of individual decisions. The advantage function addresses this by using a value function to assess the quality of each intermediate state, but the value function itself must learn from the same sparse reward signal. This creates a bootstrapping challenge: we need good value estimates to produce good advantages, but we need good advantages to produce good policy updates that lead to rewards that improve value estimates.

Despite these challenges, the discrete nature of the action space simplifies some aspects of PPO. We can compute exact action probabilities rather than approximating them, as we would need to do in continuous action spaces. We can directly enumerate the KL divergence between policies by summing over vocabulary positions. The softmax function gives us a proper probability distribution that sums to one, avoiding the density estimation challenges that arise with continuous distributions.

In[4]:
Code
import torch
import torch.nn.functional as F

# Illustrate the action space concept
vocab_size = 32000  # Typical vocabulary size
hidden_dim = 768


# A simplified "policy head" that maps hidden states to action probabilities
class PolicyHead(torch.nn.Module):
    def __init__(self, hidden_dim, vocab_size):
        super().__init__()
        self.lm_head = torch.nn.Linear(hidden_dim, vocab_size, bias=False)

    def forward(self, hidden_state):
        # hidden_state: (batch, seq_len, hidden_dim)
        logits = self.lm_head(hidden_state)  # (batch, seq_len, vocab_size)
        return logits

    def get_action_probs(self, hidden_state, temperature=1.0):
        logits = self.forward(hidden_state)
        return F.softmax(logits / temperature, dim=-1)


policy_head = PolicyHead(hidden_dim, vocab_size)
num_params = sum(p.numel() for p in policy_head.parameters())
Out[5]:
Console
Action space size: 32,000 possible tokens
Policy head parameters: 24,576,000

Each position in the generated sequence requires selecting from this massive action space. A 100-token response involves 100 sequential decisions, each choosing among 32,000 options. Even a short response spans a large search space of 3200010032000^{100} possible sequences. This is why language generation uses learned policies to navigate the space efficiently.

States as Growing Contexts

The state in language generation grows with each action. Unlike a game where the agent might return to previously visited states, or a robot navigation task where the agent can revisit locations, language generation always moves forward. Each generated token permanently extends the context. The state at step t+1t+1 contains everything from state tt plus the newly generated token. There is no going back, no undo, and no way to visit the same state twice unless the exact same sequence of tokens has been generated before.

Think of the growing state as a conversation transcript. Once you have said something, you cannot unsay it. Every subsequent word must be consistent with everything already written. If the model commits to a particular argument in sentence one, it must carry that argument through to the end. If it introduces a character in paragraph one, that character constrains what can happen later. This irreversibility creates a basic asymmetry between early and late tokens: early choices constrain later ones, giving them outsized influence on the final response.

Formally, the state at step tt is:

st=(x1,x2,…,xn,y1,y2,…,yt−1)s_t = (x_1, x_2, \ldots, x_n, y_1, y_2, \ldots, y_{t-1})

where:

  • sts_t: the state at timestep tt, consisting of the complete history seen so far
  • xix_i: the ii-th token of the prompt, for i=1,…,ni = 1, \ldots, n
  • nn: the number of tokens in the prompt
  • yjy_j: the jj-th generated token, for j=1,…,t−1j = 1, \ldots, t-1

This formulation captures the needed nature of autoregressive generation. The state is not a compact summary of the situation; it is the complete history of what has been written. The model must condition on all of this information to decide what should come next. A word that appeared ten sentences ago might be important for maintaining coherence, while a word from three tokens ago might determine grammatical constraints on the current position. Transformers handle this beautifully through self-attention, which allows the model to selectively focus on any previous token regardless of distance.

The key insight is that this state space is effectively infinite and largely unique to each trajectory. Two different prompts lead to entirely different state spaces, and even the same prompt with different generation paths explores different states. Unlike board games where states might repeat (the same chess position can arise from different move orders) or continuous control where the system might return to similar configurations, language generation creates fresh territory with every token. Each state is a unique point in the space of possible text prefixes.

The growing state affects implementation in important ways. The model must process longer sequences as it generates tokens. Each new token requires attending to all previous tokens, which increases computation quadratically with sequence length under standard attention. This is where techniques like KV caching, which we discuss in the chapter on efficient inference, become needed for efficient generation during training rollouts. By caching the key and value projections from previous steps, the model avoids recomputing them for each new token, changing per-step complexity from quadratic to linear in the already-generated sequence.

In[6]:
Code
def demonstrate_state_growth():
    """Show how states evolve during generation."""
    prompt_tokens = [101, 2054, 2003, 1996, 3007]  # "What is the capital"

    states = []
    generated = []

    # Simulate a generation trajectory
    response_tokens = [1997, 2605, 1029, 102]  # "of France? [SEP]"

    for t, token in enumerate(response_tokens):
        current_state = prompt_tokens + generated
        states.append(
            {
                "step": t,
                "state_length": len(current_state),
                "state": current_state.copy(),
                "action": token,
            }
        )
        generated.append(token)

    return states


trajectory = demonstrate_state_growth()
Out[7]:
Console
State evolution during generation:
--------------------------------------------------
Step 0: state length = 5, action = 1997
Step 1: state length = 6, action = 2605
Step 2: state length = 7, action = 1029
Step 3: state length = 8, action = 102

Final trajectory length: 4 steps
Out[8]:
Visualization
Line chart showing how context length grows linearly with each generation step, with a shaded blue area and a red dashed boundary between prompt and generated tokens.
Monotonic state growth during the autoregressive generation process. Unlike environments with fixed state spaces or cycles, each generated token permanently extends the context. The blue shaded area shows how the total context (state) expands with each step, while the dashed red line marks where the prompt ends and generated content begins.

The generated trajectory illustrates how the context (state) expands with each step. Even a short response creates a sequence of unique states, as the growing history fundamentally changes the input to the policy at every decision point. At step 0, the policy sees only the prompt. At step 1, it sees the prompt plus one generated token. By the final step, it sees the entire conversation history. This expansion means that the policy faces a different decision problem at every timestep, even though the underlying question remains the same.

Reward Assignment in Sequential Generation

Reward models and RL algorithms use rewards differently, and this mismatch creates one of the central challenges for PPO in language models. The reward model, as we discussed in the chapter on Reward Modeling, takes a complete prompt-response pair and outputs a single scalar score. It evaluates the response as a whole, considering whether it is helpful and coherent as well as accurate and safe. But PPO operates on trajectories with per-timestep rewards. The algorithm expects to receive a reward signal at each step, allowing it to compute advantages and update the policy accordingly.

Think of it this way: the reward model is like a book critic who reads an entire novel and then gives it one star rating. PPO is like a writing coach who wants feedback on every sentence so it can tell the author what to keep and what to cut. The critic's overall assessment captures something real, but the writing coach needs more granular guidance. We have to translate the critic's single rating into per-sentence feedback, which inevitably involves some imprecision.

The standard approach assigns the reward to the final token:

rt={R(x,y)if t=T (final token)0otherwiser_t = \begin{cases} R(x, y) & \text{if } t = T \text{ (final token)} \\ 0 & \text{otherwise} \end{cases}

where:

  • rtr_t: the reward assigned at step tt
  • R(x,y)R(x, y): the scalar score from the reward model for the complete response
  • TT: the length of the generated sequence (final step)
  • xx: the input prompt
  • yy: the complete generated response

This approach reflects the reality that we only know how good a response is once it is complete. We cannot reliably evaluate a partial response because quality often depends on the full content, including how the response concludes. A response that starts brilliantly but ends poorly deserves a low score, but you would not know that until you reach the end. So we wait until generation finishes, compute the reward, and assign it to the final step.

Sparse rewards make credit assignment difficult. The policy must learn which of its many token choices contributed to the final reward. PPO addresses this through its advantage estimation, but the challenge remains significant. A response might receive a low reward because of a single poor word choice, but that signal must propagate back through dozens of preceding tokens. The value function must learn to predict, from any intermediate state, what the expected final reward will be. This prediction is what allows advantages to differentiate between tokens: some tokens lead to states with high expected rewards, others to states with lower expectations.

In[9]:
Code
import numpy as np


def assign_rewards(response_length, final_reward):
    """
    Standard reward assignment: all reward at final step.

    Args:
        response_length: Number of tokens in the response
        final_reward: Scalar reward from the reward model

    Returns:
        Array of per-token rewards
    """
    rewards = np.zeros(response_length)
    rewards[-1] = final_reward  # Assign all reward to final token
    return rewards


def compute_returns(rewards, gamma=1.0):
    """
    Compute discounted returns from rewards.

    With gamma=1.0 (common in RLHF), each token's return
    equals the final reward.
    """
    T = len(rewards)
    returns = np.zeros(T)
    running_return = 0

    for t in reversed(range(T)):
        running_return = rewards[t] + gamma * running_return
        returns[t] = running_return

    return returns
In[10]:
Code
# Demonstrate reward assignment
response_len = 8
reward = 0.75

rewards = assign_rewards(response_len, reward)
returns = compute_returns(rewards)
Out[11]:
Console
Per-token rewards: [0.   0.   0.   0.   0.   0.   0.   0.75]
Per-token returns: [0.75 0.75 0.75 0.75 0.75 0.75 0.75 0.75]

Every token receives return = 0.75 (the final reward)
Out[12]:
Visualization
Bar chart of per-token rewards for an 8-token response. Seven bars are at zero and the final bar reaches 0.75.
Sparse terminal rewards from the reward model: the final token receives the full score while all preceding tokens receive zero reward, which reflects that only a complete response can be meaningfully evaluated.
Bar chart of per-token returns for an 8-token response with gamma equals 1.0. All eight bars are the same height at 0.75.
Constant returns with discount factor gamma equal to 1.0: every token in the sequence shares the same return equal to the terminal reward, propagating the signal uniformly backward through the sequence.

With a discount factor of γ=1.0\gamma = 1.0, which is standard in RLHF, every token in the sequence receives the same return equal to the final reward. This uniform signal might seem uninformative at first glance. If every token gets the same return, how can the algorithm distinguish good tokens from bad ones? The answer lies in the advantage function, which compares the actual return to the expected return under the value function. A token that leads to a higher-than-expected return receives a positive advantage, while a token that leads to a lower-than-expected return receives a negative advantage. This differential signal, created by the value function's predictions, is what enables learning even with sparse rewards.

The Critical Role of the KL Penalty

The most important adaptation for language model PPO is the KL divergence penalty. This constraint measures how far the policy drifts from its starting point and is an important regularizer. Without this constraint, the optimized policy can diverge substantially from the original model, often finding degenerate solutions that maximize reward without producing useful responses.

Think of the KL penalty as a tether connecting the evolving policy to its starting point. As PPO updates the model's parameters to increase reward, the tether pulls back, preventing the model from wandering too far into unknown territory. The reference model, frozen at initialization, represents a baseline of linguistic competence built through pretraining and supervised fine-tuning. The KL penalty says: "you can move to improve reward, but you must not move so far that you forget what you already know."

Reward Hacking Revisited

As we discussed in the chapter on Reward Hacking, optimizing a proxy reward (the learned reward model) rather than true human preferences creates opportunities for exploitation. The reward model is an imperfect approximation of human preferences, and any learned approximation has exploitable gaps. Without the KL penalty, the policy is free to find responses that score highly according to the reward model while being less useful, repetitive, or even nonsensical. The KL penalty is our primary defense against this failure mode, but it is not perfect. The appropriate strength of the penalty depends on how well the reward model generalizes and must be tuned carefully.

Without the KL penalty, the policy often finds degenerate solutions. The policy is free to move anywhere in the space of possible token distributions. If the reward model has exploitable shortcuts that yield high scores without corresponding quality, the unconstrained policy will find them. It might learn to generate repetitive phrases that the reward model scores highly. It might produce outputs that superficially resemble good responses while lacking substance. It might drift so far from natural language that it generates text no human would write. One classic failure mode is mode collapse, where the policy learns to always generate one particular type of response that scores well on average, losing the diversity and adaptability that make a language model useful. The KL penalty prevents these failure modes by keeping the policy anchored to the reference distribution.

The KL penalty modifies the reward at each timestep, converting the sparse terminal reward into a dense per-token signal. The shaped reward at step tt is:

r~t=rt−β⋅KLt\tilde{r}_t = r_t - \beta \cdot \text{KL}_t

where:

  • r~t\tilde{r}_t: the shaped reward at step tt used for PPO training
  • rtr_t: the original sparse reward (nonzero only at the final step)
  • β\beta: the KL penalty coefficient, controlling the strength of the regularization
  • KLt\text{KL}_t: the KL divergence contribution at this step

The KL contribution at each step measures the divergence between the current policy and the reference for the specific token generated. Rather than summing over the full vocabulary (which would require querying both policies over all 50,000+ tokens), we use the log ratio for the chosen action as a tractable per-token approximation:

KLt=log⁡πθ(yt∣st)πref(yt∣st)\text{KL}_t = \log \frac{\pi_\theta(y_t | s_t)}{\pi_{\text{ref}}(y_t | s_t)}

where:

  • πθ(yt∣st)\pi_\theta(y_t | s_t): the probability of the chosen token under the current policy
  • πref(yt∣st)\pi_{\text{ref}}(y_t | s_t): the probability of the chosen token under the reference policy
  • yty_t: the token generated at step tt
  • sts_t: the context (state) at step tt

This formula is easy to interpret. When the current policy assigns higher probability to a token than the reference did, the log ratio is positive, and subtracting it from the reward penalizes this divergence. When the current policy assigns lower probability than the reference, the log ratio is negative, and subtracting it provides a bonus (the policy is moving closer to the reference for that action). The net effect is that the policy is discouraged from making dramatic probability changes in either direction. It can shift probabilities to improve reward, but only within bounds set by β\beta.

The key insight is that this per-token KL penalty serves double duty. First, it constrains policy drift globally, preventing the model from drifting too far from its original behavior. Second, it provides a dense reward signal at every token, not just at the end of generation. This density matters for credit assignment: instead of all tokens receiving the same sparse terminal reward, each token now receives a small bonus or penalty based on how much the policy changed from the reference for that particular choice.

The reference policy πref\pi_{\text{ref}} is typically the model after supervised fine-tuning (SFT) but before any RL training. This anchor prevents the policy from drifting into regions of token space that the original model considered highly unlikely. The SFT model represents our best current understanding of how to generate helpful, coherent text. By constraining the RL policy to stay near this baseline, we ensure that the optimized model retains the linguistic competence learned during pretraining and SFT.

The coefficient β\beta controls the strength of this constraint. Higher values keep the policy closer to the reference but limit learning; the policy cannot deviate much even when doing so would improve reward. Lower values allow more exploration but risk instability and reward hacking; the policy might find degenerate solutions that maximize reward while creating poor text. Success in RLHF depends on finding the right balance. We will explore the mathematical properties and tuning of this penalty in detail in the upcoming chapter on KL Divergence Penalty.

In[13]:
Code
def compute_kl_penalty(log_probs_policy, log_probs_ref):
    """
    Compute per-token KL divergence between policy and reference.

    This is a simplified approximation using only the chosen actions,
    not the full distribution over vocabulary.

    Args:
        log_probs_policy: Log probabilities under current policy
        log_probs_ref: Log probabilities under reference policy

    Returns:
        Per-token KL divergence estimates
    """
    # KL(policy || ref) approximated as log(policy(a)) - log(ref(a)) for chosen action a
    # This is an approximation; true KL sums over all actions
    kl = log_probs_policy - log_probs_ref
    return kl


def apply_kl_reward_shaping(rewards, log_probs_policy, log_probs_ref, beta=0.1):
    """
    Modify rewards with KL penalty.

    Args:
        rewards: Original per-token rewards (typically sparse)
        log_probs_policy: Log probs of chosen tokens under policy
        log_probs_ref: Log probs of chosen tokens under reference
        beta: KL penalty coefficient

    Returns:
        Modified rewards with KL penalty applied
    """
    kl = compute_kl_penalty(log_probs_policy, log_probs_ref)
    shaped_rewards = rewards - beta * kl
    return shaped_rewards, kl
In[14]:
Code
# Demonstrate KL penalty effect
np.random.seed(42)
seq_len = 6

# Simulate log probabilities: gaps chosen so per-token KL penalty is
# clearly visible (~0.25–0.75 units) relative to the terminal reward
log_probs_policy = np.array([-0.8, -1.6, -0.6, -1.8, -0.7, -1.5])
log_probs_ref = np.array([-1.5, -0.9, -1.8, -0.9, -1.9, -0.8])

# Original sparse reward
original_rewards = assign_rewards(seq_len, 0.8)

# Apply KL shaping with beta=0.5 so the per-token penalty is easily readable
shaped_rewards, kl_values = apply_kl_reward_shaping(
    original_rewards, log_probs_policy, log_probs_ref, beta=0.5
)

total_kl = kl_values.sum()
total_original = original_rewards.sum()
total_shaped = shaped_rewards.sum()
Out[15]:
Console
Per-token analysis:
------------------------------------------------------------
Token 0: original_r=0.00, KL=0.700, shaped_r=-0.350
Token 1: original_r=0.00, KL=-0.700, shaped_r=0.350
Token 2: original_r=0.00, KL=1.200, shaped_r=-0.600
Token 3: original_r=0.00, KL=-0.900, shaped_r=0.450
Token 4: original_r=0.00, KL=1.200, shaped_r=-0.600
Token 5: original_r=0.80, KL=-0.700, shaped_r=1.150
------------------------------------------------------------
Total KL penalty: 0.800
Total original reward: 0.80
Total shaped reward: 0.400
Out[16]:
Visualization
Bar chart of original per-token rewards. Five bars at zero and one final bar at 0.8.
Original sparse reward: only the final token receives a non-zero value from the reward model, leaving all intermediate tokens without direct feedback.
Bar chart of negative beta-weighted KL penalty at each token position, alternating between positive and negative values.
Per-token KL contribution: the negative beta-weighted KL penalty applied at each position. Red bars indicate the policy diverged toward higher probability than the reference (penalized), while green bars indicate the policy moved toward lower probability (rewarded).
Bar chart of KL-shaped reward combining terminal reward and per-token KL corrections.
KL-shaped reward: the combination of the sparse terminal reward and the dense per-token KL penalty. Every token now receives feedback, converting the sparse reward problem into a dense one.

Notice how the KL penalty transforms the reward signal. The original sparse reward only provides signal at the final token; all intermediate tokens receive zero reward. After KL shaping, every token receives a reward component based on how much the policy diverges from the reference. Tokens where the policy assigns higher probability than the reference receive penalties (the KL term is positive, so it subtracts from the reward). Tokens where the policy assigns lower probability than the reference receive bonuses (the KL term is negative, so subtracting it adds to the reward). This transformation converts the sparse terminal reward into a dense per-token signal that guides the policy at every step.

PPO Objective for Language Models

The PPO objective for language models combines the clipped surrogate loss from standard PPO with the KL-shaped reward structure we just described. Understanding how these pieces fit together reveals why PPO works for alignment: it maximizes expected reward while maintaining two separate safeguards against instability. The KL penalty prevents the policy from drifting too far from the reference in absolute terms, while the clipping mechanism prevents any single update step from moving too far from the data collection policy.

Think of the full PPO objective as a three-way balancing act. The reward signal pulls the policy toward higher-scoring behaviors. The KL penalty pulls it back toward the reference model. The clipping mechanism ensures that each individual gradient step stays within a safe neighborhood. These three forces together create a training dynamic that is far more stable than any one of them alone.

Combining all these elements, the PPO objective for language models takes the following form. For a batch of prompt-response pairs, we compute:

LPPO(θ)=E(x,y)∼πθold[∑t=1Tmin⁡(ρt(θ)A^t,clip(ρt(θ),1−ϵ,1+ϵ)A^t)]\mathcal{L}_{\text{PPO}}(\theta) = \mathbb{E}_{(x,y) \sim \pi_{\theta_{\text{old}}}} \left[ \sum_{t=1}^{T} \min\left( \rho_t(\theta) \hat{A}_t, \text{clip}(\rho_t(\theta), 1-\epsilon, 1+\epsilon) \hat{A}_t \right) \right]

where:

  • θ\theta: the parameters of the language model being optimized
  • E(x,y)∼πθold\mathbb{E}_{(x,y) \sim \pi_{\theta_{\text{old}}}}: the expectation over trajectories generated by the policy that collected the data
  • ρt(θ)\rho_t(\theta): the probability ratio between the current and old policies at step tt
  • A^t\hat{A}_t: the estimated advantage at step tt, showing how much better the chosen action was compared to the average
  • TT: the length of the generated sequence
  • ϵ\epsilon: the clipping parameter (typically 0.1 or 0.2) that defines the trust region

The min operator serves a important purpose in this objective. It takes the conservative lower bound between the unclipped and clipped objectives. This keeps updates cannot be too aggressive. If the advantage is positive, showing that the chosen action was better than expected, clipping limits how much we increase the probability of the action. If the advantage is negative, showing the action was worse than expected, clipping limits how much we decrease the probability. This prevents large updates that could collapse the policy.

The Probability Ratio

The probability ratio ρt(θ)\rho_t(\theta) is the core mechanism that allows PPO to reuse trajectory data across multiple gradient steps. Without it, policy gradient methods can only take one gradient step per batch of data, making them extremely sample-inefficient. With the ratio, we can check whether the current policy has drifted too far from the data-collection policy and clip the gradient accordingly.

The ratio is defined as:

ρt(θ)=πθ(yt∣x,y<t)πθold(yt∣x,y<t)\rho_t(\theta) = \frac{\pi_\theta(y_t | x, y_{<t})}{\pi_{\theta_{\text{old}}}(y_t | x, y_{<t})}

where:

  • πθ(yt∣x,y<t)\pi_\theta(y_t | x, y_{<t}): the probability of the token yty_t under the current policy (being updated)
  • πθold(yt∣x,y<t)\pi_{\theta_{\text{old}}}(y_t | x, y_{<t}): the probability of the same token under the policy that collected the data (frozen for this update step)
  • yty_t: the token generated at step tt
  • xx: the input prompt
  • y<ty_{<t}: the sequence of tokens generated prior to step tt

This ratio measures how the policy has changed since the data was collected. A ratio of 1.0 means the policy assigns exactly the same probability as before. A ratio greater than 1 means the policy now assigns higher probability to this token. A ratio less than 1 means the probability has decreased. By basing updates on this ratio rather than directly on log probabilities, PPO can perform multiple gradient steps on the same batch of data without the policy drifting too far from the data-collection policy.

The key insight here is why we use the ratio rather than log probabilities directly. In standard REINFORCE, we would multiply the log probability by the advantage and take a gradient step. This works for a single step but fails for multiple steps: after updating the policy, the gradient computed from the old trajectories is no longer valid because the old trajectories were generated by a different policy. The ratio corrects for this by weighting each token's contribution by how much the current policy differs from the data-collection policy.

Advantage Estimation with GAE

The advantage A^t\hat{A}_t is estimated using Generalized Advantage Estimation (GAE) applied to the KL-shaped rewards. GAE is a technique that balances the bias-variance tradeoff in advantage estimation by combining multi-step returns through exponential weighting:

A^t=∑l=0T−t(γλ)lδt+l\hat{A}_t = \sum_{l=0}^{T-t} (\gamma \lambda)^l \delta_{t+l}

where:

  • γ\gamma: the discount factor (usually near 1.0 for RLHF)
  • λ\lambda: the GAE smoothing parameter (typically 0.95)
  • TT: the length of the generated sequence
  • δt+l\delta_{t+l}: the temporal difference (TD) error at step t+lt+l

This summation calculates an exponentially weighted average of future TD errors. The parameter λ\lambda controls the bias-variance trade-off: higher values rely more on observed returns (reducing bias but increasing variance), while lower values rely more on value estimates (reducing variance but potentially introducing bias from imperfect value predictions). Setting λ=1\lambda = 1 recovers Monte Carlo returns, while λ=0\lambda = 0 gives pure one-step TD learning. The typical value of 0.95 balances these considerations.

The TD error δt\delta_t measures the surprise at each step: the difference between the observed reward plus the estimated value of the next state, and the estimated value of the current state:

δt=r~t+γV(st+1)−V(st)\delta_t = \tilde{r}_t + \gamma V(s_{t+1}) - V(s_t)

where:

  • r~t\tilde{r}_t: the KL-shaped reward at step tt
  • γ\gamma: the discount factor
  • V(st)V(s_t): the value function estimate for state sts_t (how good is the current context expected to be?)
  • V(st+1)V(s_{t+1}): the value function estimate for the next state (defined as 0 if t=Tt=T)

The TD error captures whether the transition was better or worse than expected. If the reward plus next-state value exceeds the current-state value, the transition was unexpectedly good, and the TD error is positive. If the sum falls short, the transition was unexpectedly bad, and the TD error is negative. These signals, accumulated through GAE, produce advantage estimates that indicate which actions led to better outcomes than the value function predicted.

In[17]:
Code
import numpy as np


def compute_gae(rewards, values, gamma=1.0, lam=0.95):
    """
    Compute Generalized Advantage Estimation.

    Args:
        rewards: Per-token (shaped) rewards
        values: Value function estimates for each state
        gamma: Discount factor
        lam: GAE lambda parameter

    Returns:
        advantages: GAE advantage estimates
        returns: Target returns for value function
    """
    T = len(rewards)
    advantages = np.zeros(T)
    last_gae = 0

    # Assume terminal state has value 0
    next_value = 0

    for t in reversed(range(T)):
        delta = rewards[t] + gamma * next_value - values[t]
        advantages[t] = last_gae = delta + gamma * lam * last_gae
        next_value = values[t]

    returns = advantages + values
    return advantages, returns


def compute_ppo_loss(
    log_probs_new, log_probs_old, advantages, clip_epsilon=0.2
):
    """
    Compute clipped PPO policy loss.

    Args:
        log_probs_new: Log probs under current policy
        log_probs_old: Log probs under policy that collected data
        advantages: Advantage estimates (should be normalized)
        clip_epsilon: Clipping parameter

    Returns:
        PPO policy loss (to be maximized/negated for minimization)
    """
    # Probability ratio
    ratio = np.exp(log_probs_new - log_probs_old)

    # Clipped ratio
    clipped_ratio = np.clip(ratio, 1 - clip_epsilon, 1 + clip_epsilon)

    # PPO surrogate objectives
    surrogate1 = ratio * advantages
    surrogate2 = clipped_ratio * advantages

    # Take minimum (conservative update)
    loss = np.minimum(surrogate1, surrogate2)

    return loss.mean(), ratio, clipped_ratio
In[18]:
Code
# Demonstrate PPO loss computation
np.random.seed(123)

# Simulate a trajectory
seq_len = 5
shaped_rewards = np.array([0.02, -0.05, 0.03, -0.01, 0.85])  # KL-shaped
values = np.array([0.4, 0.5, 0.45, 0.6, 0.7])  # Value estimates

# Compute advantages
advantages, returns = compute_gae(shaped_rewards, values)

# Normalize advantages
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)

# Log probabilities (old policy collected data, new policy being optimized)
log_probs_old = np.array([-1.2, -0.9, -1.5, -1.1, -0.8])
log_probs_new = np.array([-1.1, -0.85, -1.6, -1.0, -0.75])

# Compute loss
loss, ratio, clipped_ratio = compute_ppo_loss(
    log_probs_new, log_probs_old, advantages
)
Out[19]:
Console
PPO Loss Computation:
--------------------------------------------------
Advantages (normalized): [ 1.022 -0.099  1.171 -0.611 -1.484]
Probability ratios: [1.105 1.051 0.905 1.105 1.051]
Clipped ratios: [1.105 1.051 0.905 1.105 1.051]

PPO loss (to maximize): -0.0299
Out[20]:
Visualization
Line chart showing PPO objective versus probability ratio for positive advantage, with a plateau above 1.2 showing trust region clipping.
PPO clipping under positive advantage: the objective rises linearly with the probability ratio but flattens once the ratio exceeds 1 plus epsilon. This ceiling prevents the algorithm from over-reinforcing already-likely actions simply because the ratio suggests doing so would improve the surrogate objective.
Line chart showing PPO objective versus probability ratio for negative advantage, with a floor below 0.8 showing trust region clipping.
PPO clipping under negative advantage: the objective falls linearly but flattens once the ratio drops below 1 minus epsilon. This floor prevents excessive suppression of actions that have only moderate negative advantage, protecting against overly aggressive updates.

The positive advantage at the final step reflects the high reward, while negative values indicate steps that yielded lower-than-expected value. The probability ratios stay within the trust region defined by the clipping parameter. This shows how PPO maintains stability. When a ratio exceeds 1+ϵ1 + \epsilon or falls below 1−ϵ1 - \epsilon, the clipped ratio takes over, preventing the gradient from pushing the policy further in that direction.

Worked Example: Tracing a Complete PPO Update

To solidify the abstract mechanics, let us trace through a complete PPO update for a short response, computing every quantity step by step. We will use a simplified three-token response to keep the numbers manageable.

Setup: Consider a prompt "What is 2+2?" and a response with three tokens: "The", "answer", "is" (we omit the actual number for brevity). The reward model scores this as 0.7 (somewhat helpful but incomplete). The reference model and current policy have slightly different log probabilities for each token.

Step 1: Assign the sparse reward. The terminal reward of 0.7 goes to the final token, while earlier tokens receive zero:

r1=0,r2=0,r3=0.7r_1 = 0, \quad r_2 = 0, \quad r_3 = 0.7

Step 2: Compute the per-token KL divergence. Suppose the log probabilities are:

log⁡πθ(“The”)=−0.9,log⁡πref(“The”)=−1.2\log \pi_\theta(\text{``The''}) = -0.9, \quad \log \pi_{\text{ref}}(\text{``The''}) = -1.2 log⁡πθ(“answer”)=−1.4,log⁡πref(“answer”)=−0.8\log \pi_\theta(\text{``answer''}) = -1.4, \quad \log \pi_{\text{ref}}(\text{``answer''}) = -0.8 log⁡πθ(“is”)=−0.6,log⁡πref(“is”)=−0.7\log \pi_\theta(\text{``is''}) = -0.6, \quad \log \pi_{\text{ref}}(\text{``is''}) = -0.7

The KL contribution at each step is the log ratio:

KL1=−0.9−(−1.2)=+0.3\text{KL}_1 = -0.9 - (-1.2) = +0.3 KL2=−1.4−(−0.8)=−0.6\text{KL}_2 = -1.4 - (-0.8) = -0.6 KL3=−0.6−(−0.7)=+0.1\text{KL}_3 = -0.6 - (-0.7) = +0.1

Step 3: Apply KL shaping with β=0.2\beta = 0.2. The shaped rewards are:

r~1=0−0.2×0.3=−0.06\tilde{r}_1 = 0 - 0.2 \times 0.3 = -0.06 r~2=0−0.2×(−0.6)=+0.12\tilde{r}_2 = 0 - 0.2 \times (-0.6) = +0.12 r~3=0.7−0.2×0.1=+0.68\tilde{r}_3 = 0.7 - 0.2 \times 0.1 = +0.68

Token 1 receives a small penalty because the policy moved toward higher probability than the reference. Token 2 receives a small bonus because the policy moved toward lower probability than the reference. Token 3 receives most of the terminal reward, slightly discounted by the KL penalty.

Step 4: Compute TD errors and GAE advantages. Suppose the value function estimates are:

V(s1)=0.3,V(s2)=0.5,V(s3)=0.6,V(s4)=0 (terminal)V(s_1) = 0.3, \quad V(s_2) = 0.5, \quad V(s_3) = 0.6, \quad V(s_4) = 0 \text{ (terminal)}

With γ=1.0\gamma = 1.0, the TD errors are:

δ1=r~1+V(s2)−V(s1)=−0.06+0.5−0.3=0.14\delta_1 = \tilde{r}_1 + V(s_2) - V(s_1) = -0.06 + 0.5 - 0.3 = 0.14 δ2=r~2+V(s3)−V(s2)=0.12+0.6−0.5=0.22\delta_2 = \tilde{r}_2 + V(s_3) - V(s_2) = 0.12 + 0.6 - 0.5 = 0.22 δ3=r~3+V(s4)−V(s3)=0.68+0−0.6=0.08\delta_3 = \tilde{r}_3 + V(s_4) - V(s_3) = 0.68 + 0 - 0.6 = 0.08

With λ=0.95\lambda = 0.95, the GAE advantages work backward:

A^3=δ3=0.08\hat{A}_3 = \delta_3 = 0.08 A^2=δ2+0.95×A^3=0.22+0.076=0.296\hat{A}_2 = \delta_2 + 0.95 \times \hat{A}_3 = 0.22 + 0.076 = 0.296 A^1=δ1+0.95×A^2=0.14+0.281=0.421\hat{A}_1 = \delta_1 + 0.95 \times \hat{A}_2 = 0.14 + 0.281 = 0.421

All three advantages are positive, meaning all three tokens led to outcomes better than what the value function predicted. This makes sense: the response received a reward of 0.7 while the value function only predicted around 0.3 to 0.6 for the intermediate states.

Step 5: Normalize advantages. We subtract the mean and divide by the standard deviation:

μ=(0.421+0.296+0.08)/3≈0.266\mu = (0.421 + 0.296 + 0.08) / 3 \approx 0.266 σ=((0.421−0.266)2+(0.296−0.266)2+(0.08−0.266)2)/3≈0.141\sigma = \sqrt{((0.421-0.266)^2 + (0.296-0.266)^2 + (0.08-0.266)^2)/3} \approx 0.141 A^1norm≈1.10,A^2norm≈0.21,A^3norm≈−1.31\hat{A}_1^{\text{norm}} \approx 1.10, \quad \hat{A}_2^{\text{norm}} \approx 0.21, \quad \hat{A}_3^{\text{norm}} \approx -1.31

After normalization, tokens 1 and 2 have positive advantages (they were above average), while token 3 has a negative advantage (it was below average even though the reward was positive, because the value function had already predicted a high value for that state).

Step 6: Compute probability ratios. Suppose during the gradient update epoch, the policy has shifted slightly:

log⁡πθnew(“The”)=−0.85,log⁡πθold(“The”)=−0.9\log \pi_{\theta_{\text{new}}}(\text{``The''}) = -0.85, \quad \log \pi_{\theta_{\text{old}}}(\text{``The''}) = -0.9

The ratio for token 1 is exp⁡(−0.85−(−0.9))=exp⁡(0.05)≈1.05\exp(-0.85 - (-0.9)) = \exp(0.05) \approx 1.05. The advantage is positive, so the policy is trying to increase this token's probability. The ratio of 1.05 is within the trust region [0.8,1.2][0.8, 1.2], so clipping does not activate.

Step 7: Apply the clipped objective. For token 1 with advantage A^1norm=1.10\hat{A}_1^{\text{norm}} = 1.10:

unclipped=ρ1×A^1norm=1.05×1.10=1.155\text{unclipped} = \rho_1 \times \hat{A}_1^{\text{norm}} = 1.05 \times 1.10 = 1.155 clipped=clip(1.05,0.8,1.2)×1.10=1.05×1.10=1.155\text{clipped} = \text{clip}(1.05, 0.8, 1.2) \times 1.10 = 1.05 \times 1.10 = 1.155

Both are equal here because the ratio is within the trust region. The gradient flows through the unclipped objective. For a token where the ratio had exceeded 1.2, clipping would have capped the objective at 1.2×A^1.2 \times \hat{A}, preventing the gradient from pushing the probability any higher.

This walkthrough shows the complete flow: sparse reward becomes shaped reward through KL penalty, shaped reward becomes advantages through GAE, and advantages become policy gradients through the clipped surrogate objective. Each step has a clear purpose, and together they produce a training signal that is both informative and stable.

Implementation: PPO Training Step

Let us now assemble a more complete implementation showing how these pieces fit together in a training step. This simplified version captures the needed structure while omitting some production details like distributed training, gradient accumulation, and advanced memory management. This example shows the flow of data and computation.

The overall structure follows a pattern that will look familiar from supervised learning, but with key differences. We generate responses before computing loss, which makes the training loop longer. We maintain two versions of log probabilities, old and new, to compute the probability ratio. We track the KL divergence as a health metric. These differences reflect the basic distinction between RL and supervised learning: in RL, the data distribution depends on the current policy.

In[21]:
Code
from dataclasses import dataclass
from typing import Tuple

import torch
import torch.nn.functional as F


@dataclass
class PPOConfig:
    """Configuration for PPO training."""

    clip_epsilon: float = 0.2
    kl_coef: float = 0.1
    value_coef: float = 0.5
    gamma: float = 1.0
    lam: float = 0.95


class LanguageModelPPO:
    """
    PPO trainer for language models.

    This simplified implementation demonstrates the key components
    without the full complexity of production systems.
    """

    def __init__(self, policy_model, ref_model, value_model, config: PPOConfig):
        """
        Args:
            policy_model: The LLM being optimized
            ref_model: Frozen reference model for KL computation
            value_model: Value function (often shares backbone with policy)
            config: PPO hyperparameters
        """
        self.policy = policy_model
        self.ref = ref_model
        self.value = value_model
        self.config = config

        # Freeze reference model
        for param in self.ref.parameters():
            param.requires_grad = False

    def compute_log_probs(self, model, input_ids, response_ids):
        """
        Compute log probabilities for response tokens.

        Args:
            model: Language model
            input_ids: Full sequence (prompt + response)
            response_ids: Just the response tokens

        Returns:
            Log probabilities for each response token
        """
        with torch.no_grad() if model == self.ref else torch.enable_grad():
            outputs = model(input_ids)
            logits = outputs.logits if hasattr(outputs, "logits") else outputs

            # Get logits for positions that predict response tokens
            # (shifted by 1 for autoregressive prediction)
            response_start = input_ids.shape[1] - response_ids.shape[1]
            response_logits = logits[:, response_start - 1 : -1, :]

            # Compute log probabilities
            log_probs = F.log_softmax(response_logits, dim=-1)

            # Gather log probs for actual tokens
            token_log_probs = torch.gather(
                log_probs, dim=-1, index=response_ids.unsqueeze(-1)
            ).squeeze(-1)

        return token_log_probs

    def compute_rewards_and_advantages(
        self,
        reward_scores: torch.Tensor,
        log_probs_policy: torch.Tensor,
        log_probs_ref: torch.Tensor,
        values: torch.Tensor,
    ) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        Compute KL-shaped rewards and GAE advantages.
        """
        batch_size, seq_len = log_probs_policy.shape

        # KL penalty per token
        kl = log_probs_policy - log_probs_ref

        # Initialize rewards (sparse: only at final token)
        rewards = torch.zeros_like(log_probs_policy)
        rewards[:, -1] = reward_scores

        # Apply KL shaping
        shaped_rewards = rewards - self.config.kl_coef * kl

        # Compute GAE
        advantages = torch.zeros_like(shaped_rewards)
        last_gae = torch.zeros(batch_size, device=shaped_rewards.device)

        for t in reversed(range(seq_len)):
            next_value = (
                values[:, t + 1]
                if t < seq_len - 1
                else torch.zeros_like(last_gae)
            )
            delta = (
                shaped_rewards[:, t]
                + self.config.gamma * next_value
                - values[:, t]
            )
            advantages[:, t] = last_gae = (
                delta + self.config.gamma * self.config.lam * last_gae
            )

        returns = advantages + values

        return advantages, returns, kl

    def ppo_loss(
        self,
        log_probs_new: torch.Tensor,
        log_probs_old: torch.Tensor,
        advantages: torch.Tensor,
    ) -> torch.Tensor:
        """
        Compute clipped PPO policy loss.
        """
        # Normalize advantages
        advantages = (advantages - advantages.mean()) / (
            advantages.std() + 1e-8
        )

        # Probability ratio
        ratio = torch.exp(log_probs_new - log_probs_old)

        # Clipped surrogate
        clipped_ratio = torch.clamp(
            ratio, 1 - self.config.clip_epsilon, 1 + self.config.clip_epsilon
        )

        # Loss (negative because we minimize)
        policy_loss = -torch.min(
            ratio * advantages, clipped_ratio * advantages
        ).mean()

        return policy_loss

    def value_loss(
        self, values: torch.Tensor, returns: torch.Tensor
    ) -> torch.Tensor:
        """
        Compute value function loss.
        """
        return F.mse_loss(values, returns)
In[22]:
Code
# Initialize configuration
config = PPOConfig()
Out[23]:
Console
LanguageModelPPO trainer initialized with components:
  - Policy model (being optimized)
  - Reference model (frozen, for KL computation)
  - Value model (critic)

Key hyperparameters:
  - clip_epsilon: 0.2
  - kl_coef (beta): 0.1
  - value_coef: 0.5
  - GAE lambda: 0.95

These hyperparameters define the constraints for the optimization. The clipping epsilon and KL coefficient are particularly necessary for preventing the model from collapsing or drifting too far from its original capabilities. The value coefficient balances the policy and value function losses, and the GAE lambda controls the bias-variance trade-off in advantage estimation.

A Complete Training Loop

The following example demonstrates how PPO training proceeds at a high level. We use mock components to illustrate the data flow without requiring actual large models. This demonstration shows the needed rhythm of PPO training: generate responses, score them, compute advantages, and update the policy.

The loop has a distinct structure that differs from supervised fine-tuning. We first generate a batch of responses from the current policy (the rollout phase). We then score those responses with the reward model and compute all the ingredients needed for the update: log probabilities, values, KL divergence, advantages. Only then do we update the parameters. Some implementations perform multiple gradient steps on the same batch (the epochs parameter in PPO), with the probability ratio clipping preventing any single epoch from updating too aggressively.

In[24]:
Code
import torch.nn as nn


class MockLanguageModel(nn.Module):
    """Simplified mock LM for demonstration."""

    def __init__(self, vocab_size=1000, hidden_dim=128):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, hidden_dim)
        self.transformer = nn.TransformerEncoderLayer(
            d_model=hidden_dim, nhead=4, batch_first=True
        )
        self.lm_head = nn.Linear(hidden_dim, vocab_size)

    def forward(self, input_ids):
        x = self.embedding(input_ids)
        x = self.transformer(x)
        logits = self.lm_head(x)
        return logits


class MockValueHead(nn.Module):
    """Value function head."""

    def __init__(self, hidden_dim=128):
        super().__init__()
        self.value_head = nn.Linear(hidden_dim, 1)

    def forward(self, hidden_states):
        return self.value_head(hidden_states).squeeze(-1)


class MockRewardModel(nn.Module):
    """Simplified reward model."""

    def __init__(self, vocab_size=1000, hidden_dim=64):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, hidden_dim)
        self.pool = nn.AdaptiveAvgPool1d(1)
        self.head = nn.Linear(hidden_dim, 1)

    def forward(self, input_ids):
        x = self.embedding(input_ids)
        x = self.pool(x.transpose(1, 2)).squeeze(-1)
        return self.head(x).squeeze(-1)
In[25]:
Code
def run_ppo_training_step(
    policy,
    ref_policy,
    value_head,
    reward_model,
    prompts,
    responses,
    optimizer,
    config,
):
    """
    Execute one PPO training step.

    Args:
        policy: Current policy model
        ref_policy: Reference policy (frozen)
        value_head: Value function
        reward_model: Reward model
        prompts: Batch of prompt token ids
        responses: Batch of response token ids
        optimizer: Optimizer for policy and value head
        config: PPO configuration

    Returns:
        Dictionary of training metrics
    """
    batch_size = prompts.shape[0]

    # Concatenate prompts and responses
    full_sequences = torch.cat([prompts, responses], dim=1)

    # Get reward scores for complete responses
    with torch.no_grad():
        reward_scores = reward_model(full_sequences)

    # Compute log probs under current and reference policy
    policy_logits = policy(full_sequences)
    with torch.no_grad():
        ref_logits = ref_policy(full_sequences)

    # Extract response portion
    prompt_len = prompts.shape[1]
    response_logits = policy_logits[:, prompt_len - 1 : -1, :]
    ref_response_logits = ref_logits[:, prompt_len - 1 : -1, :]

    # Compute log probabilities
    log_probs_policy = F.log_softmax(response_logits, dim=-1)
    log_probs_ref = F.log_softmax(ref_response_logits, dim=-1)

    # Gather log probs for chosen tokens
    chosen_log_probs = torch.gather(
        log_probs_policy, dim=-1, index=responses.unsqueeze(-1)
    ).squeeze(-1)

    chosen_log_probs_ref = torch.gather(
        log_probs_ref, dim=-1, index=responses.unsqueeze(-1)
    ).squeeze(-1)

    # Store old log probs for ratio computation
    old_log_probs = chosen_log_probs.detach()

    # Compute values
    hidden_states = policy.embedding(full_sequences)
    hidden_states = policy.transformer(hidden_states)
    values = value_head(hidden_states[:, prompt_len:, :])

    # Compute KL and advantages
    kl = chosen_log_probs - chosen_log_probs_ref

    # Sparse rewards + KL shaping
    rewards = torch.zeros_like(chosen_log_probs)
    rewards[:, -1] = reward_scores
    shaped_rewards = rewards - config.kl_coef * kl

    # Simple advantage computation (for demonstration)
    returns = torch.zeros_like(shaped_rewards)
    running_return = torch.zeros(batch_size)
    for t in reversed(range(responses.shape[1])):
        running_return = shaped_rewards[:, t] + config.gamma * running_return
        returns[:, t] = running_return

    advantages = returns - values.detach()
    advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)

    # PPO loss
    ratio = torch.exp(chosen_log_probs - old_log_probs)
    clipped_ratio = torch.clamp(
        ratio, 1 - config.clip_epsilon, 1 + config.clip_epsilon
    )
    policy_loss = -torch.min(
        ratio * advantages, clipped_ratio * advantages
    ).mean()

    # Value loss
    value_loss = F.mse_loss(values, returns)

    # Total loss
    total_loss = policy_loss + config.value_coef * value_loss

    # Optimization step
    optimizer.zero_grad()
    total_loss.backward()
    torch.nn.utils.clip_grad_norm_(
        list(policy.parameters()) + list(value_head.parameters()), max_norm=1.0
    )
    optimizer.step()

    return {
        "policy_loss": policy_loss.item(),
        "value_loss": value_loss.item(),
        "mean_reward": reward_scores.mean().item(),
        "mean_kl": kl.mean().item(),
        "mean_ratio": ratio.mean().item(),
    }
In[26]:
Code
# Synthesise demonstration training metrics that reflect typical PPO dynamics.
# A mock training loop on random tokens cannot produce the structured patterns
# described in the prose (declining value loss, rising-then-stable KL, ratio near 1),
# because the value function has no consistent target with random inputs.
# We therefore construct plausible trajectories analytically so the plots
# faithfully illustrate the concepts.
import numpy as np

np.random.seed(42)
n_steps = 20
steps = np.arange(n_steps)

# Policy loss: oscillates around a slowly declining mean (clipping creates noise)
policy_loss_base = 0.18 * np.exp(-steps / 30) + 0.05
policy_loss_noise = 0.04 * np.sin(steps * 1.3) + 0.02 * np.random.randn(n_steps)
policy_loss = policy_loss_base + policy_loss_noise

# Value loss: declines as the critic learns to predict returns, then stabilises
value_loss = 0.55 * np.exp(-steps / 8) + 0.04 + 0.01 * np.random.randn(n_steps)
value_loss = np.clip(value_loss, 0.03, None)

# KL divergence: rises from ~0 as policy moves away from reference, then levels off
kl_base = 0.12 * (1 - np.exp(-steps / 6))
kl = kl_base + 0.005 * np.random.randn(n_steps)
kl = np.clip(kl, 0, None)

# Probability ratio: stays close to 1.0 because clipping constrains updates
ratios = 1.0 + 0.015 * np.sin(steps * 0.8) + 0.008 * np.random.randn(n_steps)

metrics_history = [
    {
        "policy_loss": policy_loss[i],
        "value_loss": value_loss[i],
        "mean_kl": kl[i],
        "mean_ratio": ratios[i],
    }
    for i in range(n_steps)
]
Out[27]:
Visualization
Line chart of PPO policy loss versus training step with a slowly declining oscillating pattern.
PPO policy loss over training steps. The loss fluctuates as the clipped objective alternately saturates and reactivates its gradient, which reflects the tension between reward maximization and the trust region constraint.
Line chart of value function loss versus training step with a sharp initial decline and stabilization.
Value function loss over training steps. The sharp initial decline shows the critic rapidly learning to predict returns, leveling off as estimates converge and giving better advantage baselines for the policy.
Line chart of mean KL divergence versus training step rising gradually then leveling off.
Mean KL divergence from the reference model over training steps. The gradual rise and plateau is the healthy pattern: the policy moves away from the reference to improve reward but the KL penalty prevents unbounded drift.
Line chart of mean probability ratio versus training step staying near 1.0 with a dashed reference line.
Mean probability ratio between the current and old policy over training steps. The ratio stays close to 1.0 throughout, confirming that the clipping mechanism is successfully enforcing the trust region on each update.

The training metrics reveal several important dynamics that you should monitor during real RLHF runs. The policy loss oscillates as the model learns to balance reward maximization against the clipping constraint. The value loss declines as the critic learns to predict returns. This provides better advantage estimates over time. The KL divergence tracks how far the policy drifts from the reference, a key quantity we want to keep bounded. If KL grows too large, the policy is moving into territory where the reward model may not be reliable. The probability ratio stays near 1.0 because the clipping mechanism prevents extreme updates. This shows that the trust region constraint is functioning as intended.

Key Parameters

The success of PPO training depends heavily on correctly setting its hyperparameters. Unlike supervised learning where the learning rate is often the primary concern, PPO for language models has multiple interacting parameters that require careful tuning. Understanding what each parameter controls is the first step toward debugging training failures.

The key parameters for PPO training are:

  • clip_epsilon: The clipping threshold (typically 0.1 or 0.2) that constrains the policy update. Smaller values produce more conservative updates and can slow convergence. Larger values allow faster learning but risk instability. A common heuristic is to start with 0.2 and reduce it if training becomes unstable.
  • kl_coef (β\beta): Coefficient for the KL penalty term, controlling how closely the policy must stay to the reference model. This is perhaps the most important hyperparameter for preventing reward hacking. Higher values produce safer but slower learning. In practice, some implementations use an adaptive β\beta that increases when KL exceeds a target threshold and decreases when KL falls below it.
  • value_coef: Weight for the value function loss in the total optimization objective. Balancing this against the policy loss affects how quickly the value function adapts relative to the policy. If the value function lags too far behind, advantage estimates become unreliable.
  • gamma: Discount factor for future rewards. In RLHF, this is typically set to 1.0 since we care equally about all tokens in the response. Using a discount factor less than 1.0 would bias the model toward placing quality in early tokens rather than later ones, which is generally not desired.
  • lam: The GAE smoothing parameter that balances bias and variance in advantage estimation. Values around 0.95 are standard. Lower values give more biased but lower-variance advantages; higher values give less biased but higher-variance advantages.

Practical Considerations

Implementing PPO for large language models involves several practical challenges beyond the algorithmic core. These engineering concerns often dominate the difficulty of real-world deployments and require careful attention. Getting the algorithm correct is necessary but not sufficient; the system must also be computationally efficient, numerically stable, and reliable to the diversity of prompts and responses encountered in practice.

Memory management is a practical constraint. During training, you must store activations for the policy model, reference model, and value model simultaneously. The reference model can be loaded in half precision or quantized to reduce memory footprint. Some implementations share the backbone between policy and value models, adding only a small value head. This sharing reduces memory requirements but couples the representations, which may affect optimization dynamics. The value head needs to represent very different information from the language modeling head, and sharing representations can create competing gradients.

Batch construction requires careful thought. Unlike supervised learning where examples are independent, PPO batches consist of complete generation trajectories. Generation is inherently sequential, making large-batch collection time-consuming. Implementations typically generate multiple responses in parallel across many prompts, using efficient batched inference to maximize throughput. The batch must contain enough diversity to provide a stable estimate of the gradient. Using too small a batch leads to high variance in the gradient estimate, causing oscillations in training. Using too large a batch may require too much memory or too long a generation phase between updates.

Response generation during training uses sampling rather than greedy decoding. This exploration is needed for PPO to discover high-reward responses that might differ from the reference policy's preferred outputs. Temperature and other sampling parameters become training hyperparameters that affect the exploration-exploitation balance. Too low a temperature leads to insufficient exploration; too high a temperature produces incoherent responses that receive low rewards and provide noisy gradient signals.

Advantage normalization stabilizes training significantly. Normalizing advantages to have zero mean and unit variance across each batch prevents any single trajectory from dominating the gradient. Without normalization, a few outlier responses with extreme advantages could destabilize training by creating large gradient updates. This is a simple intervention with a large practical impact.

In[28]:
Code
# Calculate memory requirements for a 7B model
param_count = 7e9  # 7 billion
bytes_fp16 = 2
bytes_fp32 = 4

# Memory in GB
policy_mem = (param_count * bytes_fp16) / 1e9
ref_mem = (param_count * bytes_fp16) / 1e9

# Value head (approx 25M params)
value_head_params = 25e6
value_mem_mb = (value_head_params * bytes_fp32) / 1e6

# Optimizer (Adam stores momentums + variances in FP32 usually)
opt_mem = (param_count * bytes_fp32) / 1e9

total_mem = policy_mem + ref_mem + (value_mem_mb / 1000) + opt_mem
Out[29]:
Console
Memory footprint comparison for a 7B parameter model:
-------------------------------------------------------
Component                 Precision    Memory         
-------------------------------------------------------
Policy model              FP16         ~14 GB         
Reference model           FP16         ~14 GB         
Value head                FP32         ~100 MB        
Optimizer states          FP32         ~28 GB         
-------------------------------------------------------
Total (naive)                          ~56 GB
Out[30]:
Visualization
Vertical bar chart showing memory requirements for PPO training components on a 7B model, with optimizer states as the largest contributor.
Estimated memory footprint for PPO training of a 7-billion parameter model. The optimizer states dominate because Adam stores first and second moment estimates in full precision. The requirement to simultaneously host the policy model and a frozen reference model in FP16, combined with full-precision optimizer states, creates a total memory demand that exceeds what a single consumer GPU can provide.

To make PPO training tractable for large models, practitioners use several optimization strategies:

  • Load the reference model in 8-bit quantized format, saving approximately 7 GB versus FP16 for a 7B model.
  • Use gradient checkpointing on the policy model to trade computation for activation memory.
  • Apply LoRA (Low-Rank Adaptation) to the policy model, which reduces the optimizer state memory dramatically by only maintaining momentum terms for the low-rank adapter parameters.
  • Use DeepSpeed ZeRO-stage 3 to shard optimizer states, gradients, and parameters across multiple GPUs.

Limitations and Challenges

Applying PPO to language models, while effective, comes with significant challenges that require careful navigation. Understanding these limitations helps explain why the field has explored alternative methods and also sets realistic expectations for what PPO can and cannot achieve.

Sparse rewards make credit assignment fundamentally difficult. When a complete response receives a reward, determining which tokens contributed positively and which detracted is imprecise at best. The advantage function provides some differentiation through the value function's predictions, but it operates through imperfect estimates. The value function can only learn what patterns in intermediate states correlate with final rewards; it cannot directly observe the causal relationships between specific word choices and response quality. A model might learn that responses beginning with "Certainly! Here is..." receive high rewards, without learning anything about the actual quality of what follows. This imprecision can lead to slow learning, gaming of surface patterns, and inconsistent behavior across similar prompts.

The high computational cost of PPO creates a significant barrier to adoption and iteration. PPO requires generating complete responses during training, which is much slower than the teacher-forcing paradigm used in supervised learning. During teacher forcing, the model processes all tokens in parallel because it always sees the correct previous token. During generation, tokens must be produced sequentially. Each training step involves running the policy model to generate text, running it again to compute log probabilities, running the reference model for KL computation, running the value model for advantage estimation, and running the reward model for scoring. This pipeline is sequential in several places and requires keeping multiple large models in memory simultaneously. A single PPO training run can cost 10 to 100 times more compute than the equivalent supervised fine-tuning run, making extensive hyperparameter search impractical.

The sensitivity to hyperparameters creates both reproducibility challenges and practical fragility. The clipping parameter, KL coefficient, learning rate, batch size, number of PPO epochs, advantage normalization, and many other choices all interact in complex ways. Settings that work well for one model size, one task, or one reward model may fail on another configuration. Small changes to any hyperparameter can lead to training instability, where the policy diverges or collapses, or to reward hacking, where the policy finds degenerate solutions. This sensitivity is one of the primary motivations for exploring simpler alternatives like Direct Preference Optimization (DPO), which we will discuss in an upcoming chapter, that eliminate many of these hyperparameters by eliminating the RL training loop entirely.

The reliance on a learned reward model introduces all the challenges we discussed in the Reward Hacking chapter, and PPO's optimization pressure exacerbates them. The reward model is a proxy for human preferences, and optimizing it too aggressively can lead to responses that score highly according to the model while being less useful or even harmful. The reward model was trained on a finite dataset of human comparisons and generalizes imperfectly to the full distribution of prompts the policy will encounter. In the regions of prompt space that were underrepresented in the reward model training data, the reward model may give unreliable signals that the policy will happily exploit. The KL penalty mitigates but does not eliminate this risk, because a sufficiently flexible policy can find high-reward behaviors within the KL constraint that still fail human evaluation.

Finally, the training distribution mismatch between the supervised fine-tuning phase and the RL phase can create instabilities. The SFT model was trained to predict specific human-written responses. The RL policy must generate diverse responses that receive high reward, which may include phrasings and styles very different from the SFT training data. This distribution shift means that the value function must extrapolate far from its training distribution when evaluating novel responses, potentially giving noisy advantage estimates that destabilize the policy update. Some practitioners address this by warming up the value function with supervised training before starting RL, or by periodically refreshing the reference model to track the evolving policy.

Summary

This chapter translated the PPO algorithm from its general reinforcement learning formulation to the specific setting of language model alignment. We started with the basic observation that autoregressive generation is policy rollout: the language model is a policy, the vocabulary is the action space, the growing context is the state, and a generated response is a trajectory. This conceptual mapping enables a precise application of policy gradient theory to language generation.

The key conceptual mappings are:

  • A language model is a stochastic policy, with states being the growing context and actions being vocabulary tokens at each position
  • The action space is the vocabulary, typically containing 30,000 to 100,000 discrete options, many orders of magnitude larger than classical RL environments
  • Rewards are assigned sparsely, with the reward model's score appearing only at the final token because quality can only be evaluated for complete responses
  • The KL divergence penalty turns sparse rewards into dense per-token signal while simultaneously preventing policy collapse and reward hacking
  • GAE computes advantages that differentiate between token choices by estimating the value of intermediate states under the current policy

The PPO objective for language models combines the clipped surrogate loss with KL-shaped rewards, creating a training signal that balances reward maximization against staying close to the reference distribution. This balance is important for stable training and for avoiding the reward hacking that would otherwise plague unconstrained optimization against a learned reward proxy.

Practical deployment requires significant engineering beyond the algorithmic core, including memory optimization through quantization and parameter-efficient methods, careful batch construction to ensure gradient quality, and reliable monitoring of KL divergence, probability ratios, and reward trends during training.

In the next chapter on the RLHF Pipeline, we will see how PPO fits into the complete workflow that turns a pretrained language model into an aligned assistant, including the data collection, reward model training, and iterative refinement stages that surround the PPO optimization we have studied here.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about applying PPO to language models.

PPO for Language Models

Question 1 of 70 of 7 completed
In the reinforcement learning formulation of language generation, what corresponds to the 'state' at timestep t?

Comments

No comments yet. Be the first to share your thoughts!

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025ppolanguage, author = {Michael Brenndoerfer}, title = {PPO for Language Models: Adapting RL to Text Generation}, year = {2025}, url = {https://mbrenndoerfer.com/writing/ppo-for-language-models-rlhf-policy-optimization}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-30} }
APAAcademic
Michael Brenndoerfer (2025). PPO for Language Models: Adapting RL to Text Generation. Retrieved from https://mbrenndoerfer.com/writing/ppo-for-language-models-rlhf-policy-optimization
MLAAcademic
Michael Brenndoerfer. "PPO for Language Models: Adapting RL to Text Generation." 2026. Web. September 30, 2026. <https://mbrenndoerfer.com/writing/ppo-for-language-models-rlhf-policy-optimization>.
CHICAGOAcademic
Michael Brenndoerfer. "PPO for Language Models: Adapting RL to Text Generation." Accessed September 30, 2026. https://mbrenndoerfer.com/writing/ppo-for-language-models-rlhf-policy-optimization.
HARVARDAcademic
Michael Brenndoerfer (2025) 'PPO for Language Models: Adapting RL to Text Generation'. Available at: https://mbrenndoerfer.com/writing/ppo-for-language-models-rlhf-policy-optimization (Accessed: September 30, 2026).
SimpleBasic
Michael Brenndoerfer (2025). PPO for Language Models: Adapting RL to Text Generation. https://mbrenndoerfer.com/writing/ppo-for-language-models-rlhf-policy-optimization

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.