File size: 7,296 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 | """The shared shape of every model in the library.
A model here is nothing but: an optional input projection, a 1-D mixer type,
and an ``nd_method`` deciding how that mixer covers the lattice. One class
holds that recipe; ``LSTM``, ``GRU``, ``S4D``, and ``Mamba`` differ only in
``_mixer``. This is the design's central claim made literal β an N-D model is
a 1-D mixer plus a plan for sweeping it.
"""
from __future__ import annotations
import warnings
from collections.abc import Callable
from functools import partial
from typing import cast
import torch
import torch.nn as nn
from torch_dimensions.compose import axial_scan, resolve_nd_method
from torch_dimensions.lattice import AxisSpec, Lattice
from torch_dimensions.plan import ScanPlan
__all__ = ["LatticeModel"]
class LatticeModel(nn.Module):
"""Base for the model family. Subclasses set ``_mixer``.
With no lattice this is an ordinary sequence model β a lattice with no
spatial axes has an identity permutation, so the 1-D case is the N-D case
with nothing to fold.
"""
_mixer: type[nn.Module]
def __init__(
self,
d_model: int,
n_layers: int = 1,
lattice: Lattice | None = None,
*,
d_input: int | None = None,
nd_method: str | Callable[..., nn.Module] = axial_scan,
method: str | Callable[..., nn.Module] | None = None,
plan: ScanPlan | None = None,
bidirectional: bool | AxisSpec | list[AxisSpec] = False,
dropout: float = 0.0,
chunk: int | None = None,
mixer_kwargs: dict | None = None,
mixer: type[nn.Module] | None = None,
**method_kwargs,
) -> None:
super().__init__()
# `method` is the short spelling of `nd_method` β the method of
# multidimensionality. Both name the same thing; giving both is a
# contradiction waiting to happen and is refused.
if method is not None:
if nd_method is not axial_scan:
raise ValueError("pass either `method` or `nd_method`, not both")
nd_method = method
# No lattice means a single dynamic axis: an ordinary sequence.
self.lattice = lattice if lattice is not None else Lattice(shape=(), time=True)
if plan is None:
plan = ScanPlan.cyclic(self.lattice.axis_names, n_layers, bidirectional=bidirectional)
else:
if bidirectional is not False:
raise ValueError("pass either `plan` or `bidirectional`, not both")
# A plan *is* the layer schedule, so it fixes the depth, and it
# wins. But winning *silently* over a disagreeing n_layers would
# ship a model shallower (or deeper) than requested β the same
# silent-downgrade failure the schedule machinery exists to make
# loud. A warning rather than an error because generic builders
# legitimately fill n_layers unconditionally and add a plan only
# sometimes; n_layers=1 is the default and passes untouched.
if n_layers != 1 and n_layers != len(plan):
warnings.warn(
f"n_layers={n_layers} is ignored: the given plan has {len(plan)} steps "
"and a plan determines the depth",
UserWarning,
stacklevel=2,
)
# An input projection only when the data is not already d_model wide.
# Without it every caller writes the same nn.Linear, which is friction
# for no gain in purity.
self.in_proj = nn.Linear(d_input, d_model) if d_input is not None else nn.Identity()
# `mixer=` substitutes the 1-D operator without touching anything else,
# which is what makes a model debuggable: swapping in
# `td.testing.Recorder` answers "which axis did layer 3 sweep" on the
# real model rather than on a reconstruction of it. The class's own
# `_mixer` stays the default and the recorded config still names the
# class, so a substituted model is visibly not the stock one.
mixer_cls = mixer if mixer is not None else self._mixer
# A substituted mixer cannot be written into the recipe β a class is
# not serializable β so a checkpoint would silently rebuild with the
# stock one and hand back a *different model* that loads without
# complaint. `save()` refuses instead; see config._checkpoint_header.
self._substituted_mixer = None if mixer is None else mixer.__name__
self.nd = resolve_nd_method(nd_method)(
mixer=partial(mixer_cls, d_model, **(mixer_kwargs or {})),
plan=plan,
lattice=self.lattice,
d_model=d_model,
dropout=dropout,
chunk=chunk,
**method_kwargs,
)
# The construction recipe, recorded so a checkpoint can rebuild this
# model without the user re-specifying anything (td.save / td.load).
# Plain JSON-able types throughout β the validity mask becomes a
# nested list β so a config can also live in YAML unchanged. n_layers
# is recorded as the plan's true depth: the plan is the schedule, and
# a recipe that could disagree with itself would not be a recipe.
from torch_dimensions.config import lattice_to_dict, nd_method_name
self.config: dict = {
"d_model": d_model,
"n_layers": len(plan),
"lattice": None if lattice is None else lattice_to_dict(self.lattice),
"plan": plan.to_dict(),
"nd_method": nd_method_name(nd_method),
"d_input": d_input,
"dropout": dropout,
"chunk": chunk,
"mixer_kwargs": dict(mixer_kwargs) if mixer_kwargs else {},
**method_kwargs,
}
@property
def plan(self) -> ScanPlan:
return cast(ScanPlan, self.nd.plan)
def save(self, path) -> None:
"""Write this model β architecture and weights β to one checkpoint
file that :func:`torch_dimensions.load` can rebuild it from."""
from torch_dimensions.config import save
save(self, path)
def receptive_field(self) -> dict[str, dict[str, object]]:
"""How far along each axis this model can actually see.
``inf`` for mixers that span their axis in one layer (RNNs, SSMs,
attention); a finite number for convolutions, where it is a real
constraint worth checking before training rather than after. See
:func:`torch_dimensions.receptive_field`.
"""
from torch_dimensions.mixers.conv import axis_receptive_field
return axis_receptive_field(self)
def to_spec(self) -> dict:
"""A JSON-able description of this model's N-D architecture.
Derived without a forward pass, so it can be taken before any data
exists. See VIEWER.md.
"""
from torch_dimensions.spec import scan_model_spec
return scan_model_spec(self)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""``(B, [T,] *shape, d_input or d_model)`` in, ``d_model`` out.
With no lattice that is just ``(B, T, ...)``.
"""
return self.nd(self.in_proj(x))
|