feat(*): Start curriculum generation 2, seeded from curric-s5-aggression

This commit is contained in:
Josh Creek
2026-07-26 19:00:38 +01:00
parent 33b2c23f13
commit 390bd18be7
6 changed files with 367 additions and 231 deletions
+81 -12
View File
@@ -178,6 +178,14 @@ movement. Each stage is a normal chained run — a new `--experiment` resumed
via `--resume checkpoints/<previous>/final.zip`, same as any other run —
just with different curriculum flags.
`curriculum.py` has run through two generations so far. Generation 1 (below)
ran stages 1-6 to completion/block and is archived; generation 2 (the one
`curriculum.py` actually runs today) starts a fresh stage 1 seeded from
generation 1's last clean pass instead of continuing to retry a stage that
kept getting worse — see "Generation 2" below.
### Generation 1 (archived — see `curriculum_state_gen1.json`)
| Stage | Flags | What it teaches |
|---|---|---|
| 1 — score | `--opponent-mode inert --attack-goal-bias 1.0 --no-allow-vertical --no-allow-pitch-roll` | Team 1 is a do-nothing placeholder ship parked at its spawn (an effectively empty net); near-goal resets always target the goal the trainee attacks; the ship can't fly or pitch/roll, only drive and yaw. Isolated finishing practice. |
@@ -185,7 +193,7 @@ just with different curriculum flags.
| 3 — no draws | `--draw-penalty 5 --reset-std 0.3` | Training episodes are golden-goal (end at the *first* goal), so there's no in-episode goal-margin to penalize — `draw_penalty` is the closest available signal: a one-time penalty when an episode times out with no goal at all, on top of the existing per-tick `time_penalty`. Also lifts the locomotion mask (full 3D controls) by omitting `--allow-vertical`/`--allow-pitch-roll`; pair that with `--reset-std` since the policy never got a reward gradient on those axes before now, so expect a brief re-exploration wobble. |
| 4 — mechanics/refinement | *(no curriculum flags — plain `next_run.sh`)* | Stock self-play, full controls, default reward/start-state mix. This is what all runs before this feature already did. |
| 5 — aggression | `--opponent-mode self_play --no-allow-vertical --no-allow-pitch-roll --velocity-to-ball-weight 0.05 --ball-distance-penalty 0.006 --ball-touch-reward 0.5` | **Resumes from stage 2 (`curric-s2-defend`), not stage 4** — see the regression note below. Retunes ball-pursuit reward weights (up from 0.02/0.002/0.4) for much more aggressive, constantly-chasing floor play, deliberately keeping the locomotion mask on so it can't reopen the stage-3 regression. Passed 2026-07-22 (41-47 vs grounded stage 2 — close, not yet a clear win). |
| 6 — unmask | `--opponent-mode self_play --velocity-to-ball-weight 0.05 --ball-distance-penalty 0.006 --ball-touch-reward 0.5 --airborne-penalty 0.003` | Re-opens full 3D controls on top of the aggression retune — this is the same grounded-checkpoint-to-full-3D transition that regressed stage 3, but this time paired with `airborne_penalty` (dense, scaled by height above the floor — see `ship_ai_controller.gd`) so the policy learns to *prefer* staying grounded through incentives instead of a hard mask, and can still pick up genuinely useful aerial/wall plays instead of never touching those axes. Runs much longer (~240M timesteps / ~24h vs every prior stage's ~20M/~2h) to actually re-converge through the regime shift instead of stalling mid-way like stage 3 did in a fifth of the time. |
| 6 — unmask | `--opponent-mode self_play --velocity-to-ball-weight 0.05 --ball-distance-penalty 0.006 --ball-touch-reward 0.5 --airborne-penalty 0.003` | Re-opens full 3D controls on top of the aggression retune — this is the same grounded-checkpoint-to-full-3D transition that regressed stage 3, but this time paired with `airborne_penalty` (dense, scaled by height above the floor — see `ship_ai_controller.gd`) so the policy learns to *prefer* staying grounded through incentives instead of a hard mask, and can still pick up genuinely useful aerial/wall plays instead of never touching those axes. **Failed 3 attempts in a row** (25% → 20% → 15% win rate vs `curric-s5-aggression`) and blocked — see "Generation 2" below for what replaced it. |
> **Stages 3-4 regressed; stage 6 deliberately reopens the same transition
> with a mitigation.** The locomotion-mask inference bugfix (`8c15c46`)
@@ -199,7 +207,62 @@ just with different curriculum flags.
> overrides) instead of chaining through stages 3-4. Stage 6 is where full 3D
> flight comes back — not masked away this time, but discouraged via
> `airborne_penalty` and given ~12x the training time to settle. See
> `curriculum_state.json`'s log for the full eval numbers.
> `curriculum_state_gen1.json`'s log for the full eval numbers.
Stage 6 (`unmask`, `retry1`, `retry2`) all used identical flags —
`curriculum.py` always reuses `STAGES[stage_index]["flags"]` on retry, only
the resume checkpoint changes — so continued training just drifted the same
policy further rather than converging differently (25% → 20% → 15% win rate
vs `curric-s5-aggression`). After 3 failed attempts the script blocked for
human review; rather than pile up `retry4`, `retry5`, ... on a lineage that
kept getting worse, generation 2 (below) replaces it with a fresh stage 1.
### Generation 2 (current)
`curriculum.py`'s live `STAGES` list now contains a single stage, `unmask`
(displays as stage 1 — `curric-s1-unmask`), which picks up exactly where
generation 1's regression analysis left off. It resumes directly from
`FOUNDATION_EXPERIMENT` (`curric-s5-aggression`'s own checkpoint — the last
stage that passed cleanly) via `resume_from_experiment`/`reference_experiment`
overrides, rather than re-running stages 1-5 or continuing generation 1's
drifted `retry2`:
`--opponent-mode self_play --velocity-to-ball-weight 0.08 --ball-distance-penalty 0.01 --ball-touch-reward 0.7 --airborne-penalty 0.003 --ball-velocity-to-goal-weight 0.06 --goal-reward 80 --draw-penalty 5`
Compared to generation 1's stage 6:
- `velocity_to_ball_weight` (0.05→0.08) and `ball_distance_penalty`
(0.006→0.01) — the actual ball-chasing terms, unchanged since stage 5
despite three failed attempts — plus `ball_touch_reward` (0.5→0.7).
- Two scoring-specific terms newly exposed via `train.py` (they already
existed as `ship_ai_controller.gd`/`training_mode.gd` `@export`s, just not
as CLI flags): `ball_velocity_to_goal_weight` (0.004 default → 0.06)
rewards the ball actually moving toward the goal, not just being
chased/touched; `goal_reward` (40 default → 80) is the terminal reward for
scoring itself.
- `draw_penalty 5` (proven effective in stage 3 against passivity), which
generation 1's stage 6 had never set — previously all carrot for scoring,
no stick for never scoring.
- `reset_retry_checkpoint: True` on the stage dict, so if this stage itself
fails and retries, `resume_checkpoint()` resets to `FOUNDATION_EXPERIMENT`
again instead of drifting a failed attempt further — the specific bug that
made generation 1's 3 retries monotonically worse instead of converging.
Deliberately not added: a cooldown/cap on `ball_velocity_to_goal_weight` to
guard against a bot farming near-misses (bouncing the ball toward goal
repeatedly without finishing) instead of actually scoring. Unlike the
touch-farming bug (see `ship_ai_controller.gd`'s `ball_touch_reward`
comments) this term is already direction-scaled by construction (it's a
velocity-toward-goal quantity, not an undirected contact count), so the risk
is theoretical rather than demonstrated. If this stage's eval shows high
`ball_velocity_to_goal_weight` accrual without a matching rise in actual
goals scored, that's the signal to add one.
Every experiment name `curriculum.py` generates is now timestamped
(`YYYYMMDD-HHMM-<name>`, e.g. `20260727-0930-curric-s1-unmask`), applied once
in `run_stage_attempt` — this keeps generation 2's names from colliding with
generation 1's plain ones (both checkpoint directories and TensorBoard run
names come straight from `--experiment`) and makes run order obvious in
TensorBoard without cross-referencing `curriculum_state.json`.
All curriculum flags default to leaving Godot's own `@export` defaults
alone (`train.py` only forwards a flag when you pass it), so ordinary runs
@@ -208,7 +271,7 @@ are unaffected. Full flag list: `--opponent-mode {self_play,inert,frozen}`,
`--attack-goal-bias`, `--kickoff-chance`, `--near-goal-chance`,
`--allow-vertical`/`--no-allow-vertical`, `--allow-pitch-roll`/`--no-allow-pitch-roll`,
`--velocity-to-ball-weight`, `--ball-distance-penalty`, `--ball-touch-reward`,
`--airborne-penalty`.
`--airborne-penalty`, `--ball-velocity-to-goal-weight`, `--goal-reward`.
### Running it automatically
@@ -216,15 +279,17 @@ are unaffected. Full flag list: `--opponent-mode {self_play,inert,frozen}`,
pattern as `start_training.sh`) drives all stages end to end: for each
stage it runs `run_training.sh` (pull, train, export, commit+push) with that
stage's flags, then evaluates the resulting checkpoint against a reference
bot over 100 episodes — the fixed `rookie.json` baseline for stage 1, the
previous stage's promoted checkpoint by default for stages 2+, or an
explicit `resume_from_experiment`/`reference_experiment` override in that
stage's dict when it deliberately skips a since-regressed branch (stage 5).
bot over 100 episodes — the fixed `rookie.json` baseline for a from-scratch
stage 1 (no `resume_from_experiment`/`reference_experiment` override on
`STAGES[0]`), the previous stage's promoted checkpoint by default for
stages 2+, or an explicit override in that stage's dict when it deliberately
skips a since-regressed branch (generation 1's stage 5) or seeds from a
fixed foundation checkpoint (generation 2's stage 1 — see above).
```bash
cd training
./curriculum.sh # start/resume the curriculum
./curriculum.sh --seed-checkpoint checkpoints/run11/final.zip # seed stage 1 instead of a fresh policy
./curriculum.sh --seed-checkpoint checkpoints/run11/final.zip # override stage 1's resume source for this run
```
The gate is deliberately lenient: it blocks a stage only on a **clear
@@ -237,10 +302,14 @@ still catches a genuinely broken stage. Progress and every attempt's eval
result are logged to `curriculum_state.json` (committed alongside
`eval_history.json` after each attempt).
A stage gets up to 2 retries (3 attempts total, each resuming from that
stage's own previous attempt with a fresh `--reset-std`) before the script
stops and asks for a human look — it will not retry indefinitely or advance
past a stage that keeps failing on its own. Once you've looked at why (more
A stage gets up to 2 retries (3 attempts total) before the script stops and
asks for a human look — it will not retry indefinitely or advance past a
stage that keeps failing on its own. By default a retry resumes from that
stage's own previous attempt with a fresh `--reset-std`; a stage can instead
set `reset_retry_checkpoint: True` (generation 2's stage 1 does) to always
reset to its normal resume source instead — see the generation 1 → 2
postmortem above for why blind same-checkpoint retries can make things
monotonically worse. Once you've looked at why a block happened (more
timesteps? a flag needs adjusting? the eval itself was misleading?), re-run
with `--force-retry` to try again or `--skip-to-next-stage` if you judge the
result good enough despite the gate.