PPO for Language Models › A Complete Training Loop

Michael BrenndoerferDecember 28, 20257 min read
Language AI HandbookAll chapters
Part 37 of 67Alignment and RLHF
Chapter 279PPO for Language Models › A Complete Training Loop
Reading section 10 of 15 · A Complete Training Loop · 7 minSection 10 of 15 · 7 min

Book contents

Section 10 of 15 · 7 min read

PPO for Language Models › A Complete Training LoopOne section · Sections in this chapter10 / 15 ↓
Reading tools Definitions & inline links

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

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.