Trust Regions and Proximal Policy Optimization

Dive into Deep Learning · §15.2

Trust regions and proximal policy optimization
parameter distance lies about policy distance · reuse a batch, exactly · the performance difference lemma · a clip instead of a constraint

Parameter Space versus Policy Space

One parameter, two actions (example due to Joshua Achiam), two updates of the same size \Delta\theta = 2:

\sigma'(0) = 0.25 against \sigma'(6) \approx 0.0025: no learning rate is right in both regions. One oversized step near indifference throws the policy into saturation, scores vanish, and the on-policy data is collected by the broken policy: the run is over. Capping the step in \theta caps the wrong quantity.

Old Data, Exactly

What can a batch from \pi_{\theta_{\text{old}}} say about \pi_\theta? Change of measure:

J(\theta) = E_{\tau \sim \theta_{\text{old}}}\!\Big[ \tfrac{P(\tau;\theta)}{P(\tau;\theta_{\text{old}})}\, R(\tau) \Big], \qquad \frac{P(\tau;\theta)}{P(\tau;\theta_{\text{old}})} = \prod_t \frac{\pi_\theta(a_t\mid s_t)} {\pi_{\theta_{\text{old}}}(a_t\mid s_t)}.

  • transitions cancel in the ratio: model-free, again
  • exact and unbiased, but the product compounds along the trajectory; all the bias traded for horizon-growing variance

A Surrogate You Can Afford

Keep one ratio per step:

\rho_t = \frac{\pi_\theta(a_t\mid s_t)} {\pi_{\theta_{\text{old}}}(a_t\mid s_t)}, \qquad \hat L(\theta) = \frac1n \sum_{i,t} \rho^i_t(\theta)\, \hat A^i_t.

Two corners cut: product \to per-step ratio; states still from the old policy’s visits. At \theta_{\text{old}}, \nabla \hat L is the policy gradient. \hat L is a local model: trustworthy near where it was built, a liar far away.

The Performance Difference Lemma

J(\theta) - J(\theta_{\text{old}}) = E_{\tau \sim P(\cdot;\,\theta)} \Big[ \sum_t \gamma^t\, A^{\text{old}}(s_t, a_t) \Big]

Proof in four lines: the TD identity telescopes along any trajectory; take expectations (:cite:Kakade.Langford.2002).

  • improvement = the new policy’s expected old-policy advantage
  • everything hard hides in \tau \sim \theta: where the new policy goes
  • swap in the old states, reweight actions by \rho_t: the surrogate. The two cut corners are one corner seen twice

TRPO: a Bound, then a Constraint

J(\theta) \geq J(\theta_{\text{old}}) + \bar L(\theta) - \frac{4\gamma A_{\max}}{(1-\gamma)^2} \max_s D_{\text{KL}}\big(\pi_{\theta_{\text{old}}} \Vert \pi_\theta\big)

\bar L: the population surrogate over the discounted occupancy \rho_{\text{old}}; \bar L(\theta_{\text{old}}) = 0 exactly (the sampled \hat L need not vanish). Ascend the lower bound and J ascends monotonically. In practice: sampled \hat L, mean KL \leq \delta_{\text{KL}} over visited states (a proxy, no bound attached), second-order machinery to solve it.

Measuring steps in KL is steepest ascent under the Fisher metric: the natural gradient, :numref:sec_muon’s norm story again.

The Clip

L^{\text{CLIP}} = \frac1n\sum_{i,t} \min\!\big(\rho\hat A,\ \text{clip}(\rho,1-\epsilon,1+\epsilon) \hat A\big)

Once a ratio leaves the band in the paying direction, that sample’s gradient is zero; the pessimistic side stays open. PPO keeps the shape of the guarantee and none of the guarantee.

ppo_epochs: Reuse as a Function

Freeze the advantages and the collector’s log-probabilities, then spend the epochs; diagnostics returned as data:

@nnx.jit
def _ppo_step(policy, opt, obs, act, adv, logp_old, mask, epsilon,
              entropy_coef, use_clip):
    def loss_fn(policy):
        logp_all = jax.nn.log_softmax(policy(obs), axis=-1)
        logp = jnp.take_along_axis(logp_all, act[:, None], -1).squeeze(-1)
        rho = jnp.exp(logp - logp_old)
        surr = jnp.where(use_clip, jnp.minimum(
            rho * adv, jnp.clip(rho, 1 - epsilon, 1 + epsilon) * adv),
            rho * adv)
        entropy = -(jnp.exp(logp_all) * logp_all).sum(-1)
        loss = -(mask * (surr + entropy_coef * entropy)).sum() / mask.sum()
        return loss, (rho, logp, entropy)
    (_, (rho, logp, entropy)), grads = nnx.value_and_grad(
        loss_fn, has_aux=True)(policy)
    opt.update(policy, grads)
    n = mask.sum()
    return ((mask * (jnp.abs(rho - 1) > epsilon)).sum() / n,
            (mask * (logp_old - logp)).sum() / n, (mask * entropy).sum() / n)

def ppo_epochs(ac, batch, adv, logp_old, epsilon, num_epochs,
               entropy_coef=0.01, use_clip=True):
    """num_epochs clipped-surrogate passes on one frozen batch; returns
    [num_epochs, 3] numpy diagnostics: fraction of ratios outside the
    band, approximate KL, mean policy entropy."""
    size = 1 << max(6, (len(adv) - 1).bit_length())
    mask = jnp.asarray((np.arange(size) < len(adv)).astype(np.float32))
    obs, act, adv, logp_old = (_pad(np.asarray(x), size) for x in
                               (batch.obs, batch.act, adv, logp_old))
    step = nnx.cached_partial(_ppo_step, ac.policy, ac.opt_pi)
    return np.array([step(obs, act, adv, logp_old, mask, epsilon,
                          entropy_coef, use_clip)
                     for _ in range(num_epochs)])

train_ppo: GAE(0.95) by Default

The advantage menu is :numref:sec_actorcritic’s dial, measured there; we ship the deployed setting, \lambda = 0.95:

def train_ppo(seed, ac, use_clip=True, trace=None):
    """Freeze the advantages and the collecting policy's log-probs, then
    spend num_epochs surrogate passes; GAE(0.95) is the default."""
    rng, env = np.random.default_rng(seed), gym.make('CartPole-v1')
    env.reset(seed=seed)
    for _ in range(num_updates):
        batch = d2l.rollout(env, ac.act, batch_episodes, rng)
        for _ in range(critic_steps):   # fresh lambda-return target, per pass
            fit_value(ac, batch.obs, batch.gae(ac.value_np, gamma, lam)
                      + ac.value_np(batch.obs))
        adv = d2l.normalize(batch.gae(ac.value_np, gamma, lam))
        logp_old = ac.log_prob_np(batch.obs, batch.act)
        d = ppo_epochs(ac, batch, adv, logp_old, epsilon_clip, num_epochs,
                       entropy_coef, use_clip)
        if trace is not None:
            trace.append(d)
        yield (float(batch.episode_returns().mean()), *d.mean(0), *d[-1])

The Ablation: Eight Seeds, Clip On and Off

Same batches, same twenty passes: half or more of the unclipped seeds die near return 9 (saturation, for real); every clipped seed reaches the ceiling. The insurance pays out on about one ratio check in twenty. Which seeds die reshuffles; the rate is what is stable.

How to Know Your RL Is Broken

entropy: 0.64 over the first five updates, 0.26 over the last five
  • in these runs, KL and band-exits are front-loaded within a batch; the clip stalls the drift
  • across training: entropy decays from about 0.65 to about 0.25 nats; the bonus slows the slide, :numref:sec_regularized explains it

The Batch Goes Stale, Measured

Ratios are importance weights, so the appendix’s effective sample size applies, as a ratio-concentration diagnostic:

after 20 epochs the batch is worth 95% (clipped (PPO)) vs 42% (no clip) of its 136 steps

“Reuse for a few epochs, then stop” as a dial: with the clip the weight spectrum stays nearly flat; without it, concentration to half or less. Weights-only: blind to advantages, dependence, and state-distribution staleness.

From a Teaching Loop to a Real One

  • N \times T rectangle from vectorized environments; the cut edge is priced by V, like every truncation since :numref:sec_mdp
  • minibatch epochs (4 \times 32): the same drift budget in smaller coins
  • plus a list of named details: annealing, normalization, value clip, KL stop, orthogonal init :cite:Huang.Dossa.Raffin.ea.2022

At matched code-level details, TRPO \approx PPO :cite:Engstrom.Ilyas.Santurkar.ea.2020: the details, not the objective, carry much of the edge. Read cleanrl/ppo.py and diff it against this section.

Recap

  • parameter distance \neq policy distance: control the policy
  • change of measure buys reuse; the ratio product explodes; the surrogate is local
  • performance difference lemma: improvement = expected old-advantage under the new policy; one corner, seen twice
  • TRPO: bound + constraint + guarantee; PPO: clip, no guarantee, works
  • GAE(0.95) by default; entropy bonus in the objective
  • watch ratios, KL, entropy, ESS, never the loss