"""Convolutional models: the separable CNN, and the temporal CNN. These are the library's first models that are not sequence models at all. ``td.CNN`` has no state, no direction, and no notion of "so far" — it is a stack of local windows. That it fits the same abstraction as ``td.Mamba`` without either bending is the strongest evidence the abstraction is about *lattices* rather than about sequences: td.CNN(64, 6, lattice) # separable N-D convolution td.TCN(64, 6, lattice) # causal, dilated, doubling per axis td.Mamba(64, 6, lattice) # selective scan **What "N-D" means for a CNN, exactly.** Sweeping a 1-D convolution along each axis in turn is a *separable* convolution: with linear mixers it equals one N-D convolution whose kernel is the outer product of the per-axis kernels. That is not an approximation and not a claim — it is checked against ``F.conv2d`` and ``F.conv3d`` in ``tests/test_conv.py``, exactly as the kernel family's factorization is checked against ``torch.kron``. The cost is what separability always costs: a rank-1 kernel, ``r·k`` parameters instead of ``k^r``, so a separable stack cannot represent a diagonal edge detector that a full kernel can. Depth plus the pointwise channel mixing is what buys most of it back, and that is the same bargain MobileNet and ConvNeXt make in 2-D. **Why this one composes so cleanly and Mamba does not.** A convolution is LTI: per-axis operators commute, so the sweep order is irrelevant and direction is nearly free. A selective scan is not, so for it the order and direction are architectural choices with real consequences — which is why ``ScanPlan`` exists. LTI.md measures both claims. """ from __future__ import annotations from torch_dimensions.mixers.conv import ConvMixer, TCNMixer from torch_dimensions.models.base import LatticeModel __all__ = ["CNN", "TCN", "CNNND", "TCNND"] class CNN(LatticeModel): """An axially-separable convolutional network over a sequence or lattice. Args: d_model: feature width, and the output width. n_layers: how many sweeps. With a lattice, layers cycle through its axes unless ``plan`` says otherwise — so a rank-2 lattice with ``n_layers=6`` gets three passes over each of the two axes. lattice: omit for an ordinary 1-D convolutional stack. kernel_size: window width per axis. Must be odd; see :class:`~torch_dimensions.mixers.conv.ConvMixer`. depthwise: depthwise-separable convolutions (one filter per channel plus a pointwise mix). Separable across channels *and* across axes — the cheap corner of the design space. activation: pointwise nonlinearity, or ``None`` for a strictly linear (and therefore strictly LTI) stack. dilation_base: grow the dilation each time an axis is swept again. ``1`` keeps it fixed; use :class:`TCN` for the doubling schedule. Direction is meaningless for a centred convolution — a backward sweep arrives flipped and the kernel is symmetric in its own frame, so ``bidirectional`` buys a mirrored filter and nothing else. That is not a limitation to work around; it is what "no notion of order" means, and LTI.md measures it as ``forward − reverse`` at the noise floor. """ _mixer = ConvMixer def __init__( self, d_model: int, n_layers: int = 1, lattice=None, *, kernel_size: int = 3, depthwise: bool = False, activation: str | None = "gelu", dilation_base: int = 1, **kw, ): mixer_kwargs = { "kernel_size": kernel_size, "depthwise": depthwise, "activation": activation, "dilation_base": dilation_base, **kw.pop("mixer_kwargs", {}), } super().__init__(d_model, n_layers, lattice, mixer_kwargs=mixer_kwargs, **kw) class TCN(LatticeModel): """Temporal convolutional network — causal, dilated, doubling per axis. The 1-D model of Bai, Kolter & Koltun (2018), and its N-D generalization for free: each layer runs a causal dilated convolution along one axis, and the dilation doubles every time that axis comes round again. On a rank-3 lattice under a cyclic plan the dilation along each axis is ``1, 2, 4, …`` independently — the receptive field grows exponentially *per axis*, which is the thing the 1-D TCN is famous for and which nothing in the N-D literature states. Causality is bitwise, not approximate: padding is left-only, so no arithmetic involving a later position reaches an earlier one. The test suite holds it to equality. Check the model can actually see across the lattice before training it:: td.receptive_field(td.TCN(64, 6, lattice)) # {'h': {'span': 29, 'size': 32, 'covers': False, 'layers': 3}, ...} Args: kernel_size: window width; may be even, since causal padding has a defined side. dilation_base: ``2`` is the published schedule. ``1`` disables growth. n_conv: convolutions per block; ``2`` is the published block. """ _mixer = TCNMixer def __init__( self, d_model: int, n_layers: int = 1, lattice=None, *, kernel_size: int = 3, dilation_base: int = 2, n_conv: int = 2, activation: str | None = "relu", **kw, ): mixer_kwargs = { "kernel_size": kernel_size, "dilation_base": dilation_base, "n_conv": n_conv, "activation": activation, **kw.pop("mixer_kwargs", {}), } super().__init__(d_model, n_layers, lattice, mixer_kwargs=mixer_kwargs, **kw) CNNND = CNN TCNND = TCN """Aliases. Unlike ``S4ND``/``MambaND`` these need no separate class with a mandatory ``dim``: those names denote specific published models, so the library refuses to let ``S4ND(dim=1)`` quietly be S4. "CNND" denotes nothing, and a 2-D CNN is not at risk of being mistaken for a 1-D one."""