| """A complete new mixer, end to end — the worked example for docs/adding-a-mixer.md. |
| |
| python examples/custom_mixer.py |
| |
| An exponentially-weighted moving average with a learned, per-channel decay: |
| small enough to read in one sitting, real enough to be a genuine sequence |
| model (it is the diagonal SSM with the state dimension set to one), and it has |
| the two properties that make an extension interesting — a parameter that must |
| receive gradient, and an output that depends on order, so an axis bug cannot |
| hide. |
| |
| What this file demonstrates, in order: |
| |
| 1. the mixer itself — ``(M, A, H) -> (M, A, H)``, nothing else; |
| 2. running the library's own conformance suite against it; |
| 3. registering it as a model kind so configs and checkpoints can name it; |
| 4. using it at rank 3, sparse, with a schedule. |
| |
| `tests/test_examples.py` runs all of it, so this file cannot rot into a |
| plausible-looking snippet that no longer works. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import torch |
| import torch.nn as nn |
|
|
| import torch_dimensions as td |
| from torch_dimensions.models.base import LatticeModel |
|
|
|
|
| class EMAMixer(nn.Module): |
| """Per-channel exponential moving average: ``y_t = a * y_{t-1} + (1-a) * x_t``. |
| |
| The entire mixer contract is the shape: ``(M, A, H)`` in, the same out, |
| where ``M`` is the batch times every unswept axis and ``A`` is the swept |
| axis. A mixer is never told which axis it is on, what rank the lattice is, |
| or which cells are absent — the composition layer owns all of that, which |
| is exactly why one implementation works at every rank. |
| |
| Direction is not a mixer's business either: a backward sweep arrives |
| already flipped. So this is written causally and gets bidirectionality from |
| the schedule, for free. |
| """ |
|
|
| def __init__(self, d_model: int, init_halflife: float = 4.0) -> None: |
| super().__init__() |
| |
| |
| |
| a0 = 0.5 ** (1.0 / init_halflife) |
| self.decay_logit = nn.Parameter( |
| torch.full((d_model,), float(torch.logit(torch.tensor(a0)))) |
| ) |
| self.out = nn.Linear(d_model, d_model) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| a = torch.sigmoid(self.decay_logit) |
| |
| |
| |
| out = [] |
| state = torch.zeros_like(x[:, 0]) |
| for t in range(x.shape[1]): |
| state = a * state + (1 - a) * x[:, t] |
| out.append(state) |
| return self.out(torch.stack(out, dim=1)) |
|
|
|
|
| class EMA(LatticeModel): |
| """The model: the library's composition layer plus the mixer above. |
| |
| Subclassing `LatticeModel` is what supplies `nd_method=`, `plan=`, |
| `lattice=`, `d_input=`, `.config`, `.save()`, and `.to_spec()`. A mixer |
| author writes the class body and inherits the rest. |
| """ |
|
|
| _mixer = EMAMixer |
|
|
|
|
| def run_conformance() -> None: |
| """The same seven checks the library runs on itself. |
| |
| This is the point of `td.testing` being public API rather than test |
| scaffolding: a new mixer gets held to the identical standard, including |
| the checks that catch axis bugs — rank-1 equivalence against the bare |
| mixer, absent-cell inertia, and covariance with axis storage order. |
| """ |
|
|
| def factory(lattice, d_model, plan=None): |
| |
| |
| |
| return EMA(d_model, len(lattice.axis_names), lattice, plan=plan) |
|
|
| def reference(block, x): |
| """What one pre-norm residual layer around the bare mixer computes. |
| |
| Supplying this turns the rank-1 equivalence check from a skip into a |
| real comparison: on a lattice with one axis, the whole N-D apparatus |
| must reduce to exactly the 1-D model, bitwise. |
| """ |
| return x + block.nd.mixers[0](block.nd.norms[0](x)) |
|
|
| report = td.testing.check_block(factory, reference=reference) |
| print(report) |
| assert report, "the example mixer does not conform" |
|
|
| trained = td.testing.check_trainable(factory, d_model=16, steps=120) |
| print(trained) |
| assert trained, "the example mixer does not learn the axial task" |
|
|
|
|
| def use_it() -> None: |
| """Register it, then use it exactly like a built-in model.""" |
| if "ema" not in td.list_models(): |
| td.register_model("ema", EMA) |
|
|
| lattice = td.Lattice( |
| shape=(4, 5, 3), |
| names=("depth", "row", "col"), |
| valid=torch.rand(4, 5, 3) > 0.3, |
| time=True, |
| ) |
| plan = td.ScanPlan.paired(lattice.axis_names, n_layers=8, bidirectional=("depth", "row", "col")) |
| model = EMA(d_model=32, lattice=lattice, plan=plan, d_input=2) |
|
|
| x = torch.randn(2, 6, *lattice.shape, 2) |
| print("output:", tuple(model(x).shape)) |
| print("coverage:\n", plan.coverage(lattice)) |
|
|
| |
| |
| rebuilt = td.build({"kind": "ema", **model.config}) |
| print("rebuilt from config:", type(rebuilt).__name__, rebuilt.config["n_layers"], "layers") |
|
|
|
|
| if __name__ == "__main__": |
| run_conformance() |
| use_it() |
|
|