fix: correct continuous-action PPO scaling, seeding, and eval defaults; retrain pinned models - #19
Open
aleksandarbabicdnv wants to merge 10 commits into
Open
fix: correct continuous-action PPO scaling, seeding, and eval defaults; retrain pinned models#19aleksandarbabicdnv wants to merge 10 commits into
aleksandarbabicdnv wants to merge 10 commits into
Conversation
…as run yet
reset() unconditionally called reward_stats_calc(len(self.rewards)) on
any non-"init" reset, assuming self.rewards was non-empty. That's true
for envs[0] (pre-reset by do_training() with options={"init": True}),
but every other parallel env in a vectorized training run (n_envs > 1,
as in every experiments/*.yaml) hits its first-ever reset with
self.rewards == [] and crashes with IndexError on rewards[-1].
Guard on self.rewards being non-empty instead of relying on the
caller having passed options={"init": True} on that specific
instance — this was blocking all real training runs with n_envs > 1,
independent of continuous vs. discrete actions.
…cceleration a028b57 changed the continuous action_space Box bounds from Box(-1.0, 1.0) (normalized) to Box(-conf.acc, conf.acc) (physical units), but step() still scaled the sampled action by conf.acc again, capping the real applied acceleration at conf.acc ** 2 instead of conf.acc. With the default acc=0.1, real max acceleration was 0.01 — 10x smaller than intended. Add a regression test asserting step()'s applied acceleration matches conf.acc at the action-space boundary.
Same double-scaling bug as step()'s continuous branch, in a separate diagnostic-only code path: EpisodeResult.acc_final (used by play_ppo.py's play/sweep tables and plots) rescaled the already- physical-units action by conf.acc again, showing acc**2 instead of the real final acceleration. Found via the s42 speed-sweep output showing x_acc_f pinned at exactly 0.01 (0.1**2) across nearly every speed. Doesn't affect training or the environment's actual physics — reporting only.
…bang-bang chatter Continuous-action PPO training at the physically-intended acc=0.1 was converging to a persistent bang-bang chatter that never settled. Root cause: SB3's default initial policy std (1.0) is far wider than the action box (half-width = acc) at any acc value, so nearly every raw sampled action clips to the boundary before training has shaped anything - a self-reinforcing near-deterministic bang-bang distribution that gradient descent has no signal to escape from (log-prob is computed from the raw unclipped sample, not the delivered clipped action). Fix: expose `acc` as a proper TrainingConfig field (was previously hardcoded) and auto-derive the continuous policy's initial log_std as log(acc / 2.5) in train_ppo.py whenever it isn't set explicitly, so ~99% of raw samples start inside the box regardless of acc - including if acc is swept/randomized across future experiments, not just fixed at 0.1. Verified end-to-end: trained model settles cleanly to rest with acc=0.1 (matching the Q-learning agent's action range) instead of oscillating indefinitely, confirmed flat across a full speed-sweep. See project_ppo_acc_squared_bug.md for the full investigation. - experiment_config.py: TrainingConfig.acc and TrainingConfig.log_std_init fields, with from_dict wiring. - ppo_agent.py: ProximalPolicyOptimizationAgent accepts log_std_init, passed to SB3 as policy_kwargs. - train_ppo.py: --acc and --log-std-init CLI flags; auto-derivation logic. - play_ppo.py: reads acc from the training sidecar so playback reconstructs the correct action-space bounds for models trained at a non-default acc.
--seed defaulted to a hardcoded None instead of following --config like every other training parameter (--acc, --ent-coef, --log-std-init, etc.), so a fresh training run via `train_ppo.py --config <yaml>` alone silently ignored the yaml's seed field and trained non-deterministically. Confirmed via training sidecars: hybrid_cv01_s42.zip and hybrid_cv01_s5775.zip (and every other fresh-trained model this session) all recorded seed: null despite their yamls specifying seed: 42 / 5775 - none of the "pinned seed" reproducibility the experiment yamls' own header comments promise was actually happening.
… doc Retrains under the log_std_init auto-sizing fix (fixes the persistent bang-bang chatter this branch's earlier commits addressed) and the --seed default fix (models are now genuinely trained at seed 42/5775, not the previously-silent seed=None). Discrete counterparts are untouched - never affected by either fix. Evaluated with --no-randomize-start explicitly (the CLI default follows the training config, which is randomize_start: true - omitting this flag draws a random initial speed per sweep row instead of the requested fixed one, and silently produced misleadingly optimistic sweep numbers during this retrain investigation before the mistake was caught). Regenerates all 8 continuous-side figures (2 training-curve comparisons, 2 sweep comparisons, 2 detail plots, 2 individual episode trajectories) and rewrites reward_comparison.md's continuous-column numbers throughout. Notable changes to the reported conclusions, not just numbers: - Both seeds are now 100% crash-free across the full +-10 m/s sweep (previously s42 had 3 outlier speeds including an 835-step anomaly). - Neither seed produces a value-function instability event during training (previously s5775 had one at 1.35M steps); section 4.4 rewritten accordingly. - Settle step scales smoothly and roughly linearly with |speed| with no anomalous outliers in either seed - a cleaner result than previously reported, but not literally flat as an earlier draft of this update incorrectly claimed before the evaluation bug above was found. - Continuous explained variance now exceeds discrete for both seeds (previously the reverse) - EV is no longer part of discretisation's advantage in S7.4, only position precision and settle speed are.
Retrains under the --seed default fix (75552e8): the discrete baselines were previously trained at seed=None despite their yaml "pinned seed" headers (sidecars showed "seed": null). Now genuinely seed 42 / 5775. Evaluated with --speed-sweep --no-randomize-start (the CLI default follows the training config's randomize_start: true, which silently draws a random speed per sweep row). Also tracks <model>_vecnorm.pkl for the pinned discrete models: play_ppo.py normalises observations from it at inference, so the models were not reproducible without it regardless of the seed. Notable result changes vs the previous (unseeded, unreproducible) numbers: - disc s5775: 99/100 crash-free across the sweep (one isolated rail-contact transient at 8.2 m/s; adjacent speeds settle normally). EV 0.954 -> 0.971. - disc s42: machine-epsilon final position for 90/100 swept speeds; ten converge to a +/-0.1 m offset attractor instead of exactly zero. Mean |x_pos| ~1.0 cm. EV 0.931 -> 0.928. - Both single-run effects (one seed, one episode per speed); an earlier unseeded disc s42 run hit x=0 at every speed. - reward_comparison.md sections 4-7 updated: "machine-epsilon for all 100 speeds" softened throughout, single-run caveats added, figures regenerated. - Fig 9 episode plot moved 1.0 -> 1.2 m/s (1.0 is now an offset speed). No code changes.
- Track models/hybrid_cv01_s42_vecnorm.pkl and hybrid_cv01_s5775_vecnorm.pkl so the pinned continuous models (f3e066b) are reproducible: play_ppo.py normalises observations from <stem>_vecnorm.pkl at inference. The discrete pair was already tracked in 6b01e02; this closes the same gap for continuous. - Ignore .claude/ (Claude Code local session/config, not for the repo).
Two CLI-default footguns found while re-evaluating models on this branch: - --randomize-start defaulted to the model sidecar's value, which is randomize_start: true for every hybrid_cv01* config. A --speed-sweep run that didn't also pass --no-randomize-start therefore drew a random speed per row instead of the exact requested one, silently producing misleadingly optimistic sweep numbers. Now: default is a None sentinel, resolved to False for --speed-sweep (logged) and to the sidecar value otherwise; pass --randomize-start to force it back on for a sweep. - --save-png defaulted to True but the env only records trajectory traces while render_mode == "plot" (AntiPendulumEnv.step), so --render-mode none --save-png wrote nothing, with no warning. Now: --render-mode none is promoted to "plot" (file only, no window) when --save-png is on; a warning is logged for --render-mode play-back, which can't save. Default is also off for --speed-sweep (which would otherwise emit one PNG per swept speed) and on for single-speed playback. No behaviour change for existing explicit invocations (--speed-sweep --no-randomize-start, --render-mode plot).
The discrete expl_var curve in Figure 5 dips to ~0.04 around 800k-1.05M steps before recovering to 0.93. Note that this coincides with discrete crash-elimination (rail_hit% still falling over the same window) and is a normal pre-convergence transient, not a value-function instability event - EV is monotone non-decreasing from 1.05M onward for both variants.
aleksandarbabicdnv
requested review from
ClaasRostock and
eisDNV
and removed request for
ClaasRostock
August 31, 2026 14:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The continuous-action PPO path had a dormant double-scaling bug that made the real
actuator 10× weaker than documented. Fixing it exposed a training pathology
(persistent bang-bang chatter) and, while investigating, two more issues: the
--seedCLI default was ignored, andplay_ppo.py's--speed-sweep/--save-pngdefaults silently did the wrong thing.
This PR fixes all of those, retrains the four pinned
hybrid_cv01baselines(continuous + discrete, seeds 42 / 5775) so they are genuinely seeded and
reproducible, and rewrites
docs/source/reward_comparison.mdto match.10 commits, ~7 source/script files, 4 retrained model sets, 1 doc rewritten.
Tests:
52 passed, 3 skipped(2 new environment tests).Bugs fixed
bce6e90reset()raisedIndexErroron any env whose first-ever reset lackedoptions={"init": True}— i.e. every parallel env inn_envs > 1, which is every real experiment config. Blocked all training.self.rewardsbeing non-empty instead of the caller'soptionsdict.7411fdbstep()continuous branch multiplied the (already physical-unit) action byconf.acca second time. Real max acceleration wasconf.acc²=0.01, not0.1.* self.conf.acc. Regression test added.c8973d8ppo_agent.do_one_episode()→EpisodeResult.acc_final(used by play/sweep tables and plots).1ebf4cbacc=0.1, the continuous policy converged to permanent bang-bang chatter: SB3's default initial policy σ (1.0) is ~10× the action-box half-width, so nearly every sampled action clips to a boundary before training shapes it.log_std_init = ln(acc / 2.5)intrain_ppo.py(correct for anyacc; explicit--log-std-initstill overrides). Continuous agent now produces a smooth decaying correction and settles cleanly.75552e8--seedCLI default was hardcodedNoneinstead of following--config, so every "pinned seed" run was actually unseeded (sidecars showed"seed": null).--seedfromconfig.training.seed.5ad9ae9play_ppo.py: (1)--randomize-startdefaulted to the sidecar value (truefor allhybrid_cv01*), so a--speed-sweepwithout--no-randomize-startdrew random speeds per row — silently optimistic numbers. (2)--save-pngdefaultedtruebut the env only records traces inrender_mode == "plot", so--render-mode none --save-pngwrote nothing with no warning.--speed-sweepnow forcesrandomize_start=False(logged) unless--randomize-startis passed;--render-mode noneis promoted toplot(file only) when--save-pngis set, with a warning forplay-back;--save-pngdefaults off for sweeps. No change for explicit--speed-sweep --no-randomize-start/--render-mode plot.API / behaviour changes
TrainingConfiggainsacc: float = 0.1andlog_std_init: float | None = None;train_ppo.pygains--accand--log-std-initflags.play_ppo.pyreadsaccfrom the training sidecar when reconstructing the env(old sidecars default to
0.1, backward compatible).play_ppo.pysweep/save-png defaults as described above.Model retrains + docs (
f3e066b,6b01e02,d2a2c7f)All four pinned baselines retrained fresh, correctly seeded, evaluated with
--speed-sweep --no-randomize-start:hybrid_cv01_s42(cont)hybrid_cv01_s5775(cont)hybrid_cv01_disc_s42hybrid_cv01_disc_s5775docs/source/reward_comparison.md§4–§7 rewritten: the "machine-epsilon for all 100speeds" claim softened to "90–99/100" with single-run caveats; EV comparison updated
(continuous now edges discrete); §6.1 footnote added for the discrete EV dip during
crash-elimination.
The seeded discrete runs are marginally less pristine than the previous committed
numbers, which were unreproducible: null seed and untracked
*_vecnorm.pkl(inference normalizes observations from it).
2215430adds gitignore exceptions andcommits the four
*_vecnorm.pkl, so all four pinned models are now genuinelyreproducible from their config + seed.