File size: 17,747 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 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 | """`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))
@pytest.fixture
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"]
|