File size: 5,012 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
"""Kernel composition: per-axis operators contracted into a joint one.

The other family. Where :mod:`~torch_dimensions.compose.scan` sweeps a 1-D
operator along one axis per layer, this builds one kernel per axis and
contracts them all in a single pass. On a dense lattice the result is exactly
the Kronecker product ``A_0 ⊗ A_1 ⊗ … ⊗ A_{n-1}`` — which is the whole point,
because it means cost is quadratic in *axial* size rather than in the number of
cells. Materializing one attention matrix per lattice line is what runs out of
memory at rank 4; this does not.

Every existing implementation hardcodes its contraction to one rank, via einsum
strings keyed by axis index. Here the contraction reuses the same fold as the
scan family, so it works at any rank with no per-rank table.
"""

from __future__ import annotations

from collections.abc import Sequence
from functools import reduce

import torch

from torch_dimensions.lattice import AxisSpec, Lattice

__all__ = ["axial_contract", "kron_operator"]

# A line's renormalization is degenerate when its signed mass has cancelled to
# below this fraction of its absolute mass. Relative, not absolute: an absolute
# epsilon has no idea what scale the kernel works at — a denominator of 1e-4
# passes any tiny fixed threshold and then amplifies by 1e4.
_REL = 1e-3


def axial_contract(
    x: torch.Tensor,
    lattice: Lattice,
    axis: AxisSpec,
    kernel: torch.Tensor,
    *,
    valid: torch.Tensor | None = None,
) -> torch.Tensor:
    """Contract ``kernel`` into ``x`` along ``axis``.

    ``kernel`` is ``(A, A)`` for the swept axis, or anything broadcasting to
    ``(M, A, A)`` where ``M`` is the folded batch. Output position ``q`` becomes
    ``sum_k kernel[q, k] * x[k]`` along that axis, every other axis untouched.

    ``valid`` is the broadcast cell mask. When given, absent cells are zeroed
    *before* the contraction and each output line is then renormalized by the
    kernel mass that landed on cells which actually exist, so an output stays a
    convex combination of present values. That per-line rescale is the one
    departure from a strict Kronecker product, and it costs ``O(N · A)``
    elementwise work rather than ``O(N · A²)`` of score memory — which is
    precisely why the factorized path survives where a per-line implementation
    does not.

    The zeroing has to happen on *every* contraction, not once at entry. A
    contraction leaves absent cells holding a weighted average of present ones
    — legitimate scratch, but nonzero. Carry that into the next axis and its
    numerator sums those scratch values while its denominator still counts only
    present keys, so the result is no longer a convex combination and inflates
    with sparsity. Zeroing once at entry is only sufficient for a rank-1
    lattice.
    """
    if valid is not None:
        x = x * valid

    seq, restore = lattice.to_sequence(x, axis)  # (M, A, H)
    out = kernel @ seq

    if valid is not None:
        mass, _ = lattice.to_sequence(valid.expand(*x.shape[:-1], 1), axis)  # (M, A, 1)
        den = kernel @ mass
        # Degeneracy is *cancellation*, and cancellation is relative. A signed
        # kernel — LeakyReLU-gated scores, say — can cancel its mass to a tiny
        # residual that any absolute epsilon waves through and that then
        # amplifies by orders of magnitude; a genuinely small mass, by
        # contrast, divides out exactly because the numerator carries the same
        # factor. So compare the signed mass against the absolute mass that
        # went into it, and leave a line unscaled when almost everything
        # cancelled. `<=` and not `<`: a dead line has both at exactly zero,
        # and its numerator is zero too, so unscaled keeps it zero.
        den_abs = kernel.abs() @ mass
        den = torch.where(den.abs() <= _REL * den_abs, torch.ones_like(den), den)
        # No nan_to_num here. The where-guard already keeps |den| >= eps, so
        # this division cannot create a fresh NaN or inf — the only NaNs that
        # could reach a nan_to_num are ones already in `x`, and zeroing those
        # would silently launder a diverging model into finite numbers mid-
        # network. A NaN that arrives must leave; that is what makes divergence
        # debuggable.
        out = out / den

    return lattice.from_sequence(out, restore)


def kron_operator(kernels: Sequence[torch.Tensor]) -> torch.Tensor:
    """The joint operator per-axis kernels are equivalent to, built explicitly.

    Only usable on small lattices — it is ``(∏S, ∏S)``, which is exactly the
    cost the factorized path exists to avoid. It exists so the factorization can
    be *checked* against the thing it claims to equal, rather than against
    another call to itself.
    """
    kernels = list(kernels)
    if not kernels:
        raise ValueError("need at least one kernel; the empty product has no operator shape")
    return reduce(torch.kron, kernels)