File size: 11,475 Bytes
ecc81b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 | """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))
|