| """Parameter groups that honour what the upstream models ask for. |
| |
| `A` controls how fast a state decays and `dt` its timescale, both inside an |
| exponential — so weight decay on them is not a mild regulariser but a change |
| to the dynamics, and a learning rate suited to a projection walks them out of |
| the stable region. Both upstream repos say so in the parameters themselves |
| (`_optim` in s4's kernel, `_no_weight_decay` on Mamba's `A_log`/`D`/`dt_bias`), |
| and those tags do nothing unless an optimizer reads them. |
| |
| Which means the obvious line — `AdamW(model.parameters(), lr=...)` — trains an |
| S4 the way its authors avoid, silently. These tests pin the reading of the |
| tags, because the failure mode is a model that trains, converges to something, |
| and is misconfigured the whole way. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import pytest |
| import torch |
|
|
| import torch_dimensions as td |
|
|
| pytest.importorskip("einops", reason="the tagged parameters live in the vendored code") |
| pytest.importorskip("hydra", reason="the s4 pipeline needs hydra-core") |
|
|
| LAT = td.Lattice(shape=(4, 5), names=("h", "w"), time=True) |
|
|
|
|
| def _all_params(groups): |
| return [p for g in groups for p in g["params"]] |
|
|
|
|
| def test_every_parameter_lands_in_exactly_one_group(): |
| """A parameter in two groups is stepped twice; one in none is frozen |
| without anyone saying so.""" |
| model = td.S4(32, 2, LAT, d_input=1, d_state=16) |
| groups = td.param_groups(model, lr=1e-3) |
| listed = _all_params(groups) |
| ids = [id(p) for p in listed] |
| assert len(ids) == len(set(ids)), "a parameter appears in more than one group" |
| expected = {id(p) for p in model.parameters() if p.requires_grad} |
| assert set(ids) == expected, "a parameter was dropped or invented" |
|
|
|
|
| def test_s4_kernel_parameters_keep_the_settings_upstream_gave_them(): |
| """s4 attaches `_optim` to its SSM kernel parameters. Those settings are |
| the authors', not ours, and must survive verbatim.""" |
| model = td.S4(32, 2, LAT, d_input=1, d_state=16) |
| tagged = [p for p in model.parameters() if getattr(p, "_optim", None)] |
| assert tagged, "the vendored s4 kernel no longer tags its parameters" |
|
|
| groups = td.param_groups(model, lr=1e-2, weight_decay=0.1) |
| for param in tagged: |
| group = next(g for g in groups if any(p is param for p in g["params"])) |
| assert group["weight_decay"] == 0.0 |
| assert group["lr"] <= td.optim.SSM_MAX_LR |
|
|
|
|
| def test_mamba_no_weight_decay_parameters_get_none(): |
| """`A_log`, `D` and `dt_bias` carry `_no_weight_decay`. Decay on `A_log` |
| pulls every state toward the same timescale, which is the opposite of what |
| a bank of states is for.""" |
| model = td.Mamba(32, 2, LAT, d_input=1, d_state=8) |
| tagged = [p for p in model.parameters() if getattr(p, "_no_weight_decay", False)] |
| assert tagged, "the vendored mamba block no longer tags its parameters" |
|
|
| groups = td.param_groups(model, lr=1e-3, weight_decay=0.1) |
| for param in tagged: |
| group = next(g for g in groups if any(p is param for p in g["params"])) |
| assert group["weight_decay"] == 0.0 |
|
|
|
|
| def test_the_ssm_rate_is_capped_not_raised(): |
| """Upstream fixes the SSM rate at 1e-3. A caller asking for 1e-2 gets it |
| for ordinary weights and 1e-3 for the SSM; a caller asking for 1e-5 keeps |
| 1e-5 everywhere rather than having it raised to the cap.""" |
| model = td.S4(32, 1, LAT, d_input=1, d_state=16) |
|
|
| high = td.param_groups(model, lr=1e-2) |
| assert max(g["lr"] for g in high) == pytest.approx(1e-2) |
| assert min(g["lr"] for g in high) == pytest.approx(1e-3) |
|
|
| low = td.param_groups(model, lr=1e-5) |
| assert all(g["lr"] == pytest.approx(1e-5) for g in low) |
|
|
|
|
| def test_norms_and_biases_are_excluded_from_decay_by_default(): |
| model = td.LSTM(32, 2, LAT, d_input=1) |
| groups = td.param_groups(model, lr=1e-3, weight_decay=0.1) |
| for group in groups: |
| if group["weight_decay"] > 0: |
| assert all(p.ndim > 1 for p in group["params"]) |
|
|
| including = td.param_groups(model, lr=1e-3, weight_decay=0.1, decay_1d=True) |
| assert any(p.ndim <= 1 for g in including if g["weight_decay"] > 0 for p in g["params"]) |
|
|
|
|
| def test_frozen_parameters_are_left_out(): |
| model = td.LSTM(16, 1, LAT, d_input=1) |
| frozen = next(iter(model.parameters())) |
| frozen.requires_grad_(False) |
| listed = _all_params(td.param_groups(model, lr=1e-3)) |
| assert all(p is not frozen for p in listed) |
|
|
|
|
| def test_the_groups_drive_a_real_optimizer_step(): |
| """The point of the split is that the optimizer applies it — checked by |
| stepping and confirming the tagged parameters moved less.""" |
| torch.manual_seed(0) |
| model = td.S4(32, 1, LAT, d_input=1, d_state=16) |
| tagged = [p for p in model.parameters() if getattr(p, "_optim", None)] |
| before = [p.detach().clone() for p in tagged] |
|
|
| opt = torch.optim.AdamW(td.param_groups(model, lr=1e-1), lr=1e-1) |
| x = torch.randn(2, 3, 4, 5, 1) |
| model(x).pow(2).mean().backward() |
| opt.step() |
|
|
| moved = max(float((p.detach() - b).abs().max()) for p, b in zip(tagged, before, strict=True)) |
| |
| assert moved < 1e-2, f"SSM parameters moved by {moved:.3e} despite the capped rate" |
|
|
|
|
| |
|
|
|
|
| def test_warmup_reaches_the_peak_then_decays(): |
| model = td.LSTM(16, 1, LAT, d_input=1) |
| opt = torch.optim.AdamW(td.param_groups(model, lr=1e-2), lr=1e-2) |
| sched = td.warmup_cosine(opt, warmup=10, total=100) |
|
|
| seen = [] |
| for _ in range(100): |
| seen.append(opt.param_groups[0]["lr"]) |
| opt.step() |
| sched.step() |
|
|
| assert seen[0] < seen[5] < seen[9] |
| assert seen[9] == pytest.approx(1e-2, rel=1e-6) |
| assert seen[-1] < seen[50] < seen[10] |
| |
| |
| assert seen[-1] < 1e-5 |
| opt.step() |
| sched.step() |
| assert opt.param_groups[0]["lr"] == pytest.approx(0.0, abs=1e-9) |
|
|
|
|
| def test_the_schedule_preserves_the_ratio_between_groups(): |
| """Scaling is multiplicative on purpose: a schedule that flattened every |
| group to one rate would undo the separation that is the whole point.""" |
| model = td.S4(32, 1, LAT, d_input=1, d_state=16) |
| opt = torch.optim.AdamW(td.param_groups(model, lr=1e-2), lr=1e-2) |
| sched = td.warmup_cosine(opt, warmup=5, total=50) |
|
|
| for _ in range(25): |
| opt.step() |
| sched.step() |
|
|
| rates = [g["lr"] for g in opt.param_groups] |
| assert max(rates) == pytest.approx(10 * min(rates), rel=1e-6) |
|
|
|
|
| def test_a_floor_keeps_the_rate_above_zero(): |
| model = td.LSTM(16, 1, LAT, d_input=1) |
| opt = torch.optim.AdamW(td.param_groups(model, lr=1e-2), lr=1e-2) |
| sched = td.warmup_cosine(opt, warmup=2, total=20, floor=0.1) |
| for _ in range(20): |
| opt.step() |
| sched.step() |
| assert opt.param_groups[0]["lr"] == pytest.approx(1e-3, rel=1e-6) |
|
|
|
|
| def test_an_impossible_schedule_is_refused(): |
| model = td.LSTM(16, 1, LAT, d_input=1) |
| opt = torch.optim.AdamW(td.param_groups(model, lr=1e-3), lr=1e-3) |
| with pytest.raises(ValueError, match="total > 0"): |
| td.warmup_cosine(opt, warmup=5, total=0) |
|
|
|
|
| def test_the_recipe_trains_every_family_without_per_family_tuning(): |
| """The practical claim. Hand-picked per-family learning rates were needed |
| only because the SSM parameters were being trained at the projection rate; |
| with the groups and a warmup, one recipe holds for all of them.""" |
| lat = td.Lattice(shape=(4, 5), names=("h", "w"), time=True) |
| builders = { |
| "s4": lambda: td.S4(32, 2, lat, d_input=1, d_state=16), |
| "mamba": lambda: td.Mamba(32, 2, lat, d_input=1, d_state=8), |
| "transformer": lambda: td.Transformer(32, 2, lat, d_input=1), |
| "lstm": lambda: td.LSTM(32, 2, lat, d_input=1), |
| } |
| steps = 60 |
| for name, build in builders.items(): |
| torch.manual_seed(0) |
| model = build() |
| head = torch.nn.Linear(32, 1) |
| opt = torch.optim.AdamW( |
| td.param_groups(model, lr=3e-3) + [{"params": head.parameters(), "lr": 3e-3}], |
| lr=3e-3, |
| betas=(0.9, 0.95), |
| ) |
| sched = td.warmup_cosine(opt, warmup=steps // 10, total=steps) |
|
|
| gen = torch.Generator().manual_seed(1) |
| losses = [] |
| for _ in range(steps): |
| x = torch.randn(3, 4, 4, 5, 1, generator=gen) |
| loss = (head(model(x)) - x.cumsum(dim=3)).pow(2).mean() |
| opt.zero_grad() |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) |
| opt.step() |
| sched.step() |
| losses.append(float(loss)) |
|
|
| assert all(torch.isfinite(torch.tensor(losses))), f"{name} diverged to non-finite" |
| assert losses[-1] < losses[0], ( |
| f"{name} did not improve: {losses[0]:.3f} -> {losses[-1]:.3f}" |
| ) |
|
|