| """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 |
| from torch_dimensions.compose.flatten import Flatten |
| from torch_dimensions.compose.kernel import axial_contract, kron_operator |
| from torch_dimensions.compose.scan import AxialScan, axial_apply |
|
|
| __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 |
|
|