← Aung Kaung Myat/WalkthroughsRepository ↗
CPU-only robot-learning track · secondary project · repo ppo-from-scratch

PPO Against LQR

Reinforcement learning, implemented from nothing, and then measured against a controller you can solve for with pen and paper. Both balance the pole perfectly. The learned one paid 62 144 environment steps for that, and its recoverable region is half the size — a difference the usual score cannot see at all.

PyTorch (CPU)NumPyMuJoCo no gymnasiumno stable-baselines3 no scipyno cleanrl
Environment
hand-written, 13/13 checks
Baseline
LQR · 0 samples
PPO cost
62 144 steps · 16/16 seeds
One training run
11.4 s
Seeds per result
16
Reward curves shown
zero, on purpose
01

In plain words

Two controllers for the same cart-pole: one solved with linear algebra, one learned from nothing.

Cart-pole is the "hello world" of reinforcement learning: a pole hinged on a cart, and a controller that can push the cart left or right. Let it go and the pole falls. Keep it upright for 500 steps and you have "solved" the task.

Almost every tutorial trains a policy on it, plots a reward curve that goes up, and stops. This project asks a different question, and the difference is the whole point:

The question

What does a learned policy actually buy over a controller you can solve for in closed form — and what does it cost in samples?

Because cart-pole has an answer that does not require learning at all. Linearise the physics around upright, solve one matrix equation, and out comes an optimal linear feedback controller. It consumes zero environment steps. It scores a perfect 500.0 out of 500 on the first try, and on every try after that.

So the learned policy cannot beat the baseline on the usual metric. It can only match it. That framing forces two better questions, which is what the project measures: how many samples does matching cost, and is there any axis on which the two controllers actually differ?

Nothing is imported

No gymnasium, no stable-baselines3, no rllib, no cleanrl copy-paste, no scipy. The environment is hand-written from the 1983 paper's equations. The discrete Riccati equation is solved by iterating the recursion rather than calling a library, because understanding that recursion is the point — it is the exact same object PPO's critic estimates by regression, except here it can be computed exactly.

Terms used on this page

cart-poleA pole hinged on a cart. Push the cart left or right to keep the pole upright. The standard first control problem.
LQRLinear-quadratic regulator — the optimal controller for a linearised system, solved with matrix algebra. No learning involved.
PPOProximal policy optimisation — a reinforcement-learning algorithm that limits how far each update is allowed to move the policy.
GAEGeneralised advantage estimation — how the algorithm estimates whether an action was better or worse than average.
episode / returnOne run from reset until failure or the time limit. The return is the total reward collected in it.
terminated vs truncatedTerminated means the pole actually fell. Truncated means the clock ran out while it was still up. Treating them the same corrupts the value estimate.
seedThe random-number starting point. One seed proves nothing, so results here are reported across sixteen.
basin of attractionThe set of starting states a controller can still recover from. Wider is better.
02

The six steps

The six build steps, and what each had to produce before the next could start.

step 1The environment Cart-pole written from the published equations, verified against an independent solve of the Lagrangian. 13/13 checks
step 2LQR baseline Linearise, iterate the Riccati recursion, project to two actions. Zero sample cost by construction. 7/7 checks · 500.0
step 3PPO from scratch GAE, advantage normalisation, the clipped surrogate, multi-epoch minibatching — each switchable. 358 lines · 11.4 s/run
step 4Seed study 16 seeds, median and interquartile range, steps-to-threshold with censoring handled honestly. 16/16 · 62 144 steps
step 5Ablations One switch off at a time, 16 seeds each, permutation test for significance. clipping dominates
step 6Continuous actions Gaussian policy on a MuJoCo plant proved identical to the numpy one. Does the ranking survive? 16 seeds + 3 ablations
03

Every file, and what it is for

Every source file, how big it is, and the single job it owns.

FileLinesWhat it does
cartpole.py209 The plant and the environment, numpy only. The four physical parameters, the closed-form accelerations, the explicit-Euler step, energy and momentum functions used by the checks, and an environment class with separate terminated and truncated flags and its own random generator.
verify_env.py456 Thirteen checks in about six seconds on one core. Independent Lagrangian solve, energy and momentum conservation under RK4, the closed-form divergence rate, every termination and truncation contract, determinism, RNG isolation, the published random-policy return, and a golden-trajectory regression fixture.
lqr.py496 The baseline. Analytic linearisation, finite-difference Jacobian cross-checks, exact discretisation, the Riccati recursion, the gain, the sign projection to two actions, seven verification checks, the basin bisection, and the Q-sensitivity sweep.
lqr_baseline.json The baseline's numbers, written out so later steps compare against a file rather than a memory.
ppo.py360 PPO itself. Separate 64-64 tanh actor and critic, orthogonal initialisation, GAE, the clipped surrogate, multi-epoch minibatch updates, the truncation bootstrap, and command-line switches that turn each of the four PPO ingredients off individually.
ppo_continuous.py305 The Gaussian-policy version. Imports compute_gae and layer_init from ppo.py rather than copying them, so the two implementations cannot drift apart. Only the head changes.
mj_cartpole.py291 The MuJoCo environment for step 6, plus six verification checks — including the one that proves the two simulators describe the same rigid body to 1.4e−14.
study.py277 The experiment driver. Seed lists (0–15 for reporting, 100–103 for hyperparameter search, kept disjoint), parallel launching, censoring, and a permutation test written without scipy.
compare.py148 LQR against all sixteen trained checkpoints on the axis that can actually separate them: the basin of attraction, by bisection, plus a widened-initial-condition sweep.
visualize.py172 Watch it. MuJoCo is used as a renderer onlymj_forward for kinematics, never mj_step — and the numpy plant supplies the state. Letting MuJoCo integrate would silently be a different plant.
boxcheck.py49 Refuses to start training above a 1-minute load average of 4.0, and prints the offending process. Called by every entry point that trains, not just the study driver — for a reason recorded below.
cartpole.xml · cartpole_mj.xml42 · 60 Two MJCF models. The first is for visualisation only. The second sets inertias by hand (inertiafromgeom="false") so its rigid body is provably identical to the numpy one.
golden_traj.npz 201 frozen states. A regression fixture, not a correctness proof — it stops a refactor during steps 3–5 from moving the environment silently.
runs/ · runs_c/ Checkpoints and per-run JSON for the discrete and continuous studies.
04

Step 1 — the environment, and why it is hand-written

Why the environment is hand-written rather than imported, and how it is checked against a saved trajectory.

The reason

PPO will fail to learn several times before step 5 is finished. Every time it does, the first question is algorithm or environment. That question is only cheap to answer if the environment was verified before any reinforcement learning existed. This one was.

The dynamics

Barto, Sutton & Anderson (1983), appendix. θ is measured from upright; positive θ leans the pole toward +x.

temp      = (F + m·l·θ̇²·sin θ) / (M + m)
θ̈         = (g·sin θ − cos θ · temp) / (l·(4/3 − m·cos²θ/(M+m)))
ẍ         = temp − m·l·θ̈·cos θ / (M + m)

Two conventions hide inside those three lines and both are classic bug sources. l = 0.5 is the half-length, not the full pole length. And the 4/3 is 1 + I_cm/(m·l²) for a uniform rod, whose moment of inertia about its own centre is m(2l)²/12 = m·l²/3. Getting the half-length convention wrong is the most common bug in a hand-written cart-pole, so one check exists purely to catch it.

Thirteen checks

#CheckResult
1Scalar formulas against an independent solve of the Lagrangian mass matrix, 10 000 random states, |θ| up to πmax rel diff 2.6e−15
2aEnergy conserved under RK4, F = 0, 2 s at dt = 1e−42.4e−14
2bHorizontal momentum conserved, same run5.4e−15
3Pinned cart, divergence rate against the closed form √(3g/4l)3.834058 vs 3.834058, rel err 3.4e−11
4a–dTruncation fires exactly at the cap with terminated false; stepping a finished episode raises; reward is 1.0 on every step including the terminal one; the bounds are |x|>2.4 and |θ|>12°exact
4eAction sign: bang-bang on θ + 0.5·θ̇ balances500.0 / 500
5a–bSame seed gives a bitwise identical trajectory; the environment ignores the global numpy RNGexact
6Random-policy mean return against the published ≈22, over 10 000 episodes22.09 ± 0.12
7Golden-trajectory regression, 201 statesbitwise identical

Check 1 is the strong one. The mass matrix is assembled from the Lagrangian and inverted with np.linalg.solve — it shares no algebra with the hand-eliminated formulas the environment uses, so agreement to 2.6e−15 rules out sign errors and bad elimination. Check 3 reaches outside the code entirely: √(3g/4l) = 3.834058 s⁻¹ is a number you can derive on paper, and the simulator reproduces it to eleven digits.

What none of this proves

gymnasium is not installed, so there is no bit-diff against the reference implementation. Checks 1–3 prove the equations are the published ones and internally consistent; check 6 is the only externally published number reproduced. Check 7 is a regression fixture, not a correctness proof.

Three decisions worth arguing with

explicit Euler
Position is advanced with the old velocity. Semi-implicit Euler is the better integrator and is deliberately not used, because the deliverable is a number ("475/500") that is only comparable to published results if the plant is the same plant. The cost is measured rather than hidden: after 25 controlled steps the two integrators differ by 7.8e−3 rad, which is 3.7 % of the failure angle.
no combined done
terminated and truncated are kept separate and the environment refuses to expose a merged flag. Collapsing them makes the value target at the 500-step cap treat a perfectly healthy upright state as worth zero future return — a real bug worth roughly 100 points of final performance.
no vectorised environment
The plan assumed the Python-loop environment would be the bottleneck. Measured: 374 000 steps/s, so a 200 k-step run spends 0.5 s total inside the environment — about 1 % of the budget. It was never written, and step 3 confirmed it was never needed.
A comparison that was deliberately not reported

A full-episode Euler-vs-RK4 comparison is absent, and check 3 is the reason: at λ = 3.834 s⁻¹, any difference between two integrators is amplified by e³⁸ ≈ 3e16 over a ten-second episode. That number would be about Lyapunov exponents, not about integrators. An early draft reported it anyway and got max |Δθ| = 12.9 rad, which is how the mistake was caught.

Complete list of deviations from the standard CartPole-v1: observations are float64 not float32; stepping a finished episode raises rather than warning; the two done flags are never merged. Everything else — constants, equations, integrator and its ordering, initial distribution, thresholds, reward, 500-step cap — matches.

05

Step 2 — the baseline that costs nothing

LQR from first principles: linearisation, discretisation, the Riccati recursion, and what it costs to run.

What LQR is

Linear-Quadratic Regulator. If a system is linear and the cost you want to minimise is quadratic, the optimal controller is a matrix multiplication: u = −Kx. The gain K is computed from the model, not learned from data. Cart-pole is not linear, but near upright it is close enough — so linearise, solve, and use the result.

Linearising

Dropping second-order terms about the origin, with D = l(4/3 − m/M):

θ̈ = (g/D)·θ − (1/(M·D))·F
ẍ = F/M − (m·l/M)·θ̈

Ac[3,2] = g/D = 15.7756 s⁻², and √15.7756 = 3.9719 s⁻¹ is the growth rate of the free cart-pole — slightly faster than the pinned-cart 3.834058 s⁻¹ measured in step 1, because a free cart lets the base slide out from under the pole. Two related numbers, both derivable, both matching.

Discretisation is exact here — and that is the payoff for step 1's integrator choice

Every acceleration in euler_step() is evaluated at the old state, so the plant genuinely is s' = s + τ·f(s,u) and therefore

Ad = I + τ·Ac        Bd = τ·Bc

with no approximation at all. A semi-implicit or exact zero-order-hold plant would need a matrix exponential here. It was verified against a finite-difference Jacobian of the actual step function anyway: max absolute difference 7.57e−14.

Riccati by iteration, not by library call

P_{k+1} = Q + Adᵀ P_k Ad − Adᵀ P_k Bd (R + Bdᵀ P_k Bd)⁻¹ Bdᵀ P_k Ad,   P₀ = Q
K       = (R + Bdᵀ P Bd)⁻¹ Bdᵀ P Ad,      u = −Kx

P_k is the cost-to-go matrix with k steps of horizon remaining, so this is literally value iteration for a quadratic value function. It is the same object PPO's critic estimates by regression — the difference is that here it can be computed exactly, which is the whole reason to write the recursion out longhand.

Converged in 866 sweeps / 22 ms. The theory says the convergence rate is ρ(Ad−BdK)² = 0.96810 per sweep, predicting about 923 sweeps to reach 1e−13 relative. Measured 866. That agreement is the check that the recursion is the textbook one and not something that merely happens to converge.

K = [−0.910126, −2.132488, −30.594563, −7.841506]
ρ(Ad − Bd·K) = 0.983919

Seven checks

#CheckResult
L1Analytic Ac, Bc against a central-difference Jacobian of the derivative3.78e−12
L2Ad = I + τ·Ac against a finite-difference Jacobian of the actual step7.57e−14
L3Converged P satisfies the discrete algebraic Riccati equationresidual 6.63e−10
L4Closed-loop spectral radius below 10.983919
L5xᵀPx against the simulated linear closed-loop cost-to-gomax rel 5.46e−12
L6Scaling both Q and R by c leaves the gain unchanged, c ∈ [1e−3, 1e3]5.81e−16
L7Mean return ≥ 475 over 100 consecutive episodes500.0 (min 500, max 500)

L5 is the one that matters most: it checks P against something P was not used to compute. Roll the linear closed loop forward, accumulate xᵀQx + uᵀRu, and compare the total to x₀ᵀPx₀. Agreement to 5e−12 means the converged matrix really is the cost-to-go and not just a fixed point of some recursion that was typed in.

Bridging a continuous controller to two discrete actions

The environment offers ±10 N. LQR returns a real number. The bridge is a design decision, not a detail:

action = 1 if −Kx > 0 else 0

Bang-bang control with an LQR-derived switching surface. Only the sign of −Kx reaches the plant, so the policy is invariant to any positive rescaling of K, and therefore to scaling the whole cost — which is exactly what check L6 confirms.

The number PPO has to justify itself against

Baseline performance 500.00

Mean return over 1000 episodes. Zero failures. Metric fully saturated.

Baseline sample cost 0 steps

The design consumed no environment interaction at all. Steps-to-threshold is 0 by construction.

The framing this step delivers

PPO cannot beat this. The metric is saturated. The most it can do is match it, and the only honest axes left are sample cost — where LQR wins by construction — and everything the LQR does not have, which is independence from the model.

Return is a saturating metric — measured, not asserted

An early claim in the repository was that the cost weight R cannot matter because it only rescales K. That is false — the Riccati equation is not linear in R — and sweeping R from 0.01 to 100 rotates the normalised gain direction by 0.130. The check caught it. The interesting part is what happened next: that rotation moved the measured return by 0.0. Every one of those controllers scores 500.0.

So return cannot rank controllers on this task once they are all merely good enough. Steps 4 and 5 therefore rank on steps-to-threshold, and this repository does not contain a single reward curve.

What can tell controllers apart is the basin — how large a disturbance they can recover from — found by bisecting on the initial state one axis at a time:

Perturbation from uprightBang-bang (±10 N only)Continuous force, clipped to ±10 N
critical θ̇₀2.1693 rad/s1.9632 rad/s
critical ẋ₀2.4354 m/s1.9593 m/s
critical θ₀0.2094 rad = the termination limit0.2094 rad

Bang-bang has the larger basin than the same law with a proportional actuator of equal strength. Not a paradox: recovering from a large disturbance is a time-optimal problem, and the time-optimal solution to a bounded-input problem is bang-bang. The linear law under-commands near the edge because it is minimising a quadratic cost, not maximising survival. Angle finds nothing — every angle inside the termination limit is recoverable, which is why the sweep runs on velocity.

Q sensitivity — and the row that shows what return hides

QSweepsρReturnCritical θ̇₀Max |x|
I₄8660.9839500.02.17 rad/s0.136 m
diag(1,1,10,10) pole-weighted8870.9842500.02.14 rad/s0.143 m
diag(10,10,1,1) cart-weighted6640.9786500.01.89 rad/s0.081 m
diag(0,0,1,1) pole only2401.0000500.00.40 rad/s0.245 m

The last row is the interesting one. With no cost on cart position or velocity the cart mode is uncontrolled: ρ = 1.0000 exactly, the closed loop is only marginally stable, and the cart drifts. It still scores 500.0, because 500 steps is not long enough to drift 2.4 m. Return says these four designs are identical. The spectral radius and the basin say the fourth is broken.

A bug worth writing down

The Riccati loop originally used an absolute tolerance of 1e−14. P has entries near 7e3, so machine epsilon times 7e3 is about 1.5e−12 — the tolerance was below the float64 resolution of P itself and could only ever be met by luck. Q = I₄ landed on an exact fixed point and converged; Q = diag(10,10,1,1) chattered in the last bit for 200 000 sweeps and looked exactly like a stabilisability failure. It was not. The general lesson, which comes back in step 3: an absolute tolerance on a quantity whose scale you have not measured is not a convergence criterion.

06

Step 3 — PPO, written out

PPO written out in full — GAE, the clipped objective, and the truncation bootstrap that is easy to get wrong.

The four ingredients

PPO is policy gradient plus four specific additions. Each is written longhand here, and each can be switched off from the command line — which is what step 5 measures.

GAE(γ, λ)
Generalised advantage estimation. "How much better was this action than average?" can be estimated from one step (low variance, biased) or from the whole episode (unbiased, high variance). GAE interpolates between them with λ.
advantage normalisation
Standardise the advantages within each batch so the gradient scale does not depend on how large the rewards happen to be.
the clipped surrogate
The core of PPO. The update is weighted by the ratio of new to old action probability; clipping that ratio to [1−ε, 1+ε] removes the incentive to move the policy far in one update.
multi-epoch minibatching
Reuse each batch of experience for several optimisation epochs instead of throwing it away — which is only legal because of the clip.

Architecture: separate 64-64 tanh actor and critic trunks, orthogonal initialisation (gain √2, 0.01 on the policy head, 1.0 on the value head), Adam with eps = 1e−5, linear learning-rate anneal, gradient-norm clipping at 0.5, and 8 environments × 128 steps = 1024-step batches.

The truncation bootstrap — what step 1 was for

At the 500-step cap the pole has not fallen. The episode was cut by the clock. Bootstrapping V(s_final) there is correct; treating it as terminal teaches the critic that surviving 500 steps is worth zero future return. That is the single most common silent bug in a hand-written PPO, and it is only avoidable here because step 1 refused to merge the two done flags.

Implemented by folding the bootstrap into the reward of the truncated step, r_t += γ·V(s_final), and then treating that step as an episode boundary for GAE — which it is, since the next observation comes from a fresh reset.

Hyperparameters were searched — on seeds that are not the reported ones

The first configuration written down (lr 3e−4, 4 epochs) never reached threshold in 150 k steps, and reported a clip fraction of 0.000 on every single update: the policy was moving so little per update that the clip never engaged. That is worth stating rather than quietly deleting, because a PPO whose clip never engages is not PPO — it is A2C with extra arithmetic, and it would have made the step-5 clipping ablation come out as "makes no difference".

The search ran on seeds 100–103, disjoint from the study seeds 0–15.

ConfigurationSolvedSteps-to-threshold, medianIQR
lr 2e−3, 10 epochs — adopted4/463 028[62 020, 63 428]
lr 1e−3, 8 epochs, 8 minibatches4/466 520[65 076, 69 772]
lr 1.5e−3, 10 epochs4/473 412[68 910, 78 664]
lr 1e−3, 10 epochs4/476 104[73 346, 85 050]
lr 2.5e−3, 4 epochs3/4112 104[83 832, 141 888]
lr 3e−4, 10 epochs2/4148 808
lr 1e−3, 4 epochs1/4>150 000
lr 3e−4, 4 epochs — first attempt0/4>150 000

With the adopted configuration the clip fraction runs at 0.03–0.05 instead of 0.000.

Cost, and a prediction confirmed

one 150 k-step run
11.4 s sequential — 13 187 environment steps per second
64 runs
344 s at four-way parallel, nice 10
threads
torch.set_num_threads(1), deliberately. The networks are 64×64; intra-op threading costs more in synchronisation than it saves, and this machine shares eight threads with a detection-training job that has priority.

Step 1 predicted the environment would not be the bottleneck — 374 k steps/s standalone against 13 k inside training. Confirmed: the environment is about 3 % of the wall clock. The vectorised rewrite was never needed and was never written.

07

Steps 4 and 5 — seeds and ablations

Sixteen seeds, four ablations, and a permutation test written without scipy.

The seed study

Baseline PPOLQR
solved (≥475 over 100 consecutive episodes)16/16yes
steps-to-threshold, median62 1440
steps-to-threshold, IQR[60 442, 63 448]
greedy evaluation, 100 episodes, median500.0500.0
greedy evaluation, worst seed500.0
The honest headline

PPO matches the baseline and pays 62 144 environment steps for what the model gave away free. That is not an argument against PPO — it is a precise statement of what PPO is buying, which is independence from the model, and of what that costs on a plant where the model happens to be exact.

Censoring. A run that never reaches threshold has no steps-to-threshold. Dropping those and taking the median of the survivors is the standard way to make a bad configuration look good, so they are entered as budget + 1 instead. The median is then correct whenever fewer than half the runs fail, and reports as >150000 when more than half do. Solved counts are printed next to every median.

Ablations — one switch at a time, 16 seeds each

AblationSolvedSteps medianvs baselinePermutation p
baseline16/1662 1441.00×
no advantage normalisation16/1664 8841.04×0.054
no GAE (λ = 1)16/1668 9481.11×0.0006
no ratio clipping8/16145 3682.34×0.0001

Ratio clipping is the only one that actually matters, and it matters by a lot. Half the seeds never reach threshold without it. GAE is worth 11 % of the sample budget here, and advantage normalisation is not distinguishable from noise at the 5 % level on this task.

That ordering is task-specific and the reason is visible in the setup. Cart-pole episodes are short and γ = 0.99 over 128-step rollouts, so λ = 1 costs much less variance than it would on a long-horizon task. Clipping, by contrast, is what makes ten epochs on the same batch legal at all — without it the objective is a correct policy gradient for the first epoch and an increasingly wrong one for the next nine.

"no GAE" = λ 1.0
GAE(γ, 1) is the plain Monte-Carlo advantage. Implementing the ablation as a single number change means the comparison is about GAE rather than about two different implementations.
significance
Steps-to-threshold is right-skewed and censored, so a t-test is the wrong tool. A two-sided permutation test on the difference of medians is used instead — exhaustive when the number of splits allows, 100 000 Monte-Carlo resamples otherwise. It assumes only exchangeability under the null, which is exactly what "this ablation changed nothing" means. Written without scipy.
Why the seed count is 16 and not 8

The study ran at eight seeds first. It gave: no GAE 1.03×, no advantage normalisation 1.11×. At sixteen: no GAE 1.11×, no advantage normalisation 1.04×. The ranking of the two small ablations is exactly reversed. Both runs were honest, both used the same code, and eight seeds — already above the usual "at least five" — was enough to get the order backwards. The conclusion that survived was only the large one. Small effects need either more seeds or an explicit statement that they were not resolved, and the p-values are reported so a reader can see which is which.

08

LQR against PPO, where it counts

Where the learned policy beats the optimal linear controller, and where it plainly does not.

Both controllers score 500.0/500. Step 2 already showed return saturates and cannot rank anything competent, so the comparison runs on the basin instead — the same bisection and the same widened initial-condition sweep, same plant, same termination test, LQR against all sixteen trained checkpoints.

LQRPPO, median over 16 seedsPPO best seed
nominal return, 100 episodes500.0500.0500.0
critical θ̇₀2.169 rad/s1.082 rad/s2.004 rad/s
critical ẋ₀2.435 m/s0.824 m/s1.935 m/s
Failures out of 200 episodes, initial velocities widenedLQRPPO medianPPO worst seed
U(−0.5, 0.5)0122
U(−1.0, 1.0)04883
U(−1.5, 1.5)688115
U(−2.0, 2.0)28116138
U(−2.5, 2.5)61140158
The result worth keeping from the whole project

PPO's basin is roughly half the LQR's on angular rate and a third on cart velocity, at identical nominal return. It learned the initial-state distribution it was trained on — U(−0.05, 0.05) on every state — and nothing outside it, because nothing outside it was ever sampled and the reward never asked. A policy that holds the nominal task with a small basin is a policy that falls over the first time a real plant hands it a disturbance the training distribution did not contain. Fixing that is what domain randomisation is for, and this measurement is the thing domain randomisation would have to move.

The same finding, watchable in three windows

Same disturbance, applied from the exact origin, no random initialisation:

python visualize.py --controller lqr --zero-init --theta-dot0 1.5
python visualize.py --controller ppo --ckpt runs/study/baseline_seed2.pt --zero-init --theta-dot0 1.5
python visualize.py --controller ppo --ckpt runs/study/baseline_seed0.pt --zero-init --theta-dot0 1.5
ControllerOutcome at θ̇₀ = 1.5 rad/sPeak |θ|
LQRsurvives 500 steps46 % of the limit
PPO seed 2falls at step 18 (0.36 s)113 %
PPO seed 0survives 500 steps52 %

Both findings in one experiment: the learned policy is worse than the model-based one at the median, and the spread across seeds is enormous — critical θ̇₀ is 2.004 rad/s for seed 0 and 0.831 rad/s for seed 2, from identical hyperparameters and an identical step budget. All three score 500.0 on the nominal task and are indistinguishable by return.

--zero-init matters. Without it the random ±0.05 initialisation is still present, and a negative θ₀ paired with a positive θ̇₀ is the pole rotating back towards upright — easier, not harder. Every basin number is bisected from the origin, so reproducing them needs the flag. And baseline_seed0 is the best of the sixteen, not a typical one; it is the default checkpoint only because it is the first.

09

Step 6 — continuous actions on MuJoCo

The same algorithm on continuous actions and MuJoCo physics, and the saturation problem that exposed.

Two things change from steps 1–5 at once — the physics engine and the action space — so both are pinned down separately before anything is concluded.

The plant is identical, verified to 1.4e−14

cartpole_mj.xml sets the inertias by hand rather than letting MuJoCo derive them from a geometry primitive. A capsule adds hemispherical caps and a cylinder adds a 3r²/12 term; the 4/3 in the numpy plant is exactly the uniform-thin-rod assumption. Deriving inertia from the geometry would have made the two plants nearly the same object, which is the worst possible state to be in.

Check V1 compares MuJoCo's qacc against the numpy accelerations over 5000 random states: max absolute difference 1.42e−14. The two simulators describe the same rigid body. Anything that differs from here on is the integrator or the action space, and cannot be blamed on the model.

The integrator is different, and that is not reconcilable

MuJoCo's Euler is semi-implicit: velocity first, then position advanced with the new velocity. The numpy plant uses the explicit ordering because that is what the reference cart-pole does.

Energy drift, zero control, θ₀ = 0.5 radat 50 stepsat 500 steps
MuJoCo semi-implicit+6.69 %+9.85 %
explicit Euler (step 1)+0.43 %+145.0 %

Semi-implicit Euler is worse in the short run and dramatically better in the long run: its energy error oscillates with bounded amplitude, while the explicit version's grows without bound. Neither is "the correct plant" — they are two discretisations of the same continuous system, which is why step 6 gets its own baseline instead of borrowing step 4's numbers. One-step gap between them: median |Δθ| 5.84e−3 rad, 3.35 % of the failure angle.

The step-2 gain transfers unchanged — the smallest honest sim-to-real argument here

Step-2 gain K applied to the MuJoCo plantMean return, 100 episodes
bang-bang, sign only, ±10 N500.0 (min 500)
continuous, clipped to ±10 N500.0 (min 500)

The gain was computed against the numpy plant, in step 2, before this file existed. A model-based design survived being moved to a different integrator at zero cost, because it depends on the plant and not on the discretisation. That is the one thing the learned policy in step 4 was never asked to do.

The Gaussian policy, and a bias that is reported rather than hidden

Only the head changes: Categorical(logits) becomes Normal(μ(s), exp(log σ)) with log σ a free parameter, log-probability summed over action dimensions, and Gaussian differential entropy. The entropy coefficient defaults to 0.0 here against 0.01 in the discrete version, because Gaussian differential entropy is unbounded below and can go negative, so an entropy bonus pushes σ up with no floor — the same coefficient does not mean the same thing in the two files.

Action clipping is biased, and the size of the bias is measured

The Gaussian has support on all of ℝ; the actuator saturates at ±1. The action is clipped at the environment boundary while the log-probability is computed on the unclipped sample, so every out-of-range sample is credited with a density it did not act under. The honest alternatives are a tanh-squashed policy with the change-of-variables correction, or a Beta policy on the bounded interval; neither is used. Instead every run reports its saturation fraction, so the size of the problem is visible in the results table.

The search table makes the case for why initialising σ matters on a bounded action space. At init_log_std = 0, σ = 1 on a ±1 action range means 37 % of sampled actions are outside the actuator range — the policy spends most of its probability mass on actions the plant cannot execute, and it costs roughly 50 % more environment steps. The adopted setting, −1.0, ends at σ = 0.133 with 0.5 % saturation.

Seed study and ablations, continuous

SolvedSteps medianIQRGreedy eval
discrete, ppo.py (step 4)16/1662 144[60 442, 63 448]500.0
continuous, ppo_continuous.py16/1663 748[62 008, 65 636]500.0

Moving from two discrete actions to a one-dimensional Gaussian costs 2.6 % of the sample budget on this task, and the interquartile ranges overlap. That is smaller than expected, and it is not evidence that continuous control is free in general — it is evidence that on a plant this small, with the action range matched to the force the discrete policy was already applying, the extra difficulty of learning a mean and a standard deviation is nearly paid for by the finer control authority.

AblationSolvedSteps medianvs baselinepFinal σSaturation
baseline16/1663 7481.00×0.1360.5 %
no advantage normalisation16/1664 6641.01×0.730.1440.1 %
no GAE (λ = 1)16/1671 7601.13×0.00320.2180.8 %
no ratio clipping0/16>150 0002.35×0.00080.08828.2 %
The ordering survives; the magnitude of the top effect does not

Without clipping the discrete policy still gets half its seeds to threshold. The Gaussian policy gets none. The mechanism is visible in the two right-hand columns: no_clip ends with the smallest σ of any configuration (0.088) and by far the largest saturation (28.2 %, up to 80.6 % on the worst seed). A Gaussian with σ = 0.088 only puts 28 % of its mass outside [−1, 1] if the mean sits at roughly ±0.95 — the mean has been driven onto the actuator rail while the standard deviation collapsed around it. With an importance ratio unbounded above on a continuous density, one favourable minibatch moves the mean as far as the gradient points, and the nine remaining epochs on that batch reinforce a policy that is already off the plant's control range. A categorical ratio cannot do this — it is bounded by 1/π_old(a) on a two-element support, and the worst it can do is become deterministic between two executable actions.

So the conclusion is narrower than "clipping matters more in continuous control". It is: on an action space where the policy can place mass outside the actuator range, removing the clip is not a slowdown, it is a failure mode.

10

What this does not prove, and one process failure

The limits of the comparison, plus one mistake in the process worth recording.

  • No real exploration problem. Cart-pole rewards every timestep and a random policy already scores 22. The ablation ordering would very likely change on a sparse-reward task.
  • The ablations are one-at-a-time. Interactions were not measured.
  • One hyperparameter configuration was searched over eight candidates on four seeds. A different configuration could change the ablation magnitudes — though the clipping result is large enough that it is unlikely to flip.
  • no_clip failing is measured at this learning rate and epoch count. Clipping matters because of the ten epochs; at one epoch it would matter much less.
  • The basin comparison uses the greedy policy. The stochastic policy used during training has a different, smaller basin.
  • Step 6 changes the action space, not the task. The plant, the reward and the horizon are the same, so it tests whether the conclusion is an artefact of the categorical head — not whether it holds anywhere else.

Box etiquette, and the time the guard failed

This machine shares eight threads with a higher-priority detection-training job. Every entry point that starts training calls boxcheck.require_quiet_box() and refuses above a 1-minute load average of 4.0.

That guard originally lived only in study.py, and it did not help. The step-6 hyperparameter sweep was launched from a bare shell loop, which never called study.py, and it ran straight through a training job using 770 % CPU. A guard on the front door is not a guard, so it now lives in its own module and is called by every training entry point alike, and it prints the offending process.

The consequences are stated rather than buried:

  • The step-6 search wall-clock numbers are contaminated and are not reported. The learning outcomes are not: runs are seeded and single-threaded, and two identical configurations submitted under different tags during that window returned bitwise-identical steps-to-threshold — which is the evidence that contention moved the clock and nothing else.
  • A throughput figure measured under load was re-measured and proved wrong: contended 73 962 steps/s, quiet box 145 458 steps/s — 1.97×. So MuJoCo is 2.6× slower than the numpy environment rather than the 5× first recorded. A throughput number carries the load average it was taken under, or it carries nothing.
  • Steps 1–5 are clean. The 64-run study finished six minutes before the detection job started, and the guard passed at launch.
11

Running it

Setup, the commands in order, and the runtimes to expect on a CPU.

git clone https://github.com/AungKaung1928/ppo-from-scratch.git
cd ppo-from-scratch
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt

python verify_env.py       # step 1, ~6 s, one core
python lqr.py              # step 2, ~30 s, one core
python ppo.py --seed 0     # step 3, one 150k-step run, ~11 s
python study.py --mode all # steps 3-5, 96 runs, ~8 min, 4-way parallel
python compare.py          # LQR vs PPO basin comparison, ~3 min
python mj_cartpole.py      # step 6 environment, 6/6 checks, ~2 min
python study.py --family continuous --mode ablations --out runs_c   # ~7 min

python visualize.py --controller lqr                 # MuJoCo viewer
python visualize.py --controller lqr --record out/frames   # offscreen, no window

numpy and CPU torch are the only hard dependencies. mujoco is imported lazily and is needed only by the step-6 environment and the visualiser — the cart-pole plant itself is numpy, so every number in the repository reproduces without a physics engine installed at all.

visualize.py opens a window, so it needs a rendering backend: set MUJOCO_GL to whatever your machine has (glfw, egl, osmesa). --record writes frames offscreen and needs no window.

12

The short version

The whole project compressed into one paragraph.

A cart-pole environment hand-written from the 1983 equations and verified thirteen ways — including against an independent solve of the Lagrangian (2.6e−15) and a closed-form divergence rate derivable on paper (3.4e−11). An LQR baseline built by linearising, discretising exactly, and iterating the Riccati recursion by hand, converging in 866 sweeps against a theory prediction of 923, scoring a perfect 500.0 at zero sample cost. PPO then written from scratch — no gymnasium, no stable-baselines3, no cleanrl — and matched to that baseline for 62 144 environment steps across 16/16 seeds. Ablations over sixteen seeds with a permutation test show ratio clipping is the only ingredient that dominates (2.34×, 8/16 solved without it), while GAE is worth 11 % and advantage normalisation is not resolved at the 5 % level. Because return saturates and cannot rank competent controllers, the real comparison runs on the basin of attraction, where PPO recovers from roughly half the angular-rate disturbance and a third of the cart velocity that LQR does, at identical nominal score. A stretch step repeats it with a Gaussian policy on a MuJoCo plant proved identical to 1.4e−14, where removing the clip stops being a slowdown and becomes a 0/16 failure mode. No reward curve appears anywhere in the repository.