Skip to main content

PPO from Scratch

Key Insight

PPO (Proximal Policy Optimization) is the on-policy algorithm that ships in most production RL systems, and its core idea is a safety rail on how far the policy may move in one update. It forms the importance ratio π_new/π_old — how much more or less likely the new policy makes each past action — multiplies it by the advantage, and then clips that ratio to a narrow band around 1 so a single batch can never yank the policy too far. This is a cheap stand-in for TRPO's expensive trust region: same goal of small, stable steps, but achieved with a one-line clamp instead of constrained optimization. Reproducing CleanRL's ppo.py line by line reveals that the famous five-line loss is the easy part — the rest is GAE, advantage normalization, and the data-wrangling details that actually make it learn.


What's in this directory

FileRole
ppo.pyThe whole algorithm, with every implementation detail behind a named flag on PPOConfig. Project 23 ablates those flags, project 24 swaps the network for a CNN, and project 25 uses this file as the yardstick TRPO is measured against.
python3 ppo.py check # the sanity check that belongs in every PPO you write
python3 ppo.py all # CartPole ~2 min, LunarLander ~6 min, on 12 CPU cores

The five lines

ratio = (new_logp - old_logp).exp() # π_new / π_old
pg_loss = -torch.min(ratio * adv,
ratio.clamp(1 - eps, 1 + eps) * adv).mean()
v_loss = 0.5 * (V_pred - returns).pow(2).mean()
loss = pg_loss + c1 * v_loss - c2 * entropy

The reasoning behind them is worth stating precisely, because it explains why PPO exists at all. A2C (project 21) takes one gradient step per rollout and then throws the data away, because after that step the policy has moved and the data is off-policy. That is a wretched trade: the data cost a walk through the environment, the gradient step cost a microsecond. PPO reuses each batch for several epochs and pays for the resulting off-policy-ness with the importance ratio. Left alone, that ratio would let one batch drag the policy arbitrarily far — so PPO clips it. Past 1 ± ε the objective goes flat, its gradient vanishes, and the batch stops pulling. A trust region implemented as a clamp.

The min (not max) is what makes the clipped objective a pessimistic bound: PPO always believes the less flattering of the two stories it is told about a step. In plain terms: PPO computes the update's benefit two ways — once with the ratio clipped, once without — and then, for a good action, always takes whichever number is smaller, and for a bad action, whichever is worse. It never lets the clip make a step look better than it honestly is; the clip can only hold the policy back, never push it further than the unclipped evidence would justify. That asymmetry is what turns "clip the ratio" into an actual safety rail rather than just a cosmetic cap.

The check that belongs in every PPO you write

$ python3 ppo.py check
first-epoch ratio: mean 1.0000000000 max deviation from 1: 5.96e-08
first-epoch approx KL: -2.91e-10

At the first minibatch of the first epoch, the policy scoring the data is the policy that collected it. The ratio must therefore be exactly 1 and the KL exactly 0. If they are not, your stored log-probs disagree with your network — the most common silent PPO bug there is. It does not crash. It does not warn. It just trains a worse agent.

It solves both tasks

PPO learning curves

taskfinal return (3 seeds)threshold
CartPole-v1500.0 (a perfect score on every seed)475 = solved
LunarLander-v3238.8 (164, 273, 279)200 = solved

And for contrast, A2C from project 21 — the same network, the same GAE, the same parallel environments, differing only in that it uses each batch once and does not clip — plateaus at 98 on LunarLander and stays there. It learns to fly and hover; it does not learn to land. Batch reuse plus a guard rail on the step size is the entire difference, and on this task it is the difference between an agent that hovers and an agent that lands.

(One of the three LunarLander seeds lands at 164 rather than ~275. PPO is robust, not magic; a fifth of the runs in any honest RL table are worse than the other four.)

The clip is not decoration

clipping

The clip fires on a real fraction of samples every update, and the KL divergence per update sits in a narrow band instead of wandering. That band is the trust region, enforced by a clamp that costs nothing.

Note the diagnostic used here: the k3 estimator of the KL divergence (a standard way of measuring "how different is the new policy's action distribution from the old one" — 0 means identical, larger means further apart), ((ratio - 1) - log_ratio).mean(), rather than the naive (-log_ratio).mean(). Both formulas are trying to answer the same question — "on average, how far did the policy move?" — but the naive one is unbiased on average over infinitely many updates while still being able to report a negative distance for any one actual update, which is useless as the alarm you actually want it to be ("how far has my policy moved right now?"); a distance that can read as negative cannot be trusted as an early-warning number to watch during training. This is detail #12 — "debug variables" — and it is in the list of 37 for a reason: you cannot fix what you cannot see.

Three things that are not in the five lines, and without which nothing works

Reproducing PPO line by line teaches something the paper does not: the loss function is the easy part. Three settings, none of them visible in the equations above, each separately turn a solved LunarLander into a wrecked one. All three were live bugs in this project before they were findings.

1. The discount must span the reward. LunarLander pays its +100 landing bonus hundreds of steps after the thruster firings that earned it. At γ = 0.99 the effective horizon is 1/(1−γ) ≈ 100 steps — shorter than the episode. The agent literally cannot see the landing pad from where it stands, so it learns to hover instead, which is precisely what it did here until γ was raised to 0.999. The discount factor is not a "regularizer", it is a statement about which futures the agent is allowed to care about.

2. Reward scaling is load-bearing — and not for the reason you would guess. Turning it off drops LunarLander from 277 to −52, and the mechanism has nothing to do with the rewards being "too big". It is an interaction with a completely different detail:

actor gradient normcritic gradient normglobal clip factoreffective actor LR
raw rewards0.1350.00.5/50 = 0.013e-4 × 0.01 = 3e-6
normalized rewards0.101.00.5/1.0 = 0.503e-4 × 0.50 = 1.5e-4

LunarLander's returns run into the hundreds, so the critic's regression loss — and therefore its gradient — is enormous, while the actor's gradient (on normalized advantages) is order 0.1. Global gradient clipping (detail #11) clips them jointly. The critic's gradient eats the entire clipping budget, the whole update gets rescaled by 0.01, and the actor's effective learning rate collapses to 3e-6. The policy sits still while the critic thrashes. Normalizing the reward does not fix the reward; it fixes the gradient budget. Project 23 measures this.

3. Value-loss clipping (detail #9) is actively harmful on raw rewards. It clamps the critic's prediction to move at most ±clip_coef = ±0.2 per update. With returns in the hundreds, a critic starting at 0 needs a thousand updates just to reach the right order of magnitude — and it has only 293. The critic never arrives, the advantages are garbage, and the agent never learns. Once rewards are normalized the returns are order 1, the ±0.2 clamp becomes a sensible trust region on the critic, and the detail turns from harmful into helpful — project 23 measures it as worth +94. Detail #9 is not "useless", as the literature often has it; it is conditional on detail #30, and an ablation that does not say which reward scale it used has measured nothing.

None of this is in the paper. All of it is in the 37 details, and project 23 exists to take each one apart.

What to take away

The clipped objective is elegant and it is not the point. PPO's reputation for robustness comes from the fact that this particular assembly of parts — vectorized envs, GAE, advantage normalization, batch reuse, a clipped ratio, a global gradient clip, annealed learning rate — tolerates being wrong about almost everything else. But "tolerates almost everything" is not "tolerates anything", and the failures above are instructive precisely because they are silent: no crash, no NaN, no warning. Just an agent that hovers, and a learning curve that looks plausible enough to publish.