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