Upload code/scripts/test_training.py with huggingface_hub
Browse files- code/scripts/test_training.py +142 -0
code/scripts/test_training.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the training script's staging, curricula and checkpoint logic.
|
| 2 |
+
|
| 3 |
+
Separate from ``test_planner.py``, which covers the recursion itself. These
|
| 4 |
+
cover the wiring around it — the places where a run can be configured into
|
| 5 |
+
something other than what the stage table says, silently.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 15 |
+
|
| 16 |
+
from lejepa_control.losses import arrival_hold_loss # noqa: E402
|
| 17 |
+
from lejepa_control_2.scripts.train_planner import ( # noqa: E402
|
| 18 |
+
STAGES,
|
| 19 |
+
current_value,
|
| 20 |
+
parse_args,
|
| 21 |
+
parse_curriculum,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_stage_presets_match_the_bring_up_table():
|
| 26 |
+
"""Each stage turns on exactly one more mechanism than the last."""
|
| 27 |
+
a = parse_args(['--stage', 'A'])
|
| 28 |
+
assert a.cycles == 1 and a.use_feedback is False
|
| 29 |
+
assert (a.lambda_cycle, a.lambda_anchor, a.lambda_sat, a.lambda_support) \
|
| 30 |
+
== (0.0, 0.0, 0.0, 0.0)
|
| 31 |
+
|
| 32 |
+
b = parse_args(['--stage', 'B'])
|
| 33 |
+
assert b.cycles == 3 and b.use_feedback is True and b.lambda_cycle == 0.3
|
| 34 |
+
assert (b.lambda_anchor, b.lambda_sat, b.lambda_support) == (0.0, 0.0, 0.0)
|
| 35 |
+
|
| 36 |
+
c = parse_args(['--stage', 'C'])
|
| 37 |
+
assert (c.lambda_anchor, c.lambda_sat) == (0.05, 1e-3)
|
| 38 |
+
assert c.lambda_support == 0.0
|
| 39 |
+
|
| 40 |
+
d = parse_args(['--stage', 'D'])
|
| 41 |
+
assert d.lambda_support == 0.01
|
| 42 |
+
assert max(v for _, v in parse_curriculum(d.horizon_curriculum, 100)) == 5
|
| 43 |
+
|
| 44 |
+
# E is D with warm start off — the cold-start control, not a new mechanism
|
| 45 |
+
e = parse_args(['--stage', 'E'])
|
| 46 |
+
assert e.warm_start is False
|
| 47 |
+
for key in ('cycles', 'lambda_cycle', 'lambda_support', 'lambda_anchor',
|
| 48 |
+
'lambda_sat', 'horizon_curriculum'):
|
| 49 |
+
assert getattr(e, key) == getattr(d, key), f'E differs from D in {key}'
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_explicit_flags_override_the_preset():
|
| 53 |
+
"""A preset must not silently win over something typed on the CLI."""
|
| 54 |
+
args = parse_args(['--stage', 'A', '--cycles', '5', '--lambda-cycle', '0.7'])
|
| 55 |
+
assert args.cycles == 5 and args.lambda_cycle == 0.7
|
| 56 |
+
# and untouched keys still come from the preset
|
| 57 |
+
assert args.use_feedback is False
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_default_run_uses_the_section_10_hyperparameters():
|
| 61 |
+
args = parse_args([])
|
| 62 |
+
assert (args.inner, args.cycles) == (6, 3)
|
| 63 |
+
assert (args.hold_weight, args.alpha) == (0.5, 0.05)
|
| 64 |
+
assert (args.lambda_cycle, args.lambda_support) == (0.3, 0.01)
|
| 65 |
+
assert (args.lambda_anchor, args.lambda_sat) == (0.05, 1e-3)
|
| 66 |
+
assert (args.lr, args.weight_decay, args.grad_clip) == (1e-4, 1e-4, 1.0)
|
| 67 |
+
assert (args.batch_size, args.steps) == (32, 20000)
|
| 68 |
+
assert args.curriculum == '0:2,0.25:3,0.5:5'
|
| 69 |
+
assert parse_curriculum(args.horizon_curriculum, 20000) == [
|
| 70 |
+
(0, 3), (10000, 5)
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_curriculum_advances_at_the_right_steps():
|
| 75 |
+
stages = parse_curriculum('0:2,0.25:3,0.5:5', 20000)
|
| 76 |
+
assert stages == [(0, 2), (5000, 3), (10000, 5)]
|
| 77 |
+
assert current_value(stages, 0) == 2
|
| 78 |
+
assert current_value(stages, 4999) == 2
|
| 79 |
+
assert current_value(stages, 5000) == 3
|
| 80 |
+
assert current_value(stages, 19999) == 5
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.mark.parametrize(
|
| 84 |
+
'horizon_spec,step,want_h,want_q',
|
| 85 |
+
[
|
| 86 |
+
# stage D: the two curricula advance together, so the clamp is inert
|
| 87 |
+
('0:3,0.5:5', 0, 3, 2),
|
| 88 |
+
('0:3,0.5:5', 6000, 3, 3),
|
| 89 |
+
('0:3,0.5:5', 12000, 5, 5),
|
| 90 |
+
# stages B and C: H is pinned at 3 while the offset curriculum still
|
| 91 |
+
# climbs to 5. This is where the clamp actually bites.
|
| 92 |
+
('0:3', 12000, 3, 3),
|
| 93 |
+
],
|
| 94 |
+
)
|
| 95 |
+
def test_goal_offset_is_clamped_to_the_live_horizon(
|
| 96 |
+
horizon_spec, step, want_h, want_q
|
| 97 |
+
):
|
| 98 |
+
"""The offset curriculum must not outrun the horizon being rolled out.
|
| 99 |
+
|
| 100 |
+
Stages B and C hold ``H = 3`` while the shared offset curriculum advances
|
| 101 |
+
to 5 at the halfway point. Sampling ``q = 5`` against a 3-step rollout
|
| 102 |
+
would be silently clamped down to 3 inside ``arrival_hold_loss``, so the
|
| 103 |
+
deadline would stop meaning what the curriculum says it means — and the
|
| 104 |
+
dataset would be relabeling goals from 5 transitions ahead that the
|
| 105 |
+
planner has no steps left to reach. The clamp belongs at the sampler.
|
| 106 |
+
"""
|
| 107 |
+
horizon = current_value(parse_curriculum(horizon_spec, 20000), step)
|
| 108 |
+
offset_stages = parse_curriculum('0:2,0.25:3,0.5:5', 20000)
|
| 109 |
+
offset = min(current_value(offset_stages, step), horizon)
|
| 110 |
+
assert (horizon, offset) == (want_h, want_q)
|
| 111 |
+
assert offset <= horizon
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_arrival_term_is_undistorted_once_the_offset_is_clamped():
|
| 115 |
+
"""With the clamp in place ``q`` always indexes a step that exists."""
|
| 116 |
+
d = torch.tensor([[0.9, 0.6, 0.3]])
|
| 117 |
+
for q in (1, 2, 3):
|
| 118 |
+
offset = torch.tensor([q])
|
| 119 |
+
got = arrival_hold_loss(d, offset, 0.5).mean()
|
| 120 |
+
hold = d[:, q:].mean(dim=1) if q < 3 else torch.zeros(1)
|
| 121 |
+
assert torch.allclose(got, (d[:, q - 1] + 0.5 * hold).mean())
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_ablation_switches_are_reachable_but_off_by_default():
|
| 125 |
+
"""Section 12's ablations are wired behind flags, not on by default."""
|
| 126 |
+
args = parse_args([])
|
| 127 |
+
assert args.detach_schedule == 'last-cycle' # ablation 4
|
| 128 |
+
assert args.terminal_only is False # ablation 5
|
| 129 |
+
assert args.path_weighting == 'late' # ablation 6
|
| 130 |
+
assert args.use_feedback is True # ablation 2
|
| 131 |
+
|
| 132 |
+
assert parse_args(['--terminal-only']).terminal_only is True
|
| 133 |
+
assert parse_args(['--no-feedback']).use_feedback is False
|
| 134 |
+
assert parse_args(['--path-weighting', 'discount']).path_weighting \
|
| 135 |
+
== 'discount'
|
| 136 |
+
for schedule in ('last-cycle', 'one-step', 'full'):
|
| 137 |
+
assert parse_args(['--detach-schedule', schedule]).detach_schedule \
|
| 138 |
+
== schedule
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
if __name__ == '__main__':
|
| 142 |
+
sys.exit(pytest.main([__file__, '-v', '--no-header']))
|