Alessandro Duico

Deriving PPO from First Principles

An interactive walkthrough: building the PPO objective piece by piece from scratch.

PPO is the most widely used policy gradient algorithm in deep reinforcement learning. It trained OpenAI Five to beat the Dota 2 world champions in 2019, the robot hand that solved a Rubik’s cube that same year, and the RLHF step behind InstructGPT and ChatGPT in 2022.

Most explanations show the PPO objective in full and then work backwards. Here we build it up one term at a time, so that nothing appears without a reason.

PPO is really just a few additions to REINFORCE, an algorithm from 1992. REINFORCE is about as simple as a learning algorithm gets: a dozen lines of code.

The whole idea is one sentence: run your policy, see which episodes went well, and make those actions more likely.

each run is one episode: a chain of steps, ending when the episode doesπθpolicyτa1a2a3a4a5a6a7a8a9a10G  =  +180made more likelyG  =  +40G  =  −90G  =  −150made less likelyadjust the policy in proportion to how well each run did, then repeat

1 · On-policy RL, in its simplest form

An agent acts, collects rewards, and the episode ends. We want whatever behavior collects the most reward.

All of that behavior lives in the policy πθ\pi_\theta, one neural network with weights θ\theta. Handed a state it returns a probability distribution over actions, πθ(as)\pi_\theta(a \mid s).

One pass through the environment leaves a trail of states, actions and rewards. Call it a trajectory:

τ=(s0,a0,r0,  s1,a1,r1,  ,  sT,aT,rT)\tau = (s_0, a_0, r_0,\; s_1, a_1, r_1,\; \dots,\; s_T, a_T, r_T)

How good was the episode? Add up the rewards, optionally shrinking later ones by a factor γ\gamma per step so that reward now counts for more than reward later. That sum is the return GG:

G(τ)=t=0TγtrtG(\tau) = \sum_{t=0}^{T} \gamma^{t} r_t

✎ Exercise 1 · the return of a trajectory

The return of one whole episode: a single number, not one per step.

# rewards : torch.Tensor (T,)    reward at each step of one episode
# gamma   : float                discount factor
# ->        torch.Tensor scalar  the return of the whole episode
def trajectory_return(rewards, gamma):
  ...

The objective is the expected value of the return, for a trajectory sampled from the policy. Call it JJ:

J(θ)  =  Eτπθ[G(τ)]J(\theta) \;=\; \mathbb{E}_{\tau \sim \pi_\theta}\big[\,G(\tau)\,\big]

We want to find the policy that maximizes JJ.

πθ\pi_\theta is a neural network, so maximizing JJ means gradient ascent, which requires θJ\nabla_\theta J. Luckily, this identity lets us compute it:

θExpθ[f(x)]  =  Expθ[θlogpθ(x)f(x)]\nabla_\theta\, \mathbb{E}_{x\sim p_\theta}[f(x)] \;=\; \mathbb{E}_{x\sim p_\theta}\big[\nabla_\theta \log p_\theta(x)\, f(x)\big]

A gradient of an expectation becomes an expectation of a gradient, and those you can estimate by sampling. This is the score function estimator.

Apply it to JJ, with f=Gf = G:*

θJ(θ)  =  E[  (tθlogπθ(atst))G(τ)  ]\nabla_\theta J(\theta) \;=\; \mathbb{E}\Big[\;\Big(\textstyle\sum_t \nabla_\theta \log \pi_\theta(a_t\mid s_t)\Big)\cdot G(\tau)\;\Big]

A working algorithm, and a naive one.

Reading it carefully says why: every action in the episode is multiplied by the same number G(τ)G(\tau). An action taken at step t=200t = 200 is credited with rewards collected at step t=3t = 3.

2 · REINFORCE

An action cannot influence rewards that arrived before it. That is the principle of causality. Those earlier terms are not signal; they are noise, added to every update that action will ever receive. So we credit each action with only what came after it. That is the REINFORCE reward:

Gt=k=tTγktrkG_t = \sum_{k=t}^{T}\gamma^{k-t} r_k
G(τ)  =  tγtrtnaive: every action credited with every rewardγ0γ1γ2γ3a0γ0γ1γ2γ3a1γ0γ1γ2γ3a2γ0γ1γ2γ3a3r0r1r2r3Gt  =  ktγktrkREINFORCE: only the rewards that came after itγ0γ1γ2γ3a0γ0γ1γ2a1γ0γ1a2γ0a3r0r1r2r3
Click an action to see what it is credited with, and the discount on each connection.

Swapping it in costs nothing: the discarded terms had expectation zero, so the estimator is still exactly unbiased, and strictly quieter. This is REINFORCE in the form people usually mean:

θJE[tθlogπθ(atst)Gt]\nabla_\theta J \approx \mathbb{E}\Big[\sum_t \nabla_\theta \log \pi_\theta(a_t\mid s_t)\cdot G_t\Big]

Notice what GtG_t actually is. It is the observed return, a sum of rewards that really happened, not an estimate of anything. REINFORCE has no value function, no model of the environment, nothing to learn but the policy itself.

✎ Exercise 2 · reward-to-go

The sum above has an equivalent recursive form, and it is the one you want here:

Gt  =  rt+γGt+1,GT+1=0G_t \;=\; r_t + \gamma\,G_{t+1}, \qquad G_{T+1} = 0

Walk backwards and each return is one multiply and one add. The loop is written for you. Fill in the line that carries the return back a step.

# rewards : torch.Tensor (T,)    reward at each step of one episode
# gamma   : float                discount factor
# ->        torch.Tensor (T,)    G[t] = return from step t onwards
def reward_to_go(rewards, gamma):
  G = torch.zeros_like(rewards)
  acc = 0.0
  for t in reversed(range(len(rewards))):
      acc = ...
      G[t] = acc
  return G

Whichever action you sample goes up

One state, three actions. We sample an action atπθa_t \sim \pi_\theta and collect the reward it pays. The episode is one step long, so Gt=rtG_t = r_t.

Set what each action returns and choose which one came out of the sample. The bars show where the probabilities end up.

sampled

Every action returns something positive, so only the one that gets sampled is reinforced, even the worst of the three.

3 · REINFORCE minus a baseline

If every GtG_t is positive then every action is pushed up, and only the differences between them carry any information.

The fix is to subtract a reference point. Call it the baseline, b(st)b(s_t):

Gtb(st)G_t - b(s_t)

The surprising part is that you may choose it freely: any such bb leaves the gradient’s expectation exactly where it was.

Eaπ[logπθ(as)b(s)]=b(s)aπθ(as)=b(s) ⁣ ⁣aπθ(as)=b(s)1  =  0\begin{aligned} \mathbb{E}_{a\sim\pi}\big[\nabla\log\pi_\theta(a\mid s)\,b(s)\big] &= b(s)\sum_a \nabla\pi_\theta(a\mid s) \\ &= b(s)\,\nabla\!\!\sum_a \pi_\theta(a\mid s) \\ &= b(s)\,\nabla 1 \;=\; 0 \end{aligned}

The reason is worth seeing without the algebra too. Probabilities in a state sum to one, so pushing all of them up by the same amount pushes none of them up relative to each other.

The best baseline is the one that predicts the return you were about to get: b(st)=Vπ(st)b(s_t) = V^\pi(s_t), the value of being in state sts_t.


The same three actions. Let’s replace GtG_t with GtVπ(st)G_t - V^\pi(s_t).

sampled

The return is measured against that average now, so a sampled action can be pushed down, not just up.

4 · One equation to rule them all

Look back at what actually changed between the last three formulas. The logπ\nabla \log \pi factor never moved. Only the number multiplying it did.

So give that coefficient a name and write the family once:

θJ(θ)  =  E[θlogπθ(atst)Ψt]\nabla_\theta J(\theta)\;=\;\mathbb{E}\big[\,\nabla_\theta \log \pi_\theta(a_t\mid s_t)\cdot \Psi_t\,\big]

Every algorithm from here (A2C, TRPO, PPO) is a choice of Ψt\Psi_t.*

Ψt\Psi_t
REINFORCE, naiveG(τ)G(\tau)
+ causalityGtG_t
+ baselineGtV(st)G_t - V(s_t)
A2Ccoming next
PPOcoming next
* Not only a choice of Ψt\Psi_t. TRPO and PPO also change how the batch is used: instead of one gradient step per batch of experience, they take several passes over the same batch. That is section 6.

5 · A2C

Three additions: bootstrapping, the critic (a second neural network, estimating V(s)V(s)), and GAE.

Bootstrapping

Using GtG_t means waiting for the episode to end. But you do not need the whole episode to judge an action. The value of a state is the reward you collect plus the value of wherever you land. That is the Bellman equation:

Vπ(s)=E[r+γVπ(s)]V^\pi(s) = \mathbb{E}\big[\,r + \gamma V^\pi(s')\,\big]

So stop after one step: take the reward you got and let the value of the next state stand in for the rest.

Monte Carlowaits for the endstst+1st+2st+3st+4st+5Gt every reward from here to the endBootstrappingone real step, then the criticVφ(st+1)stst+1rtVφ(st+1) stands in for all of it

We do not have VπV^\pi. In its place put a second network, VϕV_\phi, with its own parameters ϕ\phi, trained to predict what a state is worth.

The gap between what VϕV_\phi predicted and what one step of reality says is the TD error, δt\delta_t.

We are improving an estimate using another estimate. That is bootstrapping, and it is the one idea separating A2C from everything before it.

✎ Exercise 3 · the TD error

δt=rt+γVϕ(st+1)Vϕ(st)\delta_t = r_t + \gamma V_\phi(s_{t+1}) - V_\phi(s_t)

One line, no loop. The only catch is Vϕ(st+1)V_\phi(s_{t+1}): for every step it is the next entry of values, and the last step has no next entry, so last_value stands in for it.

# rewards    : torch.Tensor (T,)  reward collected at each step of the rollout
# values     : torch.Tensor (T,)  the critic's V(s_t) at each step
# last_value : torch.Tensor ()    the critic's V(s_T), where the rollout stopped
# gamma      : float
# ->           torch.Tensor (T,)  one TD error per step
def td_error(rewards, values, last_value, gamma):
  ...

The critic

There are two networks now, doing different jobs. πθ\pi_\theta picks actions: it is the actor, and the only part that gets used once training is over. VϕV_\phi never chooses anything. It predicts, and its prediction is what the actor’s choices get measured against. It is the critic.

actorsπθa probability for every actionπθ : S → Δ(A)criticsVφ−12.4one numberVφ : S → R

And the quantity being measured has a name. How much better an action turned out than what the policy would have done on average is the advantage:

Aπ(st,at)  =  E[Gt    st,at]    Vπ(st)A^\pi(s_t,a_t) \;=\; \mathbb{E}\big[\,G_t \;\big|\; s_t, a_t\,\big] \;-\; V^\pi(s_t)

It is zero-mean under the policy, Eaπ[Aπ(s,a)]=0\mathbb{E}_{a\sim\pi}[A^\pi(s,a)] = 0, so better-than-average actions get positive weight and worse-than-average get negative. Section 3’s baseline was already estimating it with a whole episode; δt\delta_t estimates it with one step.

Three words, three pieces. A2C is advantage actor-critic.

The gain is less variance: one transition’s worth of noise instead of an entire episode’s, and no waiting for the episode to end, since a single step is now enough to learn from.

The cost is correctness. δt\delta_t is an advantage estimate only when Vϕ=VπV_\phi = V^\pi, and VϕV_\phi is never quite right, so where every estimator so far was unbiased and merely noisy, this one is biased.

GAE

We now have two estimates of the same advantage, and they disagree about how far to look before trusting the critic.

Nothing forces that choice. You could take two real rewards before falling back on VϕV_\phi, or three, or ten:

A^t(n)=rt+γrt+1++γn1rt+n1+γnVϕ(st+n)Vϕ(st)\hat A_t^{(n)} = r_t + \gamma r_{t+1} + \dots + \gamma^{n-1} r_{t+n-1} + \gamma^{n} V_\phi(s_{t+n}) - V_\phi(s_t)

So rather than picking one, take a weighted average of all of them, with the weight decaying by a factor λ\lambda each step further out. That average collapses into a single sum over the TD errors we already have:

A^tGAE(λ)=l0(γλ)lδt+l\hat A_t^{\text{GAE}(\lambda)} = \sum_{l\ge 0}(\gamma\lambda)^l\,\delta_{t+l}

This is the generalized advantage estimator, and λ\lambda is the dial.

At λ=1\lambda = 1 nothing decays: Monte Carlo again.

At λ=0\lambda = 0 only the first term survives: the single TD error. The two algorithms we have built so far are the same one, at opposite ends of this dial.

So which setting? In practice almost everyone picks λ0.95\lambda \approx 0.95 — close to the Monte Carlo end, but not quite at it. The plot below shows why.

Bias falls as λ → 1; variance rises. What trades them off is the mean squared error, bias² + variance, smallest at the marked λ. All three are scaled to a common maximum, so the frame holds still as you drag. Push the critic error up and the optimum moves right — a worse critic is worth bootstrapping through less.

✎ Exercise 4 · GAE

One line. delta is already there: the TD error at this step. What is left is acc, which carries it backwards, and that is the whole of GAE: the same accumulation you wrote in exercise 2, with a second decay on it.

# rewards    : torch.Tensor (T,)     reward at each step
# values     : torch.Tensor (T,)     V(s_t), the critic at each step
# last_value : torch.Tensor scalar   V(s_T), to bootstrap the final step
# gamma, lam : float
# ->           torch.Tensor (T,)     the GAE advantage at each step
def gae(rewards, values, last_value, gamma, lam):
  adv = torch.zeros_like(rewards)
  acc = 0.0
  for t in reversed(range(len(rewards))):
      v_next = last_value if t == len(rewards) - 1 else values[t + 1]
      delta = rewards[t] + gamma * v_next - values[t]
      acc = ...
      adv[t] = acc
  return adv

6 · PPO

A2C uses each transition once and throws it away. After a single gradient step πθπold\pi_\theta \ne \pi_{\text{old}}, so the batch was produced by a policy we no longer have, and the on-policy expectation from section 1, which assumed the data came from the policy being updated, no longer applies.

Weight each sample by how much more likely the new policy is to have taken it:

ρt(θ)=πθ(atst)πold(atst)\rho_t(\theta) = \frac{\pi_\theta(a_t\mid s_t)}{\pi_{\text{old}}(a_t\mid s_t)}

and the objective is usable again:

LCPI(θ)=E[ρt(θ)A^t]L^{\text{CPI}}(\theta) = \mathbb{E}\big[\,\rho_t(\theta)\,\hat A_t\,\big]

Reweighting samples drawn from one distribution in order to estimate an expectation under another is called importance sampling; ρt\rho_t is the importance weight. Here it is what makes a second pass over the same batch legitimate.

A quick check that nothing was smuggled in: at the very start of the update πθ=πold\pi_\theta = \pi_{\text{old}}, so ρ1\rho \equiv 1 and the gradient is exactly A2C’s.

✎ Exercise 5 · the importance ratio

ρt(θ)=πθ(atst)πold(atst)\rho_t(\theta) = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\text{old}}(a_t \mid s_t)}

You have logp_new and logp_old. Log probabilities are what a policy actually returns. Produce πθ/πold\pi_\theta/\pi_{\text{old}}, elementwise.

# logp_new : torch.Tensor (N,)  log pi_theta(a|s), the policy being updated
# logp_old : torch.Tensor (N,)  log pi_old(a|s), the one that collected the batch
# ->         torch.Tensor (N,)  one ratio per sample
def importance_ratio(logp_new, logp_old):
  ...

There is a catch, though. Nothing bounds ρt\rho_t. With A^t>0\hat A_t > 0 the objective grows without limit as ρt\rho_t \to \infty, so gradient ascent will happily drive one sampled action’s probability to 1 on the strength of a single batch.

Say πold\pi_{\text{old}} took an action with probability 0.10.1 and πθ\pi_\theta now takes it with probability 0.90.9. Then ρt=9\rho_t = 9, and that single sample pulls nine times as hard as it did when it was collected.

We need to constrain how far the policy may move in one update. The principled way is to bound the KL divergence between πold\pi_{\text{old}} and πθ\pi_\theta — a measure of how far apart two distributions are. An improvement guarantee can be proved mathematically when using KL divergence.

TRPO, for trust region policy optimization, is the 2015 algorithm PPO grew out of, and it uses the KL directly: maximize the objective subject to KL(πoldπθ)δ\mathrm{KL}(\pi_{\text{old}} \,\|\, \pi_\theta) \le \delta.

That works, but it is expensive.

PPO approximates that constraint inside the objective itself, by clipping the ratio so that moving too far stops paying:

LCLIP=E[min(ρtA^t,  clip(ρt,1ϵ,1+ϵ)A^t)]L^{\text{CLIP}} = \mathbb{E}\Big[\min\big(\rho_t \hat A_t,\; \text{clip}(\rho_t, 1-\epsilon, 1+\epsilon)\,\hat A_t\big)\Big]

If that looks intimidating, there are only two cases — one per sign of A^t\hat A_t:

advantageratioLtCLIPL^{\text{CLIP}}_t
A^t>0\hat A_t > 0ρt1+ϵ\rho_t \le 1+\epsilonρtA^t\rho_t \hat A_tgradient flows
ρt>1+ϵ\rho_t > 1+\epsilon(1+ϵ)A^t(1+\epsilon)\,\hat A_tgradient zero
A^t<0\hat A_t < 0ρt1ϵ\rho_t \ge 1-\epsilonρtA^t\rho_t \hat A_tgradient flows
ρt<1ϵ\rho_t < 1-\epsilon(1ϵ)A^t(1-\epsilon)\,\hat A_tgradient zero
The flat stretch is where the objective stops depending on θ. The gradient is exactly zero, not merely small. It appears only on the side the advantage was pushing toward, so the policy can always come back — a symmetric clip without the min would strand it outside the band with no way home. Flip the sign of the advantage and watch the dead zone jump sides.

✎ Exercise 6 · the clipped surrogate

LCLIP=E[min(ρtA^t,  clip(ρt,1ϵ,1+ϵ)A^t)]L^{\text{CLIP}} = \mathbb{E}\Big[\min\big(\rho_t \hat A_t,\; \text{clip}(\rho_t, 1-\epsilon, 1+\epsilon)\,\hat A_t\big)\Big]

From ratio, adv and clip_coef, return the scalar loss. Two traps: the leading sign, and which of min/max you need.

# ratio     : torch.Tensor (N,)      pi_theta / pi_old, one per sample
# adv       : torch.Tensor (N,)      advantage estimate, one per sample
# clip_coef : float                  epsilon, e.g. 0.2
# ->          torch.Tensor scalar    the LOSS (a negated objective)
def clipped_surrogate(ratio, adv, clip_coef):
  ...

Clipping allows PPO to reuse data

What PPO adds is data reuse: several gradient steps on each batch instead of one. The ratio ρt\rho_t is what makes that legitimate, since a batch collected under πold\pi_{\text{old}} still counts for the policy we have now. The clip is what keeps those extra steps safe. Neither data reuse nor clipping does anything alone. Let’s run both on the same environment.

Cliff Walking is a small gridworld: 1-1 per step, 100-100 for stepping off the cliff, and an optimal route worth 13-13.

A 4x12 gridworld. Every square costs −1. The bottom row between start and goal is
the cliff, costing −100 and sending the agent back to the start. The optimal route
runs along the row just above the cliff and is worth −13; the safe route detours a
further row up and is worth −15.
The optimal route hugs the cliff edge. One step wrong costs more than the whole episode is worth.

Run at γ=1\gamma = 1 for 400k environment steps, it defeats A2C completely. Every row below sees that same amount of experience; only the update changes:

gradient steps per rolloutclipreturn
1off−1338
10.2−1338identical: with ρ1\rho \equiv 1 the clip is inert
16 (4 epochs × 4 minibatches)off−676overshoots
160.2−14solved

Sixteen times the learning per unit of experience is what lets the policy find the goal, and the clip is what stops sixteen unconstrained steps on one batch from blowing up.

Why γ=1\gamma = 1 is what breaks A2C here is worth its own look. Bootstrapping needs the Bellman backup to be a contraction, and γ\gamma is the contraction modulus. At γ=1\gamma = 1 there is no fixed point to fall into at all:

TD updates on a state that always pays −1. For γ < 1 the value settles at −1/(1−γ). At γ = 1 there is nothing to settle onto, and V walks off — taking the advantage estimates, and the policy, with it.

The whole loss

Everything so far has been the policy term. A real implementation adds two more:

L  =  LCLIPpolicy  +  cvLVcritic    ceHˉexplorationL \;=\; \underbrace{-L^{\text{CLIP}}}_{\text{policy}} \;+\; c_v \underbrace{L^{V}}_{\text{critic}} \;-\; c_e \underbrace{\bar H}_{\text{exploration}}

The critic term trains VϕV_\phi: plain regression onto the returns the rollout actually produced, LV=E[(Vϕ(st)Rt)2]L^{V} = \mathbb{E}\big[(V_\phi(s_t) - R_t)^2\big].

The entropy term pays the policy to stay spread out. Hˉ\bar H is largest when it still gives every action a chance and zero once it always picks the same one, so rewarding it delays the moment exploration stops.

✎ Exercise 7 · put it together

The three terms are computed for you. Combine them into the single scalar that gets differentiated.

# pg_loss  : torch.Tensor scalar   clipped surrogate, already negated
# v_loss   : torch.Tensor scalar   mean squared error of the critic
# entropy  : torch.Tensor (N,)     H(pi) at each state in the batch
# vf_coef  : float                 weight on the critic
# ent_coef : float                 weight on exploration
# ->         torch.Tensor scalar   the one number you call .backward() on
def ppo_loss(pg_loss, v_loss, entropy, vf_coef, ent_coef):
  policy_term  = pg_loss
  value_term   = vf_coef * v_loss
  entropy_term = ent_coef * entropy.mean()
  return ...

And that is PPO — the whole objective, nothing held back. You derived it: the return, the reward-to-go, the baseline, the advantage, bootstrapping, GAE, the importance ratio, the clip. Every term above is something you built a reason for.

Every step, in one table

Ψt\Psi_tbiasvarianceneeds full episodes
REINFORCE, naiveG(τ)G(\tau)noneterribleyes
+ causalityGtG_tnonehighyes
+ baselineGtV(st)G_t - V(s_t)nonemoderateyes
A2Crt+γV(st+1)V(st)r_t + \gamma V(s_{t+1}) - V(s_t)yeslowno
PPOmin ⁣(ρtA^t, clip(ρt,1±ϵ)A^t)\min\!\big(\rho_t\hat A_t,\ \mathrm{clip}(\rho_t, 1{\pm}\epsilon)\,\hat A_t\big)yeslowno

The first two rows are free: variance down, bias unchanged. Only the move to A2C costs something, and it is the same thing every time: a target that refers to its own estimate. That is what buys the sample efficiency, and it is what breaks when γ=1\gamma = 1.

NB — The last row is more than just a coefficient. PPO keeps A2C’s A^t\hat A_t and changes what you may do with it: reuse the batch, so long as the policy does not wander too far while you do.

Where to go next

The same path, further on. TRPO is the step PPO approximates: a hard KL constraint with a monotonic-improvement guarantee inherited from Conservative Policy Iteration. GAE is the λ\lambda dial in its own paper. GRPO drops the critic and returns to a Monte Carlo estimator with an empirical group-mean baseline — section 3, rediscovered for language models.

Exploration is its own field. RND and ICM supply the directed exploration sampling cannot; Go-Explore attacks it by remembering promising states; the noisy-TV problem is where curiosity itself breaks.

Off-policy avoids throwing data away at all. SAC for continuous control, and the deadly triad for why that is harder than it sounds.

Canonical sources. Sutton & Barto, Reinforcement Learning: An Introduction. Ch. 13 for policy gradients. The 37 Implementation Details of PPO for everything the equations leave out, and CleanRL for single-file implementations worth reading start to finish.