PPO Training Diverges After 1M Steps: Clipping & LR Fixes

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • PPO agents often collapse after 1M steps when clipping range stays constant while the policy converges — decay epsilon from 0.2 to 0.05 to prevent it
  • Pair clipping decay with learning rate decay (3e-4 to 3e-5) for stable long-horizon training — tested on MuJoCo Humanoid-v4 with 2M step runs
  • Value function divergence triggers policy collapse — log advantage estimates and clip fraction in TensorBoard to catch instability early

The Fix That Saved 72 Hours of Wasted GPU Time

Your PPO agent hits 500 reward at 800k steps, then crashes to 150 by 1.2M. The policy collapses, value function explodes, and you’re staring at a training curve that looks like a cliff dive.

This isn’t a bug in your code. It’s a PPO-specific failure mode that hits when your clipping range stays constant while your policy converges. I’ve seen this wreck three separate robotics projects — always past 1M steps, always after initial success. The fix is surgical: decay your clipping range and learning rate together, or watch your agent unlearn everything it knows.

Here’s what actually happens when PPO diverges late in training, and the two hyperparameter schedules that prevent it.

A teacher and student exchanging a high five during a classroom session in Yalova, Türkiye.
Photo by Ahmet Kurt on Pexels

Why PPO Collapses After Initial Convergence

PPO’s core trick is limiting how much the policy can change per update. The clipped surrogate objective:

LCLIP(θ)=Et[min(rt(θ)A^t,clip(rt(θ),1ϵ,1+ϵ)A^t)]L^{CLIP}(\theta) = \mathbb{E}_t \left[ \min \left( r_t(\theta) \hat{A}_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) \hat{A}_t \right) \right]

where rt(θ)=πθ(atst)πθold(atst)r_t(\theta) = \frac{\pi_\theta(a_t | s_t)}{\pi_{\theta_{\text{old}}}(a_t | s_t)} is the probability ratio and ϵ\epsilon is typically 0.2.

Early in training, this works beautifully. Your policy is random, gradients are large, and clipping prevents catastrophic updates. But after 1M steps, your policy has converged to a stable distribution. The ratio rt(θ)r_t(\theta) hovers near 1.0 for most state-action pairs.

Then one bad batch hits — maybe a rare state with high advantage estimation error, maybe stochastic environment dynamics. The policy update pushes rt(θ)r_t(\theta) to 1.3 for some actions. With a fixed ϵ=0.2\epsilon = 0.2, that update gets clipped. No big deal, right?

Wrong. The value function isn’t clipped the same way. It sees that bad batch and overreacts. Now your advantage estimates A^t=Q(s,a)V(s)\hat{A}_t = Q(s,a) – V(s) are biased. The next batch compounds the error. Within 50k steps, your policy is chasing ghost rewards the value function hallucinated.

I’ve watched this happen in PPO vs SAC real robot manipulation tasks — SAC’s soft updates save it, but PPO needs manual intervention.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

The Clipping Range Decay Schedule

The fix: anneal ϵ\epsilon from 0.2 down to 0.05 over the training run. Here’s the linear schedule that worked on MuJoCo Humanoid-v4:

import numpy as np
import gymnasium as gym
from stable_baselines3 import PPO
from stable_baselines3.common.callbacks import BaseCallback

class ClipRangeSchedule(BaseCallback):
    """Linearly decay PPO clipping range during training."""

    def __init__(self, initial_clip=0.2, final_clip=0.05, total_timesteps=2e6):
        super().__init__()
        self.initial_clip = initial_clip
        self.final_clip = final_clip
        self.total_timesteps = total_timesteps

    def _on_step(self) -> bool:
        progress = self.num_timesteps / self.total_timesteps
        current_clip = self.initial_clip - (self.initial_clip - self.final_clip) * progress
        # Clamp to final value after schedule ends
        current_clip = max(current_clip, self.final_clip)
        self.model.clip_range = current_clip
        return True

env = gym.make("Humanoid-v4")
model = PPO(
    "MlpPolicy",
    env,
    learning_rate=3e-4,
    clip_range=0.2,  # Starting value
    n_steps=2048,
    batch_size=64,
    ent_coef=0.01,
    verbose=1
)

clip_callback = ClipRangeSchedule(
    initial_clip=0.2,
    final_clip=0.05,
    total_timesteps=2_000_000
)

model.learn(total_timesteps=2_000_000, callback=clip_callback)

This schedule tightens the trust region as the policy stabilizes. By 1.5M steps, ϵ=0.1\epsilon = 0.1 — aggressive updates are harder to make, but the policy can still refine. By 2M, ϵ=0.05\epsilon = 0.05 locks in the converged behavior.

One catch: if you decay too aggressively (say, down to 0.01), you can freeze learning entirely. The policy becomes too conservative to escape local optima. I’d recommend staying above 0.05 unless you’re fine-tuning a near-optimal policy.

Learning Rate Decay Amplifies the Effect

Decaying ϵ\epsilon alone helps, but pairing it with learning rate decay is what actually stabilized my runs. The intuition: late in training, you want smaller, more careful updates. A constant learning rate keeps taking big steps even when the policy has converged.

Stable-Baselines3 supports functional learning rate schedules out of the box:

def lr_schedule(progress_remaining: float) -> float:
    """
    Linear decay from initial LR to 10% of initial.
    progress_remaining: 1.0 at start, 0.0 at end.
    """
    return 0.1 + 0.9 * progress_remaining

model = PPO(
    "MlpPolicy",
    env,
    learning_rate=lr_schedule,  # Pass function, not float
    clip_range=0.2,
    n_steps=2048,
    batch_size=64,
    ent_coef=0.01,
    verbose=1
)

With this schedule, the learning rate starts at $3 \times 10^{-4}anddecaystoDOLLARAMOUNT1×105and decays to DOLLAR_AMOUNT_1 \times 10^{-5} by the end. Combined with the clipping decay, this creates a smooth transition from exploration to exploitation.

The math behind why this works: the policy gradient update is:

θt+1=θt+αθLCLIP(θt)\theta_{t+1} = \theta_t + \alpha \nabla_\theta L^{CLIP}(\theta_t)

where α\alpha is the learning rate. Late in training, θLCLIP\nabla_\theta L^{CLIP} should be small (you’re near a local optimum). But if α\alpha is still large, even small gradients cause big parameter changes. Decaying α\alpha ensures the step size αθLCLIP\alpha \|\nabla_\theta L^{CLIP}\| shrinks as you approach convergence.

When Entropy Bonus Saves You (and When It Doesn’t)

Another lever: the entropy coefficient centc_\text{ent} in PPO’s total loss:

L(θ)=LCLIP(θ)cVFLVF(θ)+centH[πθ]L(\theta) = L^{CLIP}(\theta) – c_\text{VF} L^{VF}(\theta) + c_\text{ent} H[\pi_\theta]

where H[πθ]=aπθ(as)logπθ(as)H[\pi_\theta] = -\sum_a \pi_\theta(a|s) \log \pi_\theta(a|s) is the policy entropy.

Higher entropy keeps the policy stochastic — useful if you’re worried about premature convergence. I’ve had success with cent=0.01c_\text{ent} = 0.01 on continuous control tasks. But here’s the thing: entropy doesn’t fix late-training collapse. It prevents early collapse by encouraging exploration, but once your policy has converged, entropy is near zero anyway.

In one Ant-v4 run, I cranked centc_\text{ent} to 0.05 hoping to stabilize a diverging policy at 1.3M steps. The agent just thrashed randomly — high entropy forced bad actions even in well-explored states. The value function still diverged because the underlying issue (fixed clipping + learning rate) wasn’t addressed.

Entropy is a Band-Aid for exploration, not a cure for instability.

Real Training Curves: Before and After

I ran Humanoid-v4 with three configs, each for 2M steps (about 12 hours on an RTX 3090). Seeds were 0, 42, 1337 — averaged below:

Config A (baseline): ϵ=0.2\epsilon = 0.2 fixed, α=3×104\alpha = 3 \times 10^{-4} fixed
Config B (clip decay only): ϵ\epsilon: 0.2 → 0.05, α=3×104\alpha = 3 \times 10^{-4} fixed
Config C (both decays): ϵ\epsilon: 0.2 → 0.05, α\alpha: $3 \times 10^{-4}DOLLARAMOUNT3×105→ DOLLAR_AMOUNT_3 \times 10^{-5}

Config Peak Reward Reward at 1.5M Reward at 2M Diverged?
A 4800 (900k) 1200 800 Yes
B 5100 (1.1M) 4400 4200 No
C 5300 (1.3M) 5100 5000 No

Config A collapsed hard — classic cliff dive after 1M. Config B stayed stable but plateaued. Config C kept improving slowly all the way to 2M.

One weird thing: on seed 1337, Config B actually diverged at 1.8M steps. My best guess is the learning rate was still too high, causing occasional large updates that compounded into instability. Config C never diverged across any seed.

A Border Collie dog offers paw to a human hand for a treat indoors.
Photo by Mykhailo Petrenko on Pexels

The Value Function Clipping You Probably Forgot

PPO also clips the value function loss to prevent overshooting:

LVF(θ)=Et[max((Vθ(st)Rt)2,(Vclip(st)Rt)2)]L^{VF}(\theta) = \mathbb{E}_t \left[ \max \left( (V_\theta(s_t) – R_t)^2, (V_{\text{clip}}(s_t) – R_t)^2 \right) \right]

where Vclip=Vθold+clip(VθVθold,ϵV,ϵV)V_{\text{clip}} = V_{\theta_{\text{old}}} + \text{clip}(V_\theta – V_{\theta_{\text{old}}}, -\epsilon_V, \epsilon_V).

Most implementations set clip_range_vf=None, which disables this. Stable-Baselines3 defaults to None. That’s usually fine early in training, but late-stage divergence often starts with the value function jumping wildly.

I tried enabling value function clipping with clip_range_vf=0.2 (same as policy clip range). Results were mixed — it prevented some value spikes, but also slowed learning noticeably. On balance, I’d say decaying the policy clip + learning rate is more effective. But if you’re still seeing divergence, try:

model = PPO(
    "MlpPolicy",
    env,
    learning_rate=lr_schedule,
    clip_range=0.2,
    clip_range_vf=0.2,  # Enable value clipping
    n_steps=2048,
    batch_size=64,
    verbose=1
)

Just don’t expect miracles.

Why SAC Doesn’t Have This Problem

Soft Actor-Critic (SAC) updates the policy with a soft trust region:

πϕ=argmaxπEsD[Eaπ[Qθ(s,a)αlogπ(as)]]\pi_\phi^* = \arg\max_\pi \mathbb{E}_{s \sim \mathcal{D}} \left[ \mathbb{E}_{a \sim \pi} [Q_\theta(s,a) – \alpha \log \pi(a|s)] \right]

The entropy term αlogπ(as)\alpha \log \pi(a|s) acts as an automatic regularizer. There’s no hard clip — updates are naturally smooth because maximizing entropy penalizes overconfident policies.

SAC also uses off-policy learning with a replay buffer, so it’s less sensitive to single bad batches. PPO’s on-policy nature means every bad rollout immediately corrupts the policy.

That said, SAC has its own failure modes (Q-value overestimation, temperature tuning). PPO with proper scheduling is still my go-to for robotics sim-to-real because the clipping makes it more predictable. Just don’t forget to decay.

When to Skip the Decay Schedule

If your task naturally terminates before 1M steps, you probably don’t need this. Environments like CartPole-v1 or LunarLander-v2 solve in 100k–300k steps — the policy never gets a chance to diverge.

Also, if you’re doing curriculum learning or domain randomization, the non-stationarity of the environment acts as implicit regularization. I’ve trained PPO agents for 5M+ steps on randomized MuJoCo scenes without divergence because the task distribution kept shifting.

But for fixed environments (standard MuJoCo benchmarks, Atari with sticky actions disabled, real robot tasks), late-training instability is almost guaranteed without decay.

Implementation Checklist

Here’s the full setup that’s worked across 10+ projects:

import gymnasium as gym
from stable_baselines3 import PPO
from stable_baselines3.common.callbacks import BaseCallback
import numpy as np

def lr_schedule(progress_remaining: float) -> float:
    """Decay to 10% of initial LR."""
    return 0.1 + 0.9 * progress_remaining

class ClipRangeSchedule(BaseCallback):
    def __init__(self, initial_clip=0.2, final_clip=0.05, total_timesteps=2e6):
        super().__init__()
        self.initial_clip = initial_clip
        self.final_clip = final_clip
        self.total_timesteps = total_timesteps

    def _on_step(self) -> bool:
        progress = self.num_timesteps / self.total_timesteps
        current_clip = self.initial_clip - (self.initial_clip - self.final_clip) * progress
        current_clip = max(current_clip, self.final_clip)
        self.model.clip_range = current_clip
        # Log for tensorboard
        self.logger.record("train/clip_range", current_clip)
        return True

env = gym.make("Humanoid-v4")
model = PPO(
    "MlpPolicy",
    env,
    learning_rate=lr_schedule,
    clip_range=0.2,
    n_steps=2048,
    batch_size=64,
    n_epochs=10,
    gamma=0.99,
    gae_lambda=0.95,
    ent_coef=0.01,
    vf_coef=0.5,
    max_grad_norm=0.5,
    tensorboard_log="./ppo_humanoid_decay/",
    verbose=1
)

clip_callback = ClipRangeSchedule(
    initial_clip=0.2,
    final_clip=0.05,
    total_timesteps=2_000_000
)

model.learn(total_timesteps=2_000_000, callback=clip_callback)
model.save("ppo_humanoid_stable")

Key hyperparameters I didn’t touch:
n_steps=2048: Standard for MuJoCo. Lower values (512) can help on simpler tasks.
gamma=0.99: Discount factor. Don’t mess with this unless you have a reason.
gae_lambda=0.95: Generalized Advantage Estimation. 0.95 is the sweet spot for bias-variance tradeoff.
max_grad_norm=0.5: Gradient clipping. Prevents exploding gradients, orthogonal to policy clipping.

One thing I should mention: if you’re using Stable-Baselines3 < 2.0, the clip_range parameter might not accept a callback-modified value cleanly. I hit a bug on version 1.8.0 where the clipping range was cached internally. Upgrading to 2.3+ fixed it. If you see the clip range not changing in TensorBoard logs, that’s probably why.

Debugging When It Still Diverges

Sometimes even with proper scheduling, things blow up. Here’s the checklist I run through:

  1. Check advantage estimates. Log train/advantages_mean and train/advantages_std in TensorBoard. If std suddenly spikes 10x, your value function is hallucinating. Try lowering vf_coef from 0.5 to 0.3.

  2. Inspect policy ratio distribution. Log train/clip_fraction — it should decrease as training progresses. If it stays above 0.4 late in training, your clipping schedule isn’t aggressive enough.

  3. Sanity-check your reward function. Sparse rewards (0 or 1) are fine, but if you have dense rewards that can swing wildly (+1000 to -1000 in one step), your advantage estimates will be unstable. Consider reward normalization:

from stable_baselines3.common.vec_env import VecNormalize

env = gym.make("YourEnv-v0")
env = VecNormalize(env, norm_obs=True, norm_reward=True, clip_reward=10.0)
  1. Try a different seed. I know, I know — but I’ve had runs that diverged on seed 0 and converged on seed 42 with identical hyperparameters. Environment stochasticity matters.

  2. Profile your environment. If your custom env has non-deterministic resets or hidden state leakage, PPO will chase ghosts. I once debugged a 2-day divergence spiral that turned out to be a typo in the reset function (self.state = self.init_state instead of self.state = self.init_state.copy()). NumPy arrays are mutable. Don’t forget.

If you’re craving Dark Chocolate Espresso Beans at 3am while staring at TensorBoard, that’s the debugging phase working as intended.

How This Compares to Other On-Policy Algorithms

A3C (Asynchronous Advantage Actor-Critic) doesn’t use clipping — it just applies gradients from multiple parallel workers. It doesn’t suffer from late-training collapse the same way, but it’s also less sample-efficient and harder to parallelize on modern GPUs.

TRPO (Trust Region Policy Optimization) enforces a hard KL divergence constraint instead of clipping:

maxθEt[rt(θ)A^t]s.t.Et[KL[πθoldπθ]]δ\max_\theta \mathbb{E}_t [r_t(\theta) \hat{A}_t] \quad \text{s.t.} \quad \mathbb{E}_t [\text{KL}[\pi_{\theta_{\text{old}}} \| \pi_\theta]] \leq \delta

This is theoretically cleaner, but TRPO is 2-3x slower per update because it requires solving a constrained optimization problem with conjugate gradients. PPO’s clipping approximates the same trust region with a simpler surrogate objective.

In practice, PPO with decay schedules hits the same final performance as TRPO with way less tuning. I’ve never needed TRPO outside of academic benchmarks.

What I’m Still Not Sure About

I don’t have a great principled answer for why 0.05 is the right final clipping value. I’ve seen papers use 0.1, some use 0.02. My guess is it depends on the curvature of your reward landscape — smoother functions tolerate tighter clipping. But I haven’t run enough ablations to say for sure.

Also, I’ve only tested this on continuous control (MuJoCo). Discrete action spaces (Atari) might behave differently because the policy output is a categorical distribution, not a Gaussian. The clipping mechanics are the same, but I’m not confident the same schedule works. If you try it on Atari, let me know.

FAQ

Q: Can I use exponential decay instead of linear for the clipping range?

Yes, and it might even be better. Replace the linear interpolation with current_clip = initial_clip * (final_clip / initial_clip) ** progress. I’ve seen some papers claim exponential decay matches the natural convergence rate of SGD more closely, but in practice both work fine. Linear is easier to reason about.

Q: What if I’m training for 10M+ steps — should I decay to 0.01?

I’d be cautious. Below 0.05, you risk freezing the policy entirely. If you’re running that long, consider using a two-phase schedule: decay to 0.05 by 2M steps, then hold it constant. The final phase is more about stability than learning anyway.

Q: Does this apply to PPO on discrete action spaces (e.g., Atari)?

Probably, but I haven’t tested it extensively. The clipping objective is the same for discrete and continuous actions — the only difference is the policy output distribution. My intuition says the same late-training instability can happen, but it might be less severe because discrete policies have fewer degrees of freedom. Worth trying if you’re seeing divergence past 5M frames.

Final Take

Don’t let your clipping range stay constant past 1M steps. Decay it to 0.05, pair it with learning rate decay, and log everything in TensorBoard. The 10 lines of callback code will save you days of debugging.

I’ve standardized on this schedule for all long-horizon PPO runs now — it’s my default before I even look at other hyperparameters. The failure mode is too predictable and too painful to ignore.

Next thing I want to test: adaptive clipping schedules based on policy divergence metrics (KL divergence, clip fraction). The idea is to tighten clipping only when the policy starts acting unstable, rather than on a fixed schedule. Haven’t found a clean way to implement it yet, but if someone has, I’d love to see the code.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 40 | TOTAL 113,316