burn_mamba/modules/state_moments.rs
1//! # Pooled SSM-state moments (for state participation ratios)
2//!
3//! [`StateMoments`] carries the **exact** first and second moments of every
4//! per-token SSM state `hₜ ∈ ℝ^{per_head_dim × state_rank}` of a `forward`
5//! pass, treating each state **row** (one `(token, per_head_dim)` pair) as a
6//! sample in `ℝ^{state_rank}` — the same sample convention a token-by-token
7//! `step` loop reading the cache would produce.
8//!
9//! The moments are all a participation ratio (PR, a differentiable effective
10//! rank) needs: with `Σ` the sample covariance, `PR = (tr Σ)² / tr(Σ²)` and
11//! both traces derive from `Σ hhᵀ`, `Σ h`, and the sample count. Storing raw
12//! **sums** (not averages) makes moments *composable*: [`StateMoments::merge`]
13//! pools across forward calls (streaming chunks, eval batches) and the PR of
14//! the merged moments is the exact pooled PR.
15
16use burn::prelude::*;
17
18/// How the `state_rank` axis of a (realified) SSM state groups into complex /
19/// quaternionic coordinates — the realification layout the Mamba-3 rotation
20/// applies to B/C (and hence to the state). Consumed by
21/// [`StateMoments::pr_complex`].
22///
23/// Construct it with `Mamba3::state_pairing()` (the single source of truth,
24/// mirroring exactly what `rotate_bc_forward` applies) — never re-derive the
25/// layout by hand.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum StatePairing {
28 /// No rotation pairing: every coordinate is real (`rope_fraction = 0`);
29 /// [`StateMoments::pr_complex`] then equals [`StateMoments::pr`].
30 Real,
31 /// Complex pairs, interleaved (NeoX-style; Mamba-3 SISO): coordinate `2a`
32 /// is the real and `2a + 1` the imaginary part of complex coordinate `a`,
33 /// for `a < num_pairs`; the tail `[2·num_pairs, state_rank)` stays real.
34 ComplexInterleaved {
35 /// Number of rotated complex pairs (`rope_dim / 2`).
36 num_pairs: usize,
37 },
38 /// Complex pairs, half-and-half (GPT-J-style; Mamba-3 MIMO): coordinate
39 /// `a` is the real and `state_rank/2 + a` the imaginary part, for
40 /// `a < num_pairs`; the remainder of **both** halves stays real.
41 ComplexHalfHalf {
42 /// Number of rotated complex pairs (`rope_dim / 2`).
43 num_pairs: usize,
44 },
45 /// Quaternion blocks (Mamba-3 `Quaternion4D`): coordinates `[4j, 4j + 4)`
46 /// form quaternion coordinate `j` (components ordered `w, x, y, z`), for
47 /// `j < num_blocks`; the tail stays real.
48 QuaternionBlocks {
49 /// Number of rotated quaternion blocks (`rope_width / 4`).
50 num_blocks: usize,
51 },
52}
53
54/// Raw (un-normalised) first/second moments of the per-token SSM states of
55/// one block's `forward` pass, pooled over tokens and `per_head_dim` rows.
56///
57/// Produced by `forward_with_state_moments` — closed-form for Mamba-2
58/// (`Mamba2SsdInput::state_moments`, no state materialisation) and serial
59/// chunkwise for Mamba-3 (`Mamba3MomentsInput::state_moments_phys`, the
60/// per-token **physical-frame** states of the complex SSM).
61#[derive(Debug, Clone)]
62pub struct StateMoments {
63 /// Second-moment (Gram) sum `Σₜ hₜᵀ hₜ` — the `ᵀ` contraction pools the
64 /// `per_head_dim` rows, the `Σₜ` the (unpadded) tokens.
65 ///
66 /// # Shape
67 /// - `[batch, nheads, state_rank, state_rank]`
68 pub m2_bhrr: Tensor<4>,
69 /// First-moment sum `Σₜ Σₚ hₜ[p, :]`.
70 ///
71 /// # Shape
72 /// - `[batch, nheads, state_rank]`
73 pub m1_bhr: Tensor<3>,
74 /// Samples pooled into each `(batch, head)` slice:
75 /// `valid_tokens · per_head_dim` (grows additively under [`Self::merge`]).
76 pub count: usize,
77}
78
79impl StateMoments {
80 /// Pool two moment sets (e.g. consecutive streamed `forward` calls, or
81 /// separate eval batches). PR of the merged moments is the exact PR of
82 /// the union of samples.
83 pub fn merge(self, other: Self) -> Self {
84 assert_eq!(
85 self.m2_bhrr.dims(),
86 other.m2_bhrr.dims(),
87 "merged state moments must share [batch, nheads, state_rank]"
88 );
89 Self {
90 m2_bhrr: self.m2_bhrr + other.m2_bhrr,
91 m1_bhr: self.m1_bhr + other.m1_bhr,
92 count: self.count + other.count,
93 }
94 }
95
96 /// Fold the batch dimension into the samples (batch-pooled moments with
97 /// `batch = 1`), matching diagnostics that treat every
98 /// `(token, batch, per_head_dim)` triple as one sample.
99 pub fn pool_batch(self) -> Self {
100 let [batch, _h, _r, _r2] = self.m2_bhrr.dims();
101 Self {
102 m2_bhrr: self.m2_bhrr.sum_dim(0),
103 m1_bhr: self.m1_bhr.sum_dim(0),
104 count: self.count * batch,
105 }
106 }
107
108 /// Participation ratio `(tr Σ)² / tr(Σ²)` of the sample covariance, per
109 /// `(batch, head)` slice; `center` subtracts the sample mean (`Σ` becomes
110 /// the centered covariance instead of the raw second moment).
111 ///
112 /// Differentiable (two traces, no eigendecomposition).
113 ///
114 /// # Shape
115 /// - output: `[batch, nheads]`
116 pub fn pr(&self, center: bool) -> Tensor<2> {
117 let [batch, nheads, state_rank, _] = self.m2_bhrr.dims();
118 assert!(self.count > 0, "state moments hold no samples");
119 let device = self.m2_bhrr.device();
120 let samples = self.count as f32;
121
122 let sigma_bhrr = {
123 let m2_bhrr = self.m2_bhrr.clone() / samples;
124 if center {
125 let mu_bhr = self.m1_bhr.clone() / samples;
126 let outer_bhrr =
127 mu_bhr.clone().unsqueeze_dim::<4>(3) * mu_bhr.unsqueeze_dim::<4>(2);
128 m2_bhrr - outer_bhrr
129 } else {
130 m2_bhrr
131 }
132 };
133
134 // tr Σ via an identity mask; tr(Σ²) = ‖Σ‖²_F (Σ is symmetric).
135 let eye_11rr = Tensor::<2>::eye(state_rank, &device).unsqueeze::<4>();
136 let tr_bh = (sigma_bhrr.clone() * eye_11rr.clone())
137 .sum_dim(3)
138 .sum_dim(2)
139 .reshape([batch, nheads]);
140 // `PR = (tr Σ)² / tr(Σ²)`. Computed via the trace-normalised
141 // `Σ̂ = Σ / tr(Σ).detach()`, keeping **both** traces of `Σ̂`:
142 //
143 // PR = tr(Σ̂)² / tr(Σ̂²).
144 //
145 // With `c = tr(Σ).detach()` a frozen scalar, the `c²` cancels between
146 // numerator and denominator, so this equals `tr(Σ)²/tr(Σ²)` **as a
147 // function of Σ** — identical value *and* exact gradient — while every
148 // differentiated intermediate stays O(1). Two subtleties that a naive
149 // rewrite gets wrong:
150 // - Keep the numerator `tr(Σ̂)²`: it is numerically 1, but with the
151 // normaliser detached its *gradient* w.r.t. Σ is not zero — it
152 // carries PR's rank-reducing (trace-tangential) component.
153 // Collapsing to `1/tr(Σ̂²)` drops it, leaving only the radial
154 // (magnitude) direction, orthogonal to ∇PR — a penalty that no
155 // longer reduces rank (see `pr_gradient_matches_direct_formula`).
156 // - Differentiating *through* `tr(Σ)` instead (no detach) is
157 // value/grad-correct but puts `tr(Σ²)²` in the backward, which
158 // underflows fp32 to 0 (→ NaN gradient) once the state magnitude
159 // `tr(Σ) ≲ 1e-11` — which weight decay drives it toward. The
160 // detached O(1) form is finite at every representable magnitude
161 // (see `pr_gradient_finite_as_magnitude_shrinks`).
162 //
163 // Two floors, for two quantities at very different scales. The
164 // normaliser `tr Σ` is a *magnitude* that weight decay drives down to —
165 // and below — `div_eps` (the crate's O(1)-calibrated negligibility
166 // threshold): flooring it there would corrupt PR across the live
167 // operating range, so it is floored only at the dtype's smallest
168 // positive normal (`finfo().min_positive`), firing solely for an
169 // all-zero state (`Σ ≡ 0`, e.g. a dead head — then `Σ̂ = 0/ε = 0`, no
170 // `0/0`). `tr(Σ̂²)` is scale-normalised (`∈ [1/r, 1]` for any nonzero Σ)
171 // and nears zero only for `Σ ≡ 0`, so `div_eps(dtype)` is the correct
172 // dtype-aware guard there (cf. `MseLoss`'s fp16 path).
173 let dtype = self.m2_bhrr.dtype();
174 let min_positive = dtype.finfo().expect("state moments are a float dtype").min_positive;
175 let scale_bh = tr_bh.clamp_min(min_positive).detach();
176 let sigma_hat = sigma_bhrr / scale_bh.reshape([batch, nheads, 1, 1]);
177 let tr1_hat_bh = (sigma_hat.clone() * eye_11rr)
178 .sum_dim(3)
179 .sum_dim(2)
180 .reshape([batch, nheads]);
181 let tr2_hat_bh = sigma_hat
182 .powf_scalar(2.0)
183 .sum_dim(3)
184 .sum_dim(2)
185 .reshape([batch, nheads])
186 .clamp_min(crate::utils::div_eps(dtype));
187 tr1_hat_bh.clone() * tr1_hat_bh / tr2_hat_bh
188 }
189
190 /// Participation ratio of the **Hermitian** sample covariance, treating the
191 /// `state_rank` axis as realified complex (or quaternionic) coordinates per
192 /// `pairing` — the Mamba-3 counterpart of [`Self::pr`].
193 ///
194 /// With the pairing's complex view `c = x + iy`, the Hermitian moment is
195 /// `M = A + iS` with `A = Σ(xxᵀ + yyᵀ)` and `S = Σ(xyᵀ − yxᵀ)` — both linear
196 /// recombinations of `m2_bhrr` sub-blocks, so centering `Σ` centers `M`
197 /// identically. `PR_ℂ = (tr M)² / tr(M²)`; the trace is real and equals the
198 /// full real trace (frame-invariant), while `tr(M²) = Σ|M_ab|²`
199 /// (`= ‖A‖²_F + ‖S‖²_F` for the fully-rotated complex case). One complex
200 /// (or quaternionic) direction counts as **one** — the ×2 (×4) realified
201 /// count is a representation artifact — so a rank-1 rotating conveyor reads
202 /// `PR_ℂ ≡ 1` where [`Self::pr`] reads up to the block size.
203 ///
204 /// Un-rotated coordinates (partial `rope_fraction`) stay a real block `U`
205 /// of the mixed Hermitian `[[M, X], [Xᴴ, U]]`; its trace and `Σ|·|²` join
206 /// the sums (`tr(·²)` gains `2‖X‖²_F + ‖U‖²_F`). Quaternionic pairing uses
207 /// the same formulas with `M_jk = Σ q̄ⱼqₖ` (diagonal real, 4-component
208 /// norms).
209 ///
210 /// Differentiable; numerics mirror [`Self::pr`] (detached trace
211 /// normalisation, `min_positive` / `div_eps` floors — see the comments
212 /// there for why).
213 ///
214 /// # Shape
215 /// - output: `[batch, nheads]`
216 pub fn pr_complex(&self, pairing: &StatePairing, center: bool) -> Tensor<2> {
217 let [batch, nheads, state_rank, _] = self.m2_bhrr.dims();
218 assert!(self.count > 0, "state moments hold no samples");
219 let device = self.m2_bhrr.device();
220 let samples = self.count as f32;
221
222 // Reorder the `r` axis into the canonical `[x-pairs | y-pairs | real]`
223 // layout (complex pairings only — quaternion blocks are already
224 // consecutive with a trailing real tail), so every block below is one
225 // contiguous `narrow`. `rotated` is the realified width of the
226 // rotated prefix in that canonical order.
227 enum Blocks {
228 Complex { num_pairs: usize },
229 Quaternion { num_blocks: usize },
230 }
231 let (reorder, blocks, rotated) = match *pairing {
232 StatePairing::Real => return self.pr(center),
233 StatePairing::ComplexInterleaved { num_pairs } => {
234 assert!(
235 num_pairs > 0 && 2 * num_pairs <= state_rank,
236 "interleaved pairing exceeds state_rank"
237 );
238 let mut idx: Vec<i64> = (0..num_pairs).map(|a| 2 * a as i64).collect();
239 idx.extend((0..num_pairs).map(|a| 2 * a as i64 + 1));
240 idx.extend((2 * num_pairs..state_rank).map(|k| k as i64));
241 (Some(idx), Blocks::Complex { num_pairs }, 2 * num_pairs)
242 }
243 StatePairing::ComplexHalfHalf { num_pairs } => {
244 let half = state_rank / 2;
245 assert!(
246 num_pairs > 0 && num_pairs <= half,
247 "half-and-half pairing exceeds state_rank / 2"
248 );
249 let mut idx: Vec<i64> = (0..num_pairs).map(|a| a as i64).collect();
250 idx.extend((0..num_pairs).map(|a| (half + a) as i64));
251 idx.extend((num_pairs..half).map(|k| k as i64));
252 idx.extend((half + num_pairs..state_rank).map(|k| k as i64));
253 (Some(idx), Blocks::Complex { num_pairs }, 2 * num_pairs)
254 }
255 StatePairing::QuaternionBlocks { num_blocks } => {
256 assert!(
257 num_blocks > 0 && 4 * num_blocks <= state_rank,
258 "quaternion pairing exceeds state_rank"
259 );
260 (None, Blocks::Quaternion { num_blocks }, 4 * num_blocks)
261 }
262 };
263
264 let sigma_bhrr = {
265 let m2_bhrr = self.m2_bhrr.clone() / samples;
266 let sigma = if center {
267 let mu_bhr = self.m1_bhr.clone() / samples;
268 let outer_bhrr =
269 mu_bhr.clone().unsqueeze_dim::<4>(3) * mu_bhr.unsqueeze_dim::<4>(2);
270 m2_bhrr - outer_bhrr
271 } else {
272 m2_bhrr
273 };
274 match reorder {
275 Some(idx) => {
276 let idx = Tensor::<1, Int>::from_ints(idx.as_slice(), &device);
277 sigma.select(2, idx.clone()).select(3, idx)
278 }
279 None => sigma,
280 }
281 };
282
283 // Frobenius norm² of a `[batch, nheads, ·, ·]` block, per (batch, head).
284 let fro2_bh = |t: Tensor<4>| -> Tensor<2> {
285 t.powf_scalar(2.0)
286 .sum_dim(3)
287 .sum_dim(2)
288 .reshape([batch, nheads])
289 };
290
291 // The Hermitian trace equals the full real trace (the phases cancel on
292 // the diagonal), so the normaliser is exactly [`Self::pr`]'s.
293 let eye_11rr = Tensor::<2>::eye(state_rank, &device).unsqueeze::<4>();
294 let tr_bh = (sigma_bhrr.clone() * eye_11rr.clone())
295 .sum_dim(3)
296 .sum_dim(2)
297 .reshape([batch, nheads]);
298 let dtype = self.m2_bhrr.dtype();
299 let min_positive = dtype
300 .finfo()
301 .expect("state moments are a float dtype")
302 .min_positive;
303 let scale_bh = tr_bh.clamp_min(min_positive).detach();
304 let sigma_hat = sigma_bhrr / scale_bh.reshape([batch, nheads, 1, 1]);
305 let tr1_hat_bh = (sigma_hat.clone() * eye_11rr)
306 .sum_dim(3)
307 .sum_dim(2)
308 .reshape([batch, nheads]);
309
310 // tr(Σ̂²) of the mixed Hermitian [[M, X], [Xᴴ, U]] = Σ|M_ab|² + 2‖X‖² + ‖U‖².
311 let mut tr2_hat_bh = match blocks {
312 Blocks::Complex { num_pairs } => {
313 let np = num_pairs;
314 let xx = sigma_hat.clone().narrow(2, 0, np).narrow(3, 0, np);
315 let yy = sigma_hat.clone().narrow(2, np, np).narrow(3, np, np);
316 let xy = sigma_hat.clone().narrow(2, 0, np).narrow(3, np, np);
317 // A = Σ(xxᵀ + yyᵀ), S = Σ(xyᵀ − yxᵀ); Σ symmetric ⇒ (yx)_ab = (xy)_ba.
318 let a = xx + yy;
319 let s = xy.clone() - xy.transpose();
320 fro2_bh(a) + fro2_bh(s)
321 }
322 Blocks::Quaternion { num_blocks } => {
323 let j = num_blocks;
324 let q_bhjaja = sigma_hat
325 .clone()
326 .narrow(2, 0, 4 * j)
327 .narrow(3, 0, 4 * j)
328 .reshape([batch, nheads, j, 4, j, 4]);
329 // Component (α, β) sub-block Σ[(4j+α), (4k+β)] as [b, h, J, J].
330 let c = |alpha: usize, beta: usize| -> Tensor<4> {
331 q_bhjaja
332 .clone()
333 .narrow(3, alpha, 1)
334 .narrow(5, beta, 1)
335 .reshape([batch, nheads, j, j])
336 };
337 // Components of M_jk = Σ q̄ⱼqₖ (Hamilton product, conjugate left).
338 let w = c(0, 0) + c(1, 1) + c(2, 2) + c(3, 3);
339 let x = c(0, 1) - c(1, 0) - c(2, 3) + c(3, 2);
340 let y = c(0, 2) + c(1, 3) - c(2, 0) - c(3, 1);
341 let z = c(0, 3) - c(1, 2) + c(2, 1) - c(3, 0);
342 fro2_bh(w) + fro2_bh(x) + fro2_bh(y) + fro2_bh(z)
343 }
344 };
345 if rotated < state_rank {
346 let tail = state_rank - rotated;
347 let cross = sigma_hat.clone().narrow(2, 0, rotated).narrow(3, rotated, tail);
348 let u = sigma_hat.narrow(2, rotated, tail).narrow(3, rotated, tail);
349 tr2_hat_bh = tr2_hat_bh + fro2_bh(cross) * 2.0 + fro2_bh(u);
350 }
351 let tr2_hat_bh = tr2_hat_bh.clamp_min(crate::utils::div_eps(dtype));
352 tr1_hat_bh.clone() * tr1_hat_bh / tr2_hat_bh
353 }
354
355 /// Raw uncentered state magnitude `tr Σ = trace(m2)/count` per
356 /// `(batch, head)` — the mean squared state magnitude `⟨‖h‖²⟩`, which is
357 /// [`Self::pr`]'s numerator scale. Reported alongside PR to tell a genuine
358 /// rank-1 state (`PR → 1`, magnitude healthy) apart from a state
359 /// collapsing toward zero (where `pr`'s `1e-12` denominator clamp drags
360 /// the ratio below its true floor of 1).
361 ///
362 /// # Shape
363 /// - output: `[batch, nheads]`
364 pub fn trace(&self) -> Tensor<2> {
365 let [batch, nheads, state_rank, _] = self.m2_bhrr.dims();
366 assert!(self.count > 0, "state moments hold no samples");
367 let eye_11rr = Tensor::<2>::eye(state_rank, &self.m2_bhrr.device()).unsqueeze::<4>();
368 (self.m2_bhrr.clone() * eye_11rr)
369 .sum_dim(3)
370 .sum_dim(2)
371 .reshape([batch, nheads])
372 / self.count as f32
373 }
374}
375
376#[cfg(all(test, feature = "_dev-test"))]
377mod tests;