torch-dimensions / tests /test_scan.py
Celsia's picture
Upload folder using huggingface_hub
ecc81b3 verified
Raw
History Blame Contribute Delete
14.7 kB
"""Phase 3 acceptance for axial_apply / AxialScan / LSTM. See PLAN.md.
The load-bearing tests use ``cumsum`` and a position-weighting as mixers,
because torch already provides an independent reference for "apply this to
every 1-D line along axis d": ``x.cumsum(dim=d)`` and a broadcast multiply.
Anything wrong with the fold, the permutation, or the flip shows up as a
mismatch against an implementation that shares no code with ours.
"""
import pytest
import torch
import torch.nn as nn
from torch_dimensions import GRU, LSTM, AxialScan, Lattice, ScanPlan, axial_apply
from torch_dimensions.mixers import LSTMMixer
RANKS = [1, 2, 3, 4]
def _shape(rank):
return tuple(range(2, 2 + rank))
def _x(lat, b=2, t=3, h=3, dtype=torch.float64):
lead = (b, t) if lat.time else (b,)
return torch.randn(*lead, *lat.shape, h, dtype=dtype)
def cumsum(seq):
return seq.cumsum(dim=1)
def weighted(seq):
"""Scale position i by (i + 1) — catches misalignment that cumsum, being
a prefix operation, could tolerate."""
w = torch.arange(1, seq.shape[1] + 1, dtype=seq.dtype).view(1, -1, 1)
return seq * w
def weighted_ref(x, dim):
shape = [1] * x.ndim
shape[dim] = x.shape[dim]
w = torch.arange(1, x.shape[dim] + 1, dtype=x.dtype).view(shape)
return x * w
# -- axial_apply: the axis bookkeeping ---------------------------------------
@pytest.mark.parametrize("rank", RANKS)
@pytest.mark.parametrize("time", [False, True])
def test_applies_the_mixer_to_every_line_of_the_axis(rank, time):
lat = Lattice(shape=_shape(rank), time=time)
x = _x(lat)
for axis in range(lat.n_axes):
d = lat.tensor_dim(axis)
assert torch.equal(axial_apply(x, lat, axis, cumsum), x.cumsum(dim=d)), f"axis={axis}"
assert torch.equal(axial_apply(x, lat, axis, weighted), weighted_ref(x, d))
@pytest.mark.parametrize("rank", RANKS)
def test_reverse_sweeps_the_line_backwards(rank):
lat = Lattice(shape=_shape(rank))
x = _x(lat)
for axis in range(lat.n_axes):
d = lat.tensor_dim(axis)
got = axial_apply(x, lat, axis, cumsum, reverse=True)
assert torch.equal(got, x.flip(d).cumsum(dim=d).flip(d)), f"axis={axis}"
def test_reverse_actually_changes_the_result():
"""Guards against a flip that is silently a no-op."""
lat = Lattice(shape=(4,))
x = _x(lat)
assert not torch.equal(
axial_apply(x, lat, 0, cumsum), axial_apply(x, lat, 0, cumsum, reverse=True)
)
def test_rank_one_is_exactly_the_bare_mixer():
"""With one axis there is nothing to fold, so the machinery must vanish."""
lat = Lattice(shape=(6,))
x = _x(lat)
assert torch.equal(axial_apply(x, lat, 0, cumsum), cumsum(x))
def test_axes_can_be_named():
lat = Lattice(shape=(3, 4), names=("h", "w"))
x = _x(lat)
assert torch.equal(axial_apply(x, lat, "w", cumsum), axial_apply(x, lat, 1, cumsum))
@pytest.mark.parametrize("chunk", [1, 2, 3, 1000])
def test_chunking_does_not_change_the_result(chunk):
lat = Lattice(shape=(3, 4))
x = _x(lat)
assert torch.equal(axial_apply(x, lat, 0, cumsum, chunk=chunk), axial_apply(x, lat, 0, cumsum))
def test_a_mixer_that_changes_shape_is_rejected_clearly():
lat = Lattice(shape=(3, 4))
with pytest.raises(ValueError, match=r"\(M, A, H\) -> \(M, A, H\)"):
axial_apply(_x(lat), lat, 0, lambda s: s[..., :1])
# -- AxialScan ---------------------------------------------------------------
def _linear_scan(lat, d_model=3, n_layers=None, **kw):
# Default to one layer per axis so the plan covers the lattice and the
# "axis never swept" warning stays meaningful when it does fire.
plan = ScanPlan.cyclic(lat.axis_names, n_layers or lat.n_axes)
return AxialScan(
mixer=lambda: nn.Linear(d_model, d_model),
plan=plan,
lattice=lat,
d_model=d_model,
**kw,
).double()
@pytest.mark.parametrize("rank", RANKS)
@pytest.mark.parametrize("time", [False, True])
def test_scan_preserves_shape(rank, time):
lat = Lattice(shape=_shape(rank), time=time)
x = _x(lat)
assert _linear_scan(lat)(x).shape == x.shape
def test_scan_follows_the_plan_in_order():
"""Distinct axis sizes let a shared mixer report which axis each layer
swept, via the sequence length it was handed."""
class Recorder(nn.Module):
def __init__(self):
super().__init__()
self.seen = []
def forward(self, x):
self.seen.append(x.shape[1])
return x
lat = Lattice(shape=(2, 3, 4), names=("a", "b", "c"))
rec = Recorder()
plan = ScanPlan.from_list(["c", "a", "b", "c"])
AxialScan(mixer=rec, plan=plan, lattice=lat, d_model=3)(_x(lat, dtype=torch.float32))
assert rec.seen == [4, 2, 3, 4]
def test_scan_gradients_reach_every_parameter():
lat = Lattice(shape=(2, 3))
model = _linear_scan(lat, n_layers=3)
model(_x(lat)).pow(2).mean().backward()
missing = [n for n, p in model.named_parameters() if p.grad is None]
assert not missing, missing
def test_scan_is_gradcheck_clean():
lat = Lattice(shape=(2, 3))
model = _linear_scan(lat, d_model=2, n_layers=2)
x = torch.randn(1, 2, 3, 2, dtype=torch.float64, requires_grad=True)
assert torch.autograd.gradcheck(model, (x,), fast_mode=True)
def test_rejects_wrong_feature_width():
lat = Lattice(shape=(2, 3))
with pytest.raises(ValueError, match="expected 3 features"):
_linear_scan(lat)(torch.randn(2, 2, 3, 5, dtype=torch.float64))
def test_unknown_axis_fails_at_construction_not_at_forward():
lat = Lattice(shape=(2, 3), names=("h", "w"))
with pytest.raises(KeyError, match="depth"):
AxialScan(
mixer=lambda: nn.Identity(),
plan=ScanPlan.from_list(["depth"]),
lattice=lat,
d_model=3,
)
def test_module_mixer_shares_weights_across_layers():
lat = Lattice(shape=(2, 3))
shared = nn.Linear(3, 3)
plan = ScanPlan.cyclic(("dim0", "dim1"), 4)
scan = AxialScan(mixer=shared, plan=plan, lattice=lat, d_model=3)
assert all(m is shared for m in scan.mixers)
factory = _linear_scan(lat, n_layers=4)
assert len({id(m) for m in factory.mixers}) == 4
# -- sparse lattices ---------------------------------------------------------
def _sparse(shape=(3, 4), seed=0):
torch.manual_seed(seed)
valid = torch.rand(shape) > 0.4
valid.reshape(-1)[0] = True
return Lattice(shape=shape, valid=valid)
def test_absent_cell_values_cannot_influence_the_output():
"""Perturb only the absent cells; every output must be bitwise identical.
Zeroing on entry is what buys this."""
lat = _sparse()
model = _linear_scan(lat, n_layers=3)
x = _x(lat)
other = x + (~lat.mask()).to(x.dtype) * torch.randn_like(x) * 1e3
assert torch.equal(model(x), model(other))
def test_absent_cells_are_zero_on_output():
lat = _sparse()
out = _linear_scan(lat, n_layers=2)(_x(lat))
assert out.masked_select(~lat.mask().expand_as(out)).abs().max() == 0
def test_dense_lattice_allocates_no_mask():
assert _linear_scan(Lattice(shape=(2, 3))).cell_mask is None
# -- LSTM / GRU --------------------------------------------------------------
def test_rank_one_single_layer_equals_a_bare_lstm():
"""With one axis, no norm, and no residual, the stack must reduce exactly
to nn.LSTM — the sharpest check that the fold is transparent."""
torch.manual_seed(0)
lat = Lattice(shape=(5,))
mixer = LSTMMixer(4).double()
scan = AxialScan(
mixer=mixer,
plan=ScanPlan.from_list([0]),
lattice=lat,
d_model=4,
norm=False,
residual=False,
)
x = _x(lat, h=4)
assert torch.equal(scan(x), mixer.rnn(x)[0])
@pytest.mark.parametrize("model_cls", [LSTM, GRU])
@pytest.mark.parametrize("rank", RANKS)
def test_rnn_forward_and_backward(model_cls, rank):
lat = Lattice(shape=_shape(rank), time=True)
model = model_cls(d_model=4, n_layers=lat.n_axes, lattice=lat)
x = torch.randn(2, 3, *lat.shape, 4)
out = model(x)
assert out.shape == x.shape
out.pow(2).mean().backward()
assert all(p.grad is not None for p in model.parameters())
def test_rnn_uses_a_cyclic_plan_by_default():
lat = Lattice(shape=(2, 3), names=("h", "w"), time=True)
assert [s.axis for s in LSTM(4, 4, lat).plan] == [0, 1, 2, 0]
def test_rnn_accepts_a_custom_plan():
lat = Lattice(shape=(2, 3), names=("h", "w"))
plan = ScanPlan.from_list([("w", True), ("h", False)])
assert [(s.axis, s.reverse) for s in LSTM(4, 2, lat, plan=plan).plan] == [
(1, True),
(0, False),
]
def test_rnn_refuses_plan_and_bidirectional_together():
lat = Lattice(shape=(2, 3))
with pytest.raises(ValueError, match="not both"):
LSTM(4, 2, lat, plan=ScanPlan.from_list([0]), bidirectional=True)
def test_rnn_bidirectional_reaches_the_plan():
lat = Lattice(shape=(2, 3), names=("h", "w"), time=True)
plan = LSTM(4, 6, lat, bidirectional=("h", "w")).plan
seen = {}
for s in plan:
seen.setdefault(s.axis, set()).add(s.reverse)
assert seen[0] == {False} # time stays causal
assert seen[1] == {False, True} and seen[2] == {False, True}
# -- 1-D is the N-D case with nothing to fold --------------------------------
def test_no_lattice_gives_a_plain_sequence_model():
model = LSTM(d_model=4, n_layers=3)
assert model.lattice.rank == 0 and model.lattice.n_axes == 1
x = torch.randn(2, 7, 4)
assert model(x).shape == x.shape
def test_no_lattice_accepts_varying_sequence_length():
"""The degenerate lattice has no static size, so length stays dynamic."""
model = LSTM(d_model=4, n_layers=2)
for t in (1, 5, 50):
assert model(torch.randn(2, t, 4)).shape == (2, t, 4)
def test_one_dimensional_stack_matches_stacked_lstm_layers():
"""No norm, no residual: the stack must equal applying each nn.LSTM in
turn, which is what makes the 1-D path free of special-casing.
Equal to floating point, not bitwise, and the reason is worth knowing.
The fold reshapes, which needs a contiguous tensor, while nn.LSTM returns
a transposed view. So from layer two onward the mixer receives contiguous
input where the reference hands it a view, and torch's RNN kernels are not
bit-identical across memory layouts. A single layer *is* bitwise exact --
see the test above -- which is what pins the fold itself.
"""
torch.manual_seed(0)
lat = Lattice(shape=(), time=True)
mixers = [LSTMMixer(4).double() for _ in range(3)]
it = iter(mixers)
scan = AxialScan(
mixer=lambda: next(it),
plan=ScanPlan.cyclic(("time",), 3),
lattice=lat,
d_model=4,
norm=False,
residual=False,
)
x = torch.randn(2, 6, 4, dtype=torch.float64)
ref = x
for m in mixers:
ref = m(ref)
assert torch.allclose(scan(x), ref, rtol=0, atol=1e-15)
assert (scan(x) - ref).abs().max() < 1e-15
def test_time_only_lattice_rejects_a_validity_mask():
with pytest.raises(ValueError, match="no cells to mark valid"):
Lattice(shape=(), time=True, valid=torch.ones(1, dtype=torch.bool))
def test_empty_shape_without_time_is_still_an_error():
with pytest.raises(ValueError, match="time=False"):
Lattice(shape=())
# -- nd_method ---------------------------------------------------------------
def test_nd_method_accepts_the_exported_function():
"""The documented spelling: a strategy is a function, not a string."""
import torch_dimensions as td
lat = Lattice(shape=(2, 3))
assert isinstance(LSTM(4, 2, lat, nd_method=td.axial_scan).nd, AxialScan)
def test_the_default_strategy_is_that_same_function():
import torch_dimensions as td
lat = Lattice(shape=(2, 3))
explicit = LSTM(4, 2, lat, nd_method=td.axial_scan)
assert type(LSTM(4, 2, lat).nd) is type(explicit.nd)
def test_nd_method_still_accepts_a_registered_name_for_config():
"""YAML cannot hold a callable, so names keep working."""
lat = Lattice(shape=(2, 3))
assert isinstance(LSTM(4, 2, lat, nd_method="axial_scan").nd, AxialScan)
def test_nd_method_accepts_a_user_written_callable():
"""A custom strategy needs no registration — that is the extension point."""
seen = {}
def only_first_axis(mixer, plan, lattice, d_model, **kw):
seen["called"] = True
return AxialScan(
mixer=mixer, plan=ScanPlan.from_list([0]), lattice=lattice, d_model=d_model
)
lat = Lattice(shape=(2, 3))
model = LSTM(4, 2, lat, nd_method=only_first_axis)
assert seen["called"] and len(model.plan) == 1
assert model(torch.randn(2, 2, 3, 4)).shape == (2, 2, 3, 4)
def test_unknown_nd_method_names_the_registered_ones():
""" "cafa" was once the example of an unknown name here; it is now a real
registered method, which is exactly why the example must be impossible."""
lat = Lattice(shape=(2, 3))
with pytest.raises(ValueError, match="axial_attention.*axial_scan.*cafa"):
LSTM(4, 2, lat, nd_method="no_such_method")
def test_nd_method_must_be_a_name_or_callable():
lat = Lattice(shape=(2, 3))
with pytest.raises(TypeError, match="name or a callable"):
LSTM(4, 2, lat, nd_method=42)
def test_registering_a_duplicate_nd_method_is_refused():
import torch_dimensions as td
from torch_dimensions import register_nd_method
with pytest.raises(ValueError, match="already registered"):
register_nd_method("axial_scan", td.axial_scan)
def test_rnn_warns_when_a_plan_disagrees_with_n_layers():
"""A plan fixes the depth. Accepting n_layers=6 with a 2-step plan and
silently building 2 layers ships a model shallower than requested — the
downgrade must at least be loud. A warning rather than an error because
generic builders legitimately pass both."""
lat = Lattice(shape=(2, 3))
with pytest.warns(UserWarning, match="n_layers=6 is ignored"):
model = LSTM(4, 6, lat, plan=ScanPlan.from_list([0, 1]))
assert len(model.plan) == 2 # the plan wins
def test_rnn_accepts_a_plan_with_the_default_n_layers(recwarn):
lat = Lattice(shape=(2, 3))
assert len(LSTM(4, lattice=lat, plan=ScanPlan.from_list([0, 1, 0])).plan) == 3
assert len(recwarn) == 0
def test_chunk_must_be_positive():
"""chunk=0 used to surface as `range() arg 3 must not be zero` from deep
inside; the contract should be stated at the boundary."""
lat = Lattice(shape=(3, 4))
with pytest.raises(ValueError, match="chunk"):
axial_apply(torch.randn(2, 3, 4, 5), lat, 0, lambda s: s, chunk=0)