File size: 5,549 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 | """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()
|