torch-dimensions / tests /test_conv.py
Celsia's picture
Upload folder using huggingface_hub
ecc81b3 verified
Raw
History Blame Contribute Delete
11.5 kB
"""The convolutional family: separability, causality, and dilation schedules.
The centrepiece is :func:`test_separable_stack_equals_a_full_nd_convolution`.
The kernel family's factorization is checked against ``torch.kron``; this is
the same discipline for the other factorization the library performs — a stack
of per-axis 1-D convolutions is claimed to *be* an N-D convolution with a
rank-1 kernel, and that claim is checked against ``F.conv2d``/``F.conv3d``
rather than against another call to itself.
"""
import math
import pytest
import torch
import torch.nn.functional as F
import torch_dimensions as td
from torch_dimensions.mixers.conv import ConvMixer, TCNMixer
MODELS = [td.CNN, td.TCN]
def _factory(cls):
def build(lat, d_model, plan=None):
return cls(d_model, lat.n_axes, lat, plan=plan)
return build
# -- conformance --------------------------------------------------------------
@pytest.mark.parametrize("cls", MODELS)
def test_conv_family_passes_every_applicable_check(cls):
report = td.testing.check_block(_factory(cls), ranks=(1, 2, 3))
assert report, str(report)
@pytest.mark.parametrize("cls,mixer_cls", [(td.CNN, ConvMixer), (td.TCN, TCNMixer)])
def test_rank_one_single_layer_equals_the_bare_mixer(cls, mixer_cls):
lat = td.Lattice(shape=(), time=True)
torch.manual_seed(0)
model = cls(6, 1, lat, norm=False, residual=False).double()
torch.manual_seed(0)
bare = mixer_cls(6).double()
x = torch.randn(2, 7, 6, dtype=torch.float64)
assert torch.equal(model(x), bare(x))
@pytest.mark.parametrize("cls", MODELS)
def test_the_conv_family_learns(cls):
stats = td.testing.check_trainable(lambda lat, d: cls(d, 6, lat), steps=150)
assert stats["ratio"] > 3.0, stats
# -- separability: the claim that makes this "N-D" --------------------------
def _joint_kernel(weights):
"""Outer-product the per-axis conv weights into one N-D kernel.
Each ``(out, in, k)`` weight contracts with the next over the intermediate
channel, which is what composing the layers does; the spatial indices stay
separate and become the kernel's axes.
"""
joint = weights[0] # (o, i, k0)
for axis, w in enumerate(weights[1:], start=1):
# (o, m, k_new) x (m, i, k0..k_{axis-1}) -> (o, i, k0.., k_new)
letters = "abcdefg"[:axis]
joint = torch.einsum(f"omz,mi{letters}->oi{letters}z", w, joint)
return joint
@pytest.mark.parametrize("rank,shape", [(2, (5, 7)), (3, (4, 5, 6))])
def test_separable_stack_equals_a_full_nd_convolution(rank, shape):
"""One sweep per axis, linear, is exactly an N-D convolution.
Two details make the equality exact rather than approximate, and both are
load-bearing. ``activation=None`` — with a nonlinearity between the sweeps
the operators no longer commute and no joint kernel exists (the negative
control below). And ``bias=False`` — zero-padding along one axis only
survives a convolution along another axis if that convolution maps zero
rows to zero rows, which a bias breaks. A separable model *with* a bias is
still a fine model; it is just no longer this identity.
"""
names = tuple("hwd"[:rank])
lat = td.Lattice(shape=shape, names=names)
torch.manual_seed(0)
model = td.CNN(
3,
rank,
lat,
kernel_size=3,
activation=None,
norm=False,
residual=False,
mixer_kwargs={"bias": False},
).double()
assert [lat.axis_names[s.axis] for s in model.plan] == list(names)
x = torch.randn(2, *shape, 3, dtype=torch.float64)
got = model(x)
joint = _joint_kernel([m.convs[0].weight for m in model.nd.mixers])
conv = F.conv2d if rank == 2 else F.conv3d
channels_first = x.permute(0, x.ndim - 1, *range(1, x.ndim - 1))
want = conv(channels_first, joint, padding=(1,) * rank)
want = want.permute(0, *range(2, want.ndim), 1)
diff = (got - want).abs().max().item()
assert diff < 1e-12, f"separable stack differs from the joint kernel by {diff:.2e}"
def test_the_separability_identity_has_teeth():
"""The negative control: a nonlinearity between sweeps must break it.
Without this, the test above would pass just as happily against a model
that had quietly stopped being separable.
"""
lat = td.Lattice(shape=(5, 7), names=("h", "w"))
torch.manual_seed(0)
model = td.CNN(
3,
2,
lat,
kernel_size=3,
activation="gelu",
norm=False,
residual=False,
mixer_kwargs={"bias": False},
).double()
x = torch.randn(2, 5, 7, 3, dtype=torch.float64)
joint = _joint_kernel([m.convs[0].weight for m in model.nd.mixers])
want = F.conv2d(x.permute(0, 3, 1, 2), joint, padding=(1, 1)).permute(0, 2, 3, 1)
assert (model(x) - want).abs().max().item() > 1e-3
# -- causality ----------------------------------------------------------------
def test_the_tcn_mixer_is_bitwise_causal():
torch.manual_seed(0)
mixer = TCNMixer(6).double()
x = torch.randn(3, 16, 6, dtype=torch.float64)
future = x.clone()
future[:, 9:] += 100.0
a, b = mixer(x), mixer(future)
assert torch.equal(a[:, :9], b[:, :9]), "a causal convolution leaked the future"
assert not torch.equal(a[:, 9:], b[:, 9:])
def test_the_centred_conv_is_deliberately_not_causal():
"""The negative control for the test above.
A centred window *must* see forward — otherwise the causality test is
passing on a mixer that happens to look backwards for some other reason.
"""
torch.manual_seed(0)
mixer = ConvMixer(6, activation=None).double()
x = torch.randn(3, 12, 6, dtype=torch.float64)
future = x.clone()
future[:, 6:] += 100.0
assert not torch.equal(mixer(x)[:, :6], mixer(future)[:, :6])
def test_an_even_kernel_is_refused_when_centred():
with pytest.raises(ValueError, match="odd"):
ConvMixer(4, kernel_size=4)
ConvMixer(4, kernel_size=4, causal=True) # defined side: allowed
# -- the dilation schedule ----------------------------------------------------
def test_dilation_doubles_per_axis_not_per_layer():
"""The N-D correction. Under a cyclic plan over three axes, layer 3 is the
*second* sweep of axis 0, so it dilates by 2 — not by 8.
This guards a real regression: ``TCNMixer`` swallowed ``sweep`` into
``**kw`` and every layer silently ran at dilation 1, which is a plain
convolution stack wearing a TCN's name.
"""
lat = td.Lattice(shape=(6, 8), names=("h", "w"), time=True)
model = td.TCN(8, 9, lat)
assert [m.dilation for m in model.nd.mixers] == [1, 1, 1, 2, 2, 2, 4, 4, 4]
def test_dilation_growth_is_opt_out():
lat = td.Lattice(shape=(6, 8), names=("h", "w"), time=True)
model = td.TCN(8, 6, lat, dilation_base=1)
assert {m.dilation for m in model.nd.mixers} == {1}
def test_a_mixer_that_never_asked_for_sweep_is_built_unchanged():
"""The opt-in rule: only a factory whose signature names `sweep` gets one.
An LSTM mixer has no such argument, and passing it would be a TypeError at
construction — this is what keeps the feature invisible to every mixer
that does not want it.
"""
lat = td.Lattice(shape=(4, 5), names=("h", "w"), time=True)
td.LSTM(8, 6, lat) # would raise if `sweep` were passed unconditionally
# -- receptive field ----------------------------------------------------------
def test_receptive_field_reports_coverage_against_the_axis():
lat = td.Lattice(shape=(6, 40), names=("h", "w"))
model = td.CNN(8, 4, lat, kernel_size=3) # two sweeps per axis, span 5
rf = td.receptive_field(model)
assert rf["h"] == {"span": 5, "size": 6, "covers": False, "layers": 2}
assert rf["w"] == {"span": 5, "size": 40, "covers": False, "layers": 2}
wide = td.CNN(8, 4, lat, kernel_size=7) # span 13
assert td.receptive_field(wide)["h"]["covers"] is True
def test_global_mixers_report_an_infinite_span():
"""An RNN spans its axis in one layer; saying "inf" is the honest answer,
and it keeps the report meaningful across mixed families."""
lat = td.Lattice(shape=(6, 8), names=("h", "w"))
rf = td.receptive_field(td.LSTM(8, 2, lat))
assert rf["h"]["span"] == math.inf and rf["h"]["covers"] is True
def test_time_axis_coverage_is_unanswerable_and_says_so():
lat = td.Lattice(shape=(4,), names=("h",), time=True)
rf = td.receptive_field(td.CNN(8, 2, lat))
assert rf["time"]["size"] is None and rf["time"]["covers"] is None
def test_receptive_field_refuses_the_kernel_family():
lat = td.Lattice(shape=(4, 5), names=("h", "w"), time=True)
model = td.CNN(8, 3, lat, method=td.cafa)
with pytest.raises(TypeError, match="scan-family"):
td.receptive_field(model)
# -- registry and round-trip --------------------------------------------------
@pytest.mark.parametrize("kind", ["cnn", "tcn"])
def test_build_and_round_trip(kind, tmp_path):
model = td.build(
{
"kind": kind,
"d_model": 8,
"n_layers": 4,
"lattice": {"shape": [4, 5], "names": ["h", "w"]},
}
)
x = torch.randn(2, 4, 5, 8)
model.eval()
path = tmp_path / "m.td"
model.save(path)
same = td.load(path).eval()
assert torch.equal(model(x), same(x))
def test_depthwise_separable_is_separable_in_both_senses():
"""Depthwise convolutions separate channels from space; the axial fold
separates the axes. Both at once is the cheapest corner of the design
space, and it should still be a working model."""
lat = td.Lattice(shape=(5, 6), names=("h", "w"))
dense = td.CNN(16, 4, lat, depthwise=False)
light = td.CNN(16, 4, lat, depthwise=True)
n_dense = sum(p.numel() for p in dense.parameters())
n_light = sum(p.numel() for p in light.parameters())
assert n_light < n_dense / 2, (n_light, n_dense)
assert light(torch.randn(2, 5, 6, 16)).shape == (2, 5, 6, 16)
# -- refusals and reporting ---------------------------------------------------
@pytest.mark.parametrize(
"kw,match",
[
({"kernel_size": 0}, "kernel_size must be >= 1"),
({"n_conv": 0}, "n_conv must be >= 1"),
({"dilation": 0}, "must be >= 1"),
({"dilation_base": 0}, "must be >= 1"),
({"activation": "swish"}, "unknown activation"),
],
)
def test_bad_configuration_is_refused_by_name(kw, match):
with pytest.raises(ValueError, match=match):
ConvMixer(4, **kw)
def test_a_width_one_kernel_needs_no_padding_and_is_the_identity_in_space():
"""kernel_size=1 is a pointwise channel mix — a legitimate degenerate case,
and the one that exercises the no-padding path."""
mixer = ConvMixer(4, kernel_size=1, activation=None).double()
x = torch.randn(2, 5, 4, dtype=torch.float64)
assert mixer(x).shape == x.shape
assert mixer.receptive_field == 1
def test_wrong_feature_width_is_refused():
with pytest.raises(ValueError, match="expected 4 features"):
ConvMixer(4)(torch.randn(2, 5, 7))
def test_the_repr_states_what_the_layer_actually_does():
"""`extra_repr` is how a stack of these is read at a glance, and the
dilation is the part a schedule makes non-obvious."""
text = repr(ConvMixer(4, kernel_size=5, causal=True, dilation=3))
assert "causal" in text and "dilation=3" in text and "receptive_field=13" in text
assert "centred" in repr(ConvMixer(4))