torch-dimensions / examples /custom_mixer.py
Celsia's picture
Upload folder using huggingface_hub
ecc81b3 verified
Raw
History Blame Contribute Delete
5.55 kB
"""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__()
# Parameterize the *logit* of the decay so that `a` stays in (0, 1)
# under unconstrained gradient descent. Clamping instead would give
# zero gradient exactly where the model most wants to move.
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)
# The honest sequential form. A cumulative-product trick is faster and
# is what a real mixer would use; this stays a loop because the point
# of the example is the contract, not the kernel.
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):
# One layer per axis, so that at rank 1 the model *is* a single layer —
# which is what makes the equivalence check below a real comparison
# rather than a comparison against a different model.
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))
# Because it is registered, it round-trips through config and checkpoints
# with no extra code.
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()