File size: 7,112 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 | """N-D composition strategies, and the registry that names them.
An **nd_method** decides how a model's extra axes are handled. It receives the
model's 1-D mixer and the lattice, and returns a module. Signature::
nd_method(mixer, plan, lattice, d_model, *, dropout, chunk, **kw) -> nn.Module
Four strategies fit that contract, and they differ in *who handles which
axis*:
``td.axial_scan``
The mixer sweeps every axis, one per layer. Mamba-ND and the N-D RNNs.
``td.axial_attention`` / ``td.cafa`` (Phase 6)
Per-axis kernels contracted together β the joint operator is a Kronecker
product. The operator *is* the kernel, so there is no mixer slot: axial
attention and CaFA are their own thing, not an LSTM wearing a hat.
``td.flatten``
No factorization at all: every axis folds into one sequence and the mixer
sees the whole lattice. What a Vision Transformer does, and the baseline
the axial methods exist to beat β most expressive, quadratic in *cells*
rather than in axis length, and the only method where a sparse lattice is
genuinely cheaper rather than merely masked.
hybrid β the same two kernel names, given a mixer (Phase 6)
The mixer owns the sequence axis; a kernel-family operator owns the
lattice axes. Attention or CaFA mixes across the grid at each timestep,
then the RNN or SSM runs along time. This is the shape of most real
forecasting models over a categorical lattice, and it is why
``LSTM(nd_method="cafa")`` is meaningful β CaFA never consumes the LSTM,
it just handles the axes the LSTM does not.
Strategies are plain functions, exported at top level: ``td.axial_scan`` today,
``td.axial_attention`` and ``td.cafa`` when the kernel family lands. A
user-supplied function is a first-class strategy on exactly the same footing;
the string registry exists only because YAML cannot hold a callable.
"""
from __future__ import annotations
from collections.abc import Callable
import torch.nn as nn
from torch_dimensions.lattice import Lattice
from torch_dimensions.plan import ScanPlan
from torch_dimensions.compose.attention import AxialKernel # isort: skip
from torch_dimensions.compose.flatten import Flatten # isort: skip
from torch_dimensions.compose.kernel import axial_contract, kron_operator # isort: skip
from torch_dimensions.compose.scan import AxialScan, axial_apply # isort: skip
__all__ = [
"ND_METHODS",
"AxialKernel",
"AxialScan",
"Flatten",
"axial_apply",
"axial_contract",
"axial_attention",
"axial_scan",
"cafa",
"flatten",
"kron_operator",
"register_nd_method",
"resolve_nd_method",
]
def axial_scan(
mixer: Callable[[], nn.Module] | nn.Module,
plan: ScanPlan,
lattice: Lattice,
d_model: int,
**kwargs,
) -> nn.Module:
"""Sweep the mixer along one axis per layer β the default strategy.
``td.LSTM(..., nd_method=td.axial_scan)``. This is the Mamba-ND / MDRNN
shape: the model's own 1-D operator handles every axis, and the schedule
decides which axis and which direction each layer gets.
A strategy is a plain function, not a class, because not all of them wrap
a single module β a hybrid strategy composes two operators over different
axes. Passing your own function here needs no registration.
"""
return AxialScan(mixer=mixer, plan=plan, lattice=lattice, d_model=d_model, **kwargs)
def axial_attention(
mixer: Callable[[], nn.Module] | nn.Module | None,
plan: ScanPlan,
lattice: Lattice,
d_model: int,
**kwargs,
) -> nn.Module:
"""Per-line attention kernels over the spatial axes; the mixer runs along
time β the hybrid form. ``td.LSTM(..., nd_method=td.axial_attention)``.
Each layer contracts every spatial axis with a per-line softmax kernel
(plus a learned relative-position bias), then the model's own 1-D mixer
sweeps the time axis. The attention never consumes the mixer; it handles
the axes the mixer does not.
"""
return AxialKernel(
mixer=mixer, plan=plan, lattice=lattice, d_model=d_model, per_line=True, **kwargs
)
def cafa(
mixer: Callable[[], nn.Module] | nn.Module | None,
plan: ScanPlan,
lattice: Lattice,
d_model: int,
**kwargs,
) -> nn.Module:
"""Factorized attention (CaFA): pooled per-axis kernels, Kronecker-
structured, with the mixer along time. ``td.LSTM(..., nd_method=td.cafa)``.
Cheaper than :func:`axial_attention` β one kernel per axis per (batch,
timestep) instead of per line β and more structured: the joint operator
is exactly a Kronecker product of the per-axis kernels. ``gate=`` selects
``"softmax"`` (default) or ``"leaky_relu"`` (the CaFA paper's default).
"""
return AxialKernel(
mixer=mixer, plan=plan, lattice=lattice, d_model=d_model, per_line=False, **kwargs
)
def flatten(
mixer: Callable[[], nn.Module] | nn.Module,
plan: ScanPlan,
lattice: Lattice,
d_model: int,
**kwargs,
) -> nn.Module:
"""No factorization: fold every axis into one sequence for the mixer.
``td.Transformer(..., nd_method=td.flatten)`` is a Vision Transformer's
composition β attention over all cells at once, not axis by axis β and
``td.ViT`` is exactly that with a patch embedding in front.
This is the baseline the axial methods exist to beat. It is the most
expressive of the three and the only one that is quadratic in *cells*
rather than in axis length, so it wins on small lattices and cannot be
allocated on large ones. On a sparse lattice it is also the only method
where absent cells are a saving: they are dropped from the sequence rather
than masked within it.
"""
return Flatten(mixer=mixer, plan=plan, lattice=lattice, d_model=d_model, **kwargs)
ND_METHODS: dict[str, Callable[..., nn.Module]] = {
"axial_attention": axial_attention,
"axial_scan": axial_scan,
"cafa": cafa,
"flatten": flatten,
}
def register_nd_method(name: str, factory: Callable[..., nn.Module]) -> None:
"""Make a composition strategy addressable by name.
Only needed for config files, which cannot hold a Python callable. In
Python, pass the function itself.
"""
if name in ND_METHODS:
raise ValueError(f"nd_method {name!r} is already registered")
ND_METHODS[name] = factory
def resolve_nd_method(method: str | Callable[..., nn.Module]) -> Callable[..., nn.Module]:
"""Accept either a registered name or any callable with the strategy
signature. Passing a callable directly is the point β a user's own
traversal needs no registration."""
if isinstance(method, str):
if method not in ND_METHODS:
raise ValueError(
f"unknown nd_method {method!r}; registered: {sorted(ND_METHODS)}. "
"Pass a callable to use one that is not registered."
)
return ND_METHODS[method]
if not callable(method):
raise TypeError(f"nd_method must be a name or a callable; got {type(method).__name__}")
return method
|