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:
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
The six steps
The six build steps, and what each had to produce before the next could start.
Every file, and what it is for
Every source file, how big it is, and the single job it owns.
| File | Lines | What it does |
|---|---|---|
| cartpole.py | 209 | 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.py | 456 | 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.py | 496 | 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.py | 360 | 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.py | 305 | 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.py | 291 | 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.py | 277 | 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.py | 148 | 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.py | 172 | Watch it. MuJoCo is used as a renderer only — mj_forward for kinematics,
never mj_step — and the numpy plant supplies the state. Letting MuJoCo integrate would
silently be a different plant. |
| boxcheck.py | 49 | 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.xml | 42 · 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. |
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
| # | Check | Result |
|---|---|---|
| 1 | Scalar formulas against an independent solve of the Lagrangian mass matrix, 10 000 random states, |θ| up to π | max rel diff 2.6e−15 |
| 2a | Energy conserved under RK4, F = 0, 2 s at dt = 1e−4 | 2.4e−14 |
| 2b | Horizontal momentum conserved, same run | 5.4e−15 |
| 3 | Pinned cart, divergence rate against the closed form √(3g/4l) | 3.834058 vs 3.834058, rel err 3.4e−11 |
| 4a–d | Truncation 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 |
| 4e | Action sign: bang-bang on θ + 0.5·θ̇ balances | 500.0 / 500 |
| 5a–b | Same seed gives a bitwise identical trajectory; the environment ignores the global numpy RNG | exact |
| 6 | Random-policy mean return against the published ≈22, over 10 000 episodes | 22.09 ± 0.12 |
| 7 | Golden-trajectory regression, 201 states | bitwise 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.
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
doneterminated 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.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.
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
| # | Check | Result |
|---|---|---|
| L1 | Analytic Ac, Bc against a central-difference Jacobian of the derivative | 3.78e−12 |
| L2 | Ad = I + τ·Ac against a finite-difference Jacobian of the actual step | 7.57e−14 |
| L3 | Converged P satisfies the discrete algebraic Riccati equation | residual 6.63e−10 |
| L4 | Closed-loop spectral radius below 1 | 0.983919 |
| L5 | xᵀPx against the simulated linear closed-loop cost-to-go | max rel 5.46e−12 |
| L6 | Scaling both Q and R by c leaves the gain unchanged, c ∈ [1e−3, 1e3] | 5.81e−16 |
| L7 | Mean return ≥ 475 over 100 consecutive episodes | 500.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
Mean return over 1000 episodes. Zero failures. Metric fully saturated.
The design consumed no environment interaction at all. Steps-to-threshold is 0 by construction.
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 upright | Bang-bang (±10 N only) | Continuous force, clipped to ±10 N |
|---|---|---|
| critical θ̇₀ | 2.1693 rad/s | 1.9632 rad/s |
| critical ẋ₀ | 2.4354 m/s | 1.9593 m/s |
| critical θ₀ | 0.2094 rad = the termination limit | 0.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
| Q | Sweeps | ρ | Return | Critical θ̇₀ | Max |x| |
|---|---|---|---|---|---|
I₄ | 866 | 0.9839 | 500.0 | 2.17 rad/s | 0.136 m |
diag(1,1,10,10) pole-weighted | 887 | 0.9842 | 500.0 | 2.14 rad/s | 0.143 m |
diag(10,10,1,1) cart-weighted | 664 | 0.9786 | 500.0 | 1.89 rad/s | 0.081 m |
diag(0,0,1,1) pole only | 240 | 1.0000 | 500.0 | 0.40 rad/s | 0.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.
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.
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.
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.
| Configuration | Solved | Steps-to-threshold, median | IQR |
|---|---|---|---|
| lr 2e−3, 10 epochs — adopted | 4/4 | 63 028 | [62 020, 63 428] |
| lr 1e−3, 8 epochs, 8 minibatches | 4/4 | 66 520 | [65 076, 69 772] |
| lr 1.5e−3, 10 epochs | 4/4 | 73 412 | [68 910, 78 664] |
| lr 1e−3, 10 epochs | 4/4 | 76 104 | [73 346, 85 050] |
| lr 2.5e−3, 4 epochs | 3/4 | 112 104 | [83 832, 141 888] |
| lr 3e−4, 10 epochs | 2/4 | 148 808 | — |
| lr 1e−3, 4 epochs | 1/4 | >150 000 | — |
| lr 3e−4, 4 epochs — first attempt | 0/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
nice 10torch.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.
Steps 4 and 5 — seeds and ablations
Sixteen seeds, four ablations, and a permutation test written without scipy.
The seed study
| Baseline PPO | LQR | |
|---|---|---|
| solved (≥475 over 100 consecutive episodes) | 16/16 | yes |
| steps-to-threshold, median | 62 144 | 0 |
| steps-to-threshold, IQR | [60 442, 63 448] | — |
| greedy evaluation, 100 episodes, median | 500.0 | 500.0 |
| greedy evaluation, worst seed | 500.0 | — |
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
| Ablation | Solved | Steps median | vs baseline | Permutation p |
|---|---|---|---|---|
| baseline | 16/16 | 62 144 | 1.00× | — |
| no advantage normalisation | 16/16 | 64 884 | 1.04× | 0.054 |
| no GAE (λ = 1) | 16/16 | 68 948 | 1.11× | 0.0006 |
| no ratio clipping | 8/16 | 145 368 | 2.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.
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.
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.
| LQR | PPO, median over 16 seeds | PPO best seed | |
|---|---|---|---|
| nominal return, 100 episodes | 500.0 | 500.0 | 500.0 |
| critical θ̇₀ | 2.169 rad/s | 1.082 rad/s | 2.004 rad/s |
| critical ẋ₀ | 2.435 m/s | 0.824 m/s | 1.935 m/s |
| Failures out of 200 episodes, initial velocities widened | LQR | PPO median | PPO worst seed |
|---|---|---|---|
| U(−0.5, 0.5) | 0 | 1 | 22 |
| U(−1.0, 1.0) | 0 | 48 | 83 |
| U(−1.5, 1.5) | 6 | 88 | 115 |
| U(−2.0, 2.0) | 28 | 116 | 138 |
| U(−2.5, 2.5) | 61 | 140 | 158 |
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
| Controller | Outcome at θ̇₀ = 1.5 rad/s | Peak |θ| |
|---|---|---|
| LQR | survives 500 steps | 46 % of the limit |
| PPO seed 2 | falls at step 18 (0.36 s) | 113 % |
| PPO seed 0 | survives 500 steps | 52 % |
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.
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 rad | at 50 steps | at 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 plant | Mean return, 100 episodes |
|---|---|
| bang-bang, sign only, ±10 N | 500.0 (min 500) |
| continuous, clipped to ±10 N | 500.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.
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
| Solved | Steps median | IQR | Greedy eval | |
|---|---|---|---|---|
discrete, ppo.py (step 4) | 16/16 | 62 144 | [60 442, 63 448] | 500.0 |
continuous, ppo_continuous.py | 16/16 | 63 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.
| Ablation | Solved | Steps median | vs baseline | p | Final σ | Saturation |
|---|---|---|---|---|---|---|
| baseline | 16/16 | 63 748 | 1.00× | — | 0.136 | 0.5 % |
| no advantage normalisation | 16/16 | 64 664 | 1.01× | 0.73 | 0.144 | 0.1 % |
| no GAE (λ = 1) | 16/16 | 71 760 | 1.13× | 0.0032 | 0.218 | 0.8 % |
| no ratio clipping | 0/16 | >150 000 | 2.35× | 0.0008 | 0.088 | 28.2 % |
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.
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_clipfailing 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.
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.
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.