| """LinearNO attention block (Stage 2 / Gate B). |
| |
| Reimplemented from the equations of: |
| "Transolver is a Linear Transformer: Revisiting Physics-Attention through the Lens of |
| Linear Attention" — Hu, Liu, Qiao, Sun, Dou (NUDT), AAAI 2026, arXiv:2511.06294. |
| Reconstructed from the equations BEFORE the official code was released (github.com/HiPRL/LinearNO, |
| Jan 2026); we did not consult it. A post-hoc check confirms structural agreement (softmax axes, |
| associativity). Differences from the official Elasticity block: it adds a learnable per-head softmax |
| temperature (init 0.5, clamped [0.01,1]) that we omit, and uses one shared in_project_x lift + small |
| per-head q/k/v maps vs our full C->inner projections. Only the attention block differs from Transolver. |
| |
| Core idea: Physics-Attention is the special case of linear attention |
| ``Attention(Q,K,V) ~ phi(Q) (psi^T(K) V)`` in which (a) phi and psi come from the SAME linear |
| layer (differing only by normalization) and (b) there is an extra slice self-attention step. |
| LinearNO removes BOTH constraints: |
| 1. learn Q and K projections independently (break weight sharing), and |
| 2. drop the slice self-attention (identity). |
| |
| LinearNO(H) = phi(Q) @ ( psi(K)^T @ V ) |
| Q = H Wq ; K = H Wk ; V = Linear_V(H) |
| phi(Q) = softmax_over_M( Linear_Q(Q) ) # (N, M) rows sum to 1 <- softmax along M |
| psi(K) = softmax_over_N( Linear_K(K) ) # (N, M) cols sum to 1 <- softmax along N |
| |
| ### THE make-or-break detail (paper Table 6, Elasticity): |
| phi softmax over M (slices), psi softmax over N (points) -> 0.0050 (correct) |
| swapping these dims -> 0.0081..0.0112 (wrong). If LinearNO lands at 0.008-0.011, the |
| softmax dimensions are almost certainly swapped. |
| |
| Associativity: compute ``psi^T V`` first (M x d), then ``phi @ (...)`` -> O(N*M*d), linear in N. |
| |
| Two variants (see docs/RECONCILIATION.md for the parameter-count tension): |
| - "independent" (paper skeleton, default): separate Wq, Wk, Wv as full dim->inner |
| projections. Most literal to the plan's reference code. ~0.85M params (> baseline). |
| - "shared_qk": share one dim->inner base for the slice projections (phi, psi), keep a |
| separate dim->inner V; breaks slice-weight sharing via two separate small slice layers. |
| ~0.72M params (~= baseline). Use this to satisfy the Gate-B "<= baseline params" constraint. |
| An optional ``project_out=False`` drops the output projection (folds the per-head concat |
| directly), reaching ~0.59M (the plan's stated LinearNO size). |
| """ |
| from __future__ import annotations |
|
|
| import torch |
| import torch.nn as nn |
| from einops import rearrange |
|
|
|
|
| class LinearNO(nn.Module): |
| def __init__( |
| self, |
| dim, |
| heads=8, |
| dim_head=16, |
| slice_num=64, |
| dropout=0.0, |
| variant: str = "independent", |
| project_out: bool = True, |
| temperature: bool = False, |
| ): |
| super().__init__() |
| inner = heads * dim_head |
| self.h, self.m, self.dh = heads, slice_num, dim_head |
| self.variant = variant |
| self.project_out = project_out |
| self.temperature = temperature |
| if temperature: |
| |
| |
| self.temp_q = nn.Parameter(torch.ones(1, heads, 1, 1) * 0.5) |
| self.temp_k = nn.Parameter(torch.ones(1, heads, 1, 1) * 0.5) |
|
|
| if variant == "independent": |
| |
| self.to_q = nn.Linear(dim, inner, bias=False) |
| self.to_k = nn.Linear(dim, inner, bias=False) |
| self.to_v = nn.Linear(dim, inner, bias=False) |
| elif variant == "shared_qk": |
| |
| self.to_qk = nn.Linear(dim, inner, bias=False) |
| self.to_v = nn.Linear(dim, inner, bias=False) |
| else: |
| raise ValueError(f"unknown variant {variant!r} (expected 'independent' or 'shared_qk')") |
|
|
| |
| self.lin_q = nn.Linear(dim_head, slice_num) |
| self.lin_k = nn.Linear(dim_head, slice_num) |
|
|
| if project_out: |
| self.to_out = nn.Sequential(nn.Linear(inner, dim), nn.Dropout(dropout)) |
| else: |
| self.to_out = nn.Dropout(dropout) |
|
|
| def _heads(self, t, B, N): |
| return t.reshape(B, N, self.h, self.dh).permute(0, 2, 1, 3).contiguous() |
|
|
| def forward(self, x): |
| B, N, C = x.shape |
| if self.variant == "independent": |
| q = self._heads(self.to_q(x), B, N) |
| k = self._heads(self.to_k(x), B, N) |
| v = self._heads(self.to_v(x), B, N) |
| else: |
| base = self._heads(self.to_qk(x), B, N) |
| q = base |
| k = base |
| v = self._heads(self.to_v(x), B, N) |
|
|
| sq = self.lin_q(q) |
| sk = self.lin_k(k) |
| if self.temperature: |
| sq = sq / self.temp_q.clamp(0.01, 1.0) |
| sk = sk / self.temp_k.clamp(0.01, 1.0) |
| phi = sq.softmax(dim=-1) |
| psi = sk.softmax(dim=-2) |
| kv = torch.einsum("bhnm,bhnd->bhmd", psi, v) |
| out = torch.einsum("bhnm,bhmd->bhnd", phi, kv) |
| out = rearrange(out, "b h n d -> b n (h d)") |
| return self.to_out(out) |
|
|