"""The models must actually train, not merely have correct gradients. "No trainer in the library" is a scope decision. It must not quietly become "nobody ever checked that it converges" — a block can pass gradcheck and still never learn. These tests are the guard against that reading. """ import warnings import pytest import torch import torch.nn as nn import torch_dimensions as td def factory(cls, plan=None): def build(lat, d_model): with warnings.catch_warnings(): warnings.simplefilter("ignore") return cls(d_model, len(lat.axis_names), lat, plan=plan) return build @pytest.mark.parametrize("cls", [td.LSTM, td.GRU]) def test_the_rnn_family_learns_a_task_that_needs_axial_mixing(cls): result = td.testing.check_trainable(factory(cls), steps=150) assert result["ratio"] >= 3.0, result assert result["held_out"] < result["initial"] def test_a_model_that_never_sweeps_the_needed_axis_cannot_learn_it(): """The negative that makes the positive mean something. The task is a cumulative sum along `w`; a plan that only sweeps time has no path for information to travel along `w` at all.""" blind = factory(td.LSTM, plan=td.ScanPlan.from_list(["time", "time"])) result = td.testing.check_trainable(blind, steps=150, raise_on_failure=False) assert result["ratio"] < 3.0, f"blind model should not solve this: {result}" def test_check_trainable_raises_with_an_actionable_message(): blind = factory(td.LSTM, plan=td.ScanPlan.from_list(["time", "time"])) with pytest.raises(AssertionError, match="did not learn"): td.testing.check_trainable(blind, steps=100) def test_check_trainable_reports_the_numbers_it_judged_on(): result = td.testing.check_trainable(factory(td.LSTM), steps=50, min_ratio=0.0) assert set(result) == {"initial", "final", "held_out", "ratio"} assert all(isinstance(v, float) for v in result.values()) def test_a_sparse_lattice_model_still_learns(): """Masking absent cells must not sever the gradient path for present ones.""" valid = torch.tensor( [[True, True, True, False], [True, True, True, True], [True, False, True, True]] ) lat = td.Lattice(shape=(3, 4), names=("h", "w"), valid=valid, time=True) torch.manual_seed(0) model, head = td.LSTM(16, 3, lat), nn.Linear(16, 1) opt = torch.optim.Adam([*model.parameters(), *head.parameters()], lr=1e-2) g = torch.Generator().manual_seed(0) def draw(): x = torch.randn(8, 5, 3, 4, 16, generator=g) return x, x[..., :1].cumsum(dim=3) * lat.mask().to(x.dtype) first = None for _ in range(150): x, y = draw() loss = (head(model(x)) * lat.mask().to(x.dtype) - y).pow(2).mean() first = first if first is not None else loss.item() opt.zero_grad() loss.backward() opt.step() assert loss.item() < first / 3, f"{first:.4f} -> {loss.item():.4f}" def test_training_updates_every_parameter(): """A parameter that never moves is dead weight the gradient check would not catch, since it can receive a gradient of exactly zero forever.""" lat = td.Lattice(shape=(2, 3), time=True) torch.manual_seed(0) model = td.LSTM(8, 3, lat) before = {n: p.detach().clone() for n, p in model.named_parameters()} opt = torch.optim.Adam(model.parameters(), lr=1e-2) for _ in range(5): loss = model(torch.randn(4, 3, 2, 3, 8)).pow(2).mean() opt.zero_grad() loss.backward() opt.step() unmoved = [n for n, p in model.named_parameters() if torch.equal(p, before[n])] assert not unmoved, unmoved