Download tests/test_weights.py from Celsia/torch-dimensions: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/Celsia/torch-dimensions/resolve/main/tests/test_weights.py
- Command line
-
hf download hf://Celsia/torch-dimensions/tests/test_weights.py
-
curl -L -o test_weights.py https://huggingface.co/Celsia/torch-dimensions/resolve/main/tests/test_weights.py
17.7 kB
| """`td.viz.weights` β the parameters, in a form a diagram can draw. | |
| The spec says what the architecture is; this says what its weights hold. The | |
| tests below are mostly about two things the viewer depends on and cannot check | |
| for itself: that every tensor is classified by the *role* it plays β which is | |
| what decides whether a diagram draws a bipartite graph, a receptive field or a | |
| bank of decaying states β and that downsampling is always declared, because a | |
| picture of one corner of a matrix presented as the matrix is worse than no | |
| picture. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import pytest | |
| import torch | |
| import torch_dimensions as td | |
| LAT = td.Lattice(shape=(4, 5), names=("h", "w")) | |
| def _roles(payload, layer=0): | |
| return {t["role"] for t in payload["layers"][layer]["tensors"]} | |
| def _by_name(payload, name, layer=0): | |
| return next(t for t in payload["layers"][layer]["tensors"] if t["name"] == name) | |
| def test_the_payload_is_json_able_and_names_its_format(): | |
| payload = td.viz.weights(td.LSTM(16, 2, LAT)) | |
| assert payload["format"] == "torch-dimensions/weights" | |
| assert payload["version"] >= 1 | |
| json.dumps(payload) # must not raise: tensors have to be plain lists by here | |
| def test_one_entry_per_layer_with_the_mixer_named(): | |
| model = td.LSTM(16, 3, LAT) | |
| payload = td.viz.weights(model) | |
| assert [entry["layer"] for entry in payload["layers"]] == [0, 1, 2] | |
| assert {entry["mixer"] for entry in payload["layers"]} == {"LSTMMixer"} | |
| def test_a_convolution_is_labelled_a_convolution_not_a_matrix(): | |
| """Role comes from the owning module, so a conv kernel stays a conv kernel | |
| however its attribute happens to be named.""" | |
| payload = td.viz.weights(td.CNN(16, 1, LAT)) | |
| assert "conv" in _roles(payload) | |
| def test_ssm_parts_are_told_apart(): | |
| """S4D's decay, output map and skip are three different things in the | |
| diagram; collapsing them to 'parameter' would draw one picture for all.""" | |
| payload = td.viz.weights(td.S4D(16, 1, LAT, portable=True, d_state=8)) | |
| roles = _roles(payload) | |
| assert "ssm_decay" in roles # log_A_real / A_imag | |
| assert "ssm_out" in roles # C | |
| assert "skip" in roles # D | |
| def test_mamba_carries_conv_ssm_and_projections_together(): | |
| payload = td.viz.weights(td.Mamba(16, 1, LAT, portable=True, d_state=8)) | |
| roles = _roles(payload) | |
| assert {"conv", "ssm_decay", "linear", "skip"} <= roles | |
| def test_attention_projections_are_linear(): | |
| payload = td.viz.weights(td.Transformer(16, 1, LAT)) | |
| assert "linear" in _roles(payload) | |
| qkv = _by_name(payload, "qkv.weight") | |
| assert qkv["shape"][1] == 16 | |
| def test_a_small_tensor_is_drawn_whole_and_says_so(): | |
| model = td.CNN(8, 1, LAT, mixer_kwargs={"kernel_size": 3}) | |
| payload = td.viz.weights(model, max_units=64) | |
| conv = next(t for t in payload["layers"][0]["tensors"] if t["role"] == "conv") | |
| assert conv["sampled"] is False | |
| assert conv["stride"] == [1, 1] | |
| def test_a_large_tensor_is_strided_and_the_stride_is_reported(): | |
| payload = td.viz.weights(td.Transformer(64, 1, LAT), max_units=8) | |
| big = _by_name(payload, "qkv.weight") | |
| assert big["sampled"] is True | |
| assert big["rows"] <= 8 and big["cols"] <= 8 | |
| assert big["stride"][0] > 1 | |
| # The original shape survives, so the drawing can say what it is a sample of. | |
| assert big["shape"] == [192, 64] | |
| assert big["stats"]["n"] == 192 * 64 | |
| def test_drawn_values_are_actually_the_model_s_weights(): | |
| """A strided sample, not a summary: the values must match the tensor at the | |
| positions they claim to come from.""" | |
| torch.manual_seed(0) | |
| model = td.Transformer(32, 1, LAT) | |
| payload = td.viz.weights(model, max_units=8) | |
| entry = _by_name(payload, "proj.weight") | |
| real = dict(model.nd.mixers[0].named_parameters())["proj.weight"] | |
| rs, cs = entry["stride"] | |
| expected = real[::rs, ::cs].detach() | |
| got = torch.tensor(entry["values"]) | |
| assert got.shape == expected.shape | |
| assert torch.allclose(got, expected, atol=1e-4) | |
| def test_conv_kernels_keep_their_taps_as_columns(): | |
| """A (out, in, k) kernel folds to (out*in, k) so every tap survives β | |
| averaging them away would erase the receptive field the diagram draws.""" | |
| payload = td.viz.weights(td.CNN(8, 1, LAT, mixer_kwargs={"kernel_size": 5}), max_units=64) | |
| conv = next(t for t in payload["layers"][0]["tensors"] if t["role"] == "conv") | |
| assert conv["cols"] == 5 | |
| def test_stats_describe_the_whole_tensor_not_the_sample(): | |
| payload = td.viz.weights(td.Transformer(64, 1, LAT), max_units=4) | |
| entry = _by_name(payload, "qkv.weight") | |
| assert entry["stats"]["n"] == 192 * 64 # every weight, not the 16 drawn | |
| # The sample cannot contain anything larger than the whole tensor's maximum. | |
| drawn_max = max(max(abs(v) for v in row) for row in entry["values"]) | |
| assert entry["stats"]["absmax"] >= drawn_max | |
| def test_a_model_without_a_composition_is_refused(): | |
| with pytest.raises(TypeError, match="no `nd` composition"): | |
| td.viz.weights(torch.nn.Linear(4, 4)) | |
| def bundle(tmp_path, monkeypatch): | |
| """A stand-in for the built viewer. | |
| The JSON routes are library code and have nothing to do with the JavaScript | |
| β but `serve` refuses to start without a bundle, so without this the whole | |
| server went untested wherever the viewer had not been built, which is every | |
| CI job that is not the viewer job. A directory with an index.html in it is | |
| all the static handler needs. | |
| """ | |
| (tmp_path / "index.html").write_text("<!doctype html><title>stub</title>") | |
| monkeypatch.setattr(td.viz, "BUNDLE", tmp_path) | |
| return tmp_path | |
| def test_the_server_offers_weights_for_a_model(bundle): | |
| import urllib.request | |
| server = td.viz.serve(td.LSTM(16, 2, LAT), port=0) | |
| try: | |
| url = f"http://127.0.0.1:{server.server_port}/weights.json" | |
| payload = json.loads(urllib.request.urlopen(url).read()) | |
| assert payload["format"] == "torch-dimensions/weights" | |
| assert payload["layers"] | |
| finally: | |
| server.shutdown() | |
| def test_the_server_says_so_when_there_are_no_weights_to_serve(bundle): | |
| """Opened on a spec dict there are no parameters, and 404 is the honest | |
| answer β the viewer then tells the user to open a model instead.""" | |
| import urllib.error | |
| import urllib.request | |
| spec = td.LSTM(16, 1, LAT).to_spec() | |
| server = td.viz.serve(spec, port=0) | |
| try: | |
| url = f"http://127.0.0.1:{server.server_port}/weights.json" | |
| with pytest.raises(urllib.error.HTTPError) as excinfo: | |
| urllib.request.urlopen(url) | |
| assert excinfo.value.code == 404 | |
| finally: | |
| server.shutdown() | |
| # --- the operator: one picture every family can be drawn in ------------------- | |
| def _operator(model, size=12): | |
| return td.viz.weights(model, operator_size=size)["layers"][0]["operator"] | |
| def test_causal_families_reach_only_forwards(): | |
| """A TCN and an SSM are causal by construction, and the impulse response | |
| has to show it: nothing above the diagonal at all.""" | |
| for model in ( | |
| td.TCN(32, 1, LAT), | |
| td.S4D(32, 1, LAT, portable=True, d_state=8), | |
| td.LSTM(32, 1, LAT), | |
| ): | |
| assert _operator(model)["causal"] == pytest.approx(1.0, abs=1e-3) | |
| def test_a_centred_convolution_reaches_both_ways(): | |
| """`ConvMixer` is centred by default, so some influence runs backwards β | |
| the measurement must not report it as causal.""" | |
| assert _operator(td.CNN(32, 1, LAT))["causal"] < 0.999 | |
| def test_a_convolution_is_banded_and_tied(): | |
| """The two properties that make a convolution a convolution rather than a | |
| dense map: it reaches a fixed distance, and it is the *same* kernel at | |
| every position.""" | |
| op = _operator(td.CNN(32, 1, LAT, mixer_kwargs={"kernel_size": 3})) | |
| assert op["bandwidth"] <= 2 # kernel of 3 reaches one step either side | |
| assert op["tied"] > 0.9 # constant along each diagonal | |
| def test_an_ssm_carries_influence_past_the_diagonal(): | |
| """An SSM's tail is small but it is the whole mechanism: position t reaches | |
| later positions through a decaying state. `reach` is what keeps a | |
| threshold-based bandwidth from reporting it as a pointwise map.""" | |
| op = _operator(td.S4D(32, 1, LAT, portable=True, d_state=16), size=16) | |
| assert op["reach"] > 0.01 | |
| assert op["causal"] == pytest.approx(1.0, abs=1e-3) | |
| def test_attention_shows_no_structure_in_its_parameters(): | |
| """Attention's mixing is computed from the data, so an impulse about zero | |
| finds no fixed off-diagonal operator β which is the correct answer, not a | |
| failure, and the viewer says so in those words.""" | |
| op = _operator(td.Transformer(32, 1, LAT)) | |
| assert op["reach"] < 0.05 | |
| def test_the_operator_survives_a_mixer_whose_first_parameter_is_not_the_width(): | |
| """MambaMixer's first parameter is `A_log` of shape (d_inner, d_state), so | |
| inferring the probe width from parameter shapes fed it an 8-wide impulse | |
| where it wanted 32 β and the probe failed silently into no diagram.""" | |
| assert _operator(td.Mamba(32, 1, LAT, portable=True, d_state=8)) is not None | |
| def test_the_operator_can_be_turned_off(): | |
| payload = td.viz.weights(td.LSTM(16, 1, LAT), operator_size=0) | |
| assert payload["layers"][0]["operator"] is None | |
| def test_the_digest_shows_the_trained_weights_not_a_snapshot(): | |
| """The diagrams have to move when the model does. `weights()` reads the | |
| parameters at call time, so a viewer refreshing it during a run draws what | |
| the model holds now β checked against the live tensors, not just against | |
| "something changed".""" | |
| torch.manual_seed(0) | |
| lat = td.Lattice(shape=(4, 5), names=("h", "w"), time=True) | |
| model = td.LSTM(16, 2, lat, d_input=1) | |
| head = torch.nn.Linear(16, 1) | |
| opt = torch.optim.Adam([*model.parameters(), *head.parameters()], lr=0.05) | |
| def drawn(): | |
| payload = td.viz.weights(model, max_units=8, operator_size=8) | |
| entry = _by_name(payload, "rnn.weight_ih_l0") | |
| return torch.tensor(entry["values"]), entry["stride"] | |
| before, _ = drawn() | |
| x = torch.randn(2, 5, 4, 5, 1) | |
| for _ in range(20): | |
| loss = (head(model(x)) - x.cumsum(dim=3)).pow(2).mean() | |
| opt.zero_grad() | |
| loss.backward() | |
| opt.step() | |
| after, (rs, cs) = drawn() | |
| assert not torch.allclose(before, after) # training moved them | |
| live = dict(model.nd.mixers[0].named_parameters())["rnn.weight_ih_l0"] | |
| assert torch.allclose(after, live[::rs, ::cs].detach(), atol=1e-4) | |
| # --- the fallback paths, which are where a digest quietly goes wrong ---------- | |
| def test_roles_fall_back_sensibly_for_unusual_names(): | |
| """Role classification has to cope with mixers this library did not write. | |
| Owner type decides where it can; the name-based rules below it are the | |
| fallback, and an unrecognised tensor must land on a role rather than crash.""" | |
| from torch_dimensions.viz.weights import _role | |
| two_d = torch.zeros(3, 3) | |
| one_d = torch.zeros(3) | |
| assert _role("something.bias", one_d, None) == "bias" | |
| assert _role("A_log", one_d, None) == "ssm_decay" | |
| # `kernel.log_dt` is a timescale, not a decay matrix, and lands on the | |
| # generic 1-D role β which is what S4D actually reports for it. | |
| assert _role("kernel.log_dt", one_d, None) == "vector" | |
| assert _role("D", one_d, None) == "skip" | |
| assert _role("gamma", one_d, None) == "vector" # a plain 1-D parameter | |
| assert _role("kernel.B", two_d, None) == "ssm_in" | |
| assert _role("kernel.C", two_d, None) == "ssm_out" | |
| assert _role("A_imag", two_d, None) == "ssm_freq" # a frequency, not a decay | |
| assert _role("kernel.log_A_real", two_d, None) == "ssm_decay" | |
| assert _role("mystery", two_d, None) == "linear" # 2-D and nothing else fits | |
| def test_a_mixer_that_refuses_the_probe_yields_no_operator_rather_than_raising(): | |
| """The impulse probe feeds a shape the mixer may simply not accept. That is | |
| a picture that cannot be drawn, not an error that should sink the payload.""" | |
| from torch_dimensions.viz.weights import _operator | |
| class Picky(torch.nn.Module): | |
| def __init__(self): | |
| super().__init__() | |
| self.w = torch.nn.Parameter(torch.zeros(4, 4)) | |
| def forward(self, x): | |
| raise RuntimeError("not that shape") | |
| assert _operator(Picky(), 8, 4) is None | |
| # A width the composition could not supply is refused before probing. | |
| assert _operator(Picky(), 8, 0) is None | |
| def test_a_mixer_that_changes_the_shape_yields_no_operator(): | |
| """The operator only means anything if the mixer maps positions to | |
| positions; one that returns something else has no square to draw.""" | |
| from torch_dimensions.viz.weights import _operator | |
| class Reshaper(torch.nn.Module): | |
| def __init__(self): | |
| super().__init__() | |
| self.w = torch.nn.Parameter(torch.zeros(4, 4)) | |
| def forward(self, x): | |
| return x[:, :1] | |
| assert _operator(Reshaper(), 8, 4) is None | |
| def test_a_constant_mixer_has_no_structure_to_report(): | |
| """An all-zero response divides by nothing: the guards have to hold.""" | |
| from torch_dimensions.viz.weights import _operator | |
| class Zero(torch.nn.Module): | |
| def __init__(self): | |
| super().__init__() | |
| self.w = torch.nn.Parameter(torch.zeros(4, 4)) | |
| def forward(self, x): | |
| return torch.zeros_like(x) | |
| op = _operator(Zero(), 6, 4) | |
| assert op is not None | |
| assert op["absmax"] == 0.0 | |
| assert op["tied"] == 0.0 # nothing to be tied along | |
| def test_the_kernel_family_reports_its_kernels(): | |
| """`td.cafa` has no `mixers` at all β its per-axis kernels are the whole | |
| mechanism, and a payload that skipped them would report nothing.""" | |
| lat = td.Lattice(shape=(4, 5), names=("h", "w"), time=True) | |
| payload = td.viz.weights(td.LSTM(16, 2, lat, method=td.cafa)) | |
| assert payload["layers"], "a kernel-family model must still report parameters" | |
| def test_the_server_also_serves_the_spec_and_the_static_bundle(bundle): | |
| """The other two routes, which were untested for the same reason.""" | |
| import urllib.request | |
| server = td.viz.serve(td.LSTM(16, 2, LAT), port=0) | |
| try: | |
| base = f"http://127.0.0.1:{server.server_port}" | |
| spec = json.loads(urllib.request.urlopen(f"{base}/spec.json").read()) | |
| assert spec["layers"] | |
| assert b"stub" in urllib.request.urlopen(f"{base}/index.html").read() | |
| finally: | |
| server.shutdown() | |
| def test_serving_without_a_bundle_says_which_path_is_missing(tmp_path, monkeypatch): | |
| monkeypatch.setattr(td.viz, "BUNDLE", tmp_path / "nope") | |
| with pytest.raises(FileNotFoundError): | |
| td.viz.serve(td.LSTM(16, 1, LAT), port=0) | |
| # --- findings: measurements a reader can act on ------------------------------ | |
| def _health(model): | |
| return td.viz.weights(model, operator_size=0)["layers"][0]["health"] | |
| def test_an_ssm_reports_its_decay_rates_as_rates(): | |
| """Not as a per-step retention. The decay a state applies is | |
| exp(-rate * dt) and `dt` is a *different* learned tensor: at unit dt a | |
| Mamba state with rate 8 reads as instant forgetting, while with its | |
| learned dt of ~0.01 it retains 92% a step. Reporting the rate is true; | |
| reporting a retention without dt is not.""" | |
| notes = _health(td.Mamba(32, 1, LAT, portable=True, d_state=8)) | |
| text = " ".join(n["text"] for n in notes) | |
| assert "decay rates span" in text | |
| assert "dt" in text # the other half is named | |
| assert all(n["level"] == "ok" for n in notes) | |
| def test_uniform_decay_is_not_reported_as_a_fault_when_frequencies_differ(): | |
| """S4D-Lin gives every state the same real part on purpose and separates | |
| them by frequency. A check that warned here would fire on a correctly | |
| initialised S4D every time, and a diagnostic that cries wolf on the | |
| default configuration gets ignored.""" | |
| notes = _health(td.S4D(32, 1, LAT, portable=True, d_state=16)) | |
| assert [n["level"] for n in notes] == ["ok"] | |
| assert "frequency" in notes[0]["text"] | |
| def test_genuinely_degenerate_states_are_reported(): | |
| """Same decay *and* same frequency is a bank of states doing one state's | |
| job, and that must be caught β otherwise the check above is just silence.""" | |
| model = td.S4D(32, 1, LAT, portable=True, d_state=16) | |
| with torch.no_grad(): | |
| for mixer in model.nd.mixers: | |
| mixer.kernel.A_imag.zero_() | |
| assert any(n["level"] == "warn" for n in _health(model)) | |
| def test_dead_units_are_counted_against_the_tensor_s_own_scale(): | |
| """An absolute threshold means nothing across initialisations, so a dead | |
| unit is one whose peak weight is negligible relative to its own tensor.""" | |
| model = td.Transformer(32, 1, LAT) | |
| with torch.no_grad(): | |
| model.nd.mixers[0].proj.weight[:4] = 0.0 | |
| notes = _health(model) | |
| assert any("4 of 32 output units are dead" in n["text"] for n in notes) | |
| def test_the_frequency_parameter_is_not_mistaken_for_a_decay(): | |
| """`A_imag` sets how fast a state oscillates, not how fast it fades. | |
| Classifying it as decay made the health check read a frequency as a | |
| retention and call a healthy S4D instantly-forgetting.""" | |
| payload = td.viz.weights(td.S4D(16, 1, LAT, portable=True, d_state=8)) | |
| assert _by_name(payload, "kernel.A_imag")["role"] == "ssm_freq" | |
| assert _by_name(payload, "kernel.log_A_real")["role"] == "ssm_decay" | |
| def test_every_tensor_carries_a_histogram(): | |
| payload = td.viz.weights(td.LSTM(16, 1, LAT)) | |
| for tensor in payload["layers"][0]["tensors"]: | |
| hist = tensor["histogram"] | |
| assert sum(hist["bins"]) == tensor["stats"]["n"] | |
| assert hist["lo"] <= hist["hi"] | |