feat(training): add airborne_penalty and a stage-6 "unmask" curriculum run

Stage 5 (aggression) passed (41-47 vs grounded curric-s2-defend, within
the lenient gate but not yet a clear win). Rather than keep the locomotion
mask on indefinitely, stage 6 reopens full 3D controls on top of the
aggression retune and pairs it with a new dense airborne_penalty (scaled
by height above the floor) so the policy learns to prefer staying grounded
through incentives instead of a hard mask — same regime shift that
regressed stage 3, but this time with a mitigation and ~12x the training
time (~240M timesteps / ~24h vs ~20M / ~2h) to actually re-converge
instead of stalling mid-shift.

airborne_penalty follows the existing SHIP_AI_OVERRIDES pattern: default
0 (off) on ship_ai_controller.gd, exposed via train.py's new
--airborne-penalty flag, added to training_mode.gd's allow-list. Also adds
a per-stage timesteps override in curriculum.py (STAGES[n]["timesteps"])
since this is the first stage to need a different budget than the rest.
This commit is contained in:
Josh Creek
2026-07-22 21:22:27 +01:00
parent 2de3085f13
commit 1afdc301ab
5 changed files with 71 additions and 17 deletions
+31 -1
View File
@@ -104,6 +104,32 @@ STAGES = [
"resume_from_experiment": "curric-s2-defend",
"reference_experiment": "curric-s2-defend",
},
{
"name": "unmask",
# Re-opens full 3D controls (no more --no-allow-vertical/
# --no-allow-pitch-roll) on top of the aggression retune, instead of
# keeping locomotion masked indefinitely. The mask blocked *thrust*-
# driven flight outright; the new airborne_penalty (dense, scaled by
# height above the floor — see ship_ai_controller.gd) is meant to
# teach the policy to prefer staying grounded through incentives
# rather than a hard constraint, so it can start learning when the
# other axes are actually useful (aerial saves, wall recoveries)
# instead of never touching them. This resumes the exact regime
# shift (grounded checkpoint -> full 3D) that regressed stage 3 —
# the mitigation this time is airborne_penalty plus a much longer
# run (24h / ~240M steps vs stage 3's 20M) to actually re-converge
# instead of stalling mid-shift like stage 3 did in a fifth of the
# time.
"flags": [
"--opponent-mode", "self_play",
"--velocity-to-ball-weight", "0.05",
"--ball-distance-penalty", "0.006",
"--ball-touch-reward", "0.5",
"--airborne-penalty", "0.003",
],
"grounded": False,
"timesteps": 240_000_000, # ~24h at the standing n-parallel/speedup (20M took ~2h)
},
]
@@ -178,9 +204,13 @@ def _grounded_for_experiment(experiment: str) -> bool:
def run_stage_attempt(stage_index: int, attempt: int, args) -> str:
exp = experiment_name(stage_index, attempt)
resume = resume_checkpoint(stage_index, attempt, args.seed_checkpoint)
# A stage can override the run's timesteps budget (see "floor-lock",
# which deliberately runs much longer than the ~20M/~2h every stage so
# far has used); otherwise it falls back to curriculum.py's own --timesteps.
timesteps = STAGES[stage_index].get("timesteps", args.timesteps)
cmd = [
"./run_training.sh", exp,
"--timesteps", str(args.timesteps),
"--timesteps", str(timesteps),
"--n-parallel", str(args.n_parallel),
"--speedup", str(args.speedup),
*STANDING_ARGS,
+5
View File
@@ -96,6 +96,10 @@ def parse_args():
"--ball-touch-reward", type=float, default=None,
help="Overrides ShipAIController.ball_touch_reward (event reward on ball contact, cooldown-gated)",
)
curriculum.add_argument(
"--airborne-penalty", type=float, default=None,
help="Overrides ShipAIController.airborne_penalty (dense per-tick cost scaled by height above the floor)",
)
return parser.parse_args()
@@ -116,6 +120,7 @@ def _curriculum_kwargs(args) -> dict:
"ai_velocity_to_ball_weight": args.velocity_to_ball_weight,
"ai_ball_distance_penalty": args.ball_distance_penalty,
"ai_ball_touch_reward": args.ball_touch_reward,
"ai_airborne_penalty": args.airborne_penalty,
}
return {key: value for key, value in mapping.items() if value is not None}