gam_terms/grid_spline_2d.rs
1//! Streaming scatter-add 2-D smoother: K×K tensor-product cubic B-splines
2//! with the EXACT anisotropic biharmonic penalty and REML-selected λ.
3//!
4//! Basis. Each axis carries K equal-width cells over the data's bounding box
5//! `[lo, hi]` with uniform extended knots `t_j = lo + (j−3)·h`, `h = (hi−lo)/K`,
6//! giving `m = K+3` cubic B-splines per axis; the tensor product has
7//! `p = (K+3)²` coefficients. A point in cell `i` activates exactly the four
8//! splines `i..i+3` per axis, hence exactly 4×4 = 16 tensor basis entries per
9//! data row.
10//!
11//! Streaming normal equations. ONE pass over the rows `(x1, x2, y_·, w)`
12//! scatter-adds `X'WX` and `X'Wy_d` (any number of response dimensions share
13//! the design, the penalty, and one REML λ — the multi-output "one surface
14//! smoothness" contract of the ANOVA pair component): O(n·(16² + 16·D)) work,
15//! no n×p design is ever materialized. Two tensor bases overlap only when both per-axis indices
16//! differ by ≤ 3, so under the row-major coefficient index
17//! `g = j1·(K+3) + j2` both `X'WX` and the penalty `S` are banded with
18//! half-bandwidth `3(K+3)+3`; they are stored as upper bands — O(K³) numbers.
19//!
20//! Penalty. The FULL anisotropic biharmonic form for the diagonal metric
21//! `A = diag(a1, a2)`,
22//! `J(f) = ∫∫ a1²·f_{x1x1}² + 2·a1·a2·f_{x1x2}² + a2²·f_{x2x2}² dx1 dx2`,
23//! INCLUDING the mixed `f_{x1x2}` term (the axis-wise P-spline difference
24//! shortcut drops it), assembled per knot cell by 4-point Gauss–Legendre per
25//! axis. Exactness degree arithmetic: on a knot cell every basis function is
26//! a single cubic polynomial per axis, so each entry of `S` is a sum over
27//! cells of integrands that factorize per axis as one of value·value
28//! (degree 3+3 = 6), deriv·deriv (2+2 = 4) or 2nd-deriv·2nd-deriv (1+1 = 2);
29//! every channel pairs a low-degree factor on one axis with at worst the
30//! degree-6 value·value factor on the other. 4-point Gauss–Legendre is exact
31//! through degree 2·4−1 = 7 ≥ 6, so the assembled `S` is the EXACT integral,
32//! not a quadrature approximation.
33//!
34//! Solve and selection. A single reference factorization of `H₀=X'WX+S`
35//! produces the affine generalized-eigenvalue pencil
36//! `H(λ)=L[I+(λ-1)U diag(μ)U']L'`. The profiled score and its first two
37//! analytic log-λ derivatives are then O(pD), and outward interval enclosures
38//! isolate every stationary interval without a grid. The selected system is
39//! factored once more for coefficients/posterior covariance. `p ≤ (32+3)² =
40//! 1225`; K is capped at 32 to keep the dense reference factor and eigensystem
41//! sizing contract honest. λ maximizes the
42//! profiled-σ² restricted (REML) criterion
43//! `ℓ_R(λ) = −½[ log|X'WX+λS| − r·log λ + (n−3)·log σ̂²(λ) ] + const`,
44//! where `r = p−3` is the penalty rank — the null space of `J` is
45//! span{1, x1, x2} (the mixed term penalizes `x1·x2`, whose cross derivative
46//! is 1 ≠ 0, so it is NOT in the null space), `σ̂²(λ) = (y'Wy − c'X'Wy)/(n−3)`
47//! is the profiled scale, and the λ-free additive constants (`log|S|₊` on the
48//! row space of S, `Σ log w`, 2π factors) are dropped: differences across λ
49//! are exact REML criterion differences. The exact bounded-domain endpoints
50//! (including the null-recovery end) compete with every certified stationary
51//! point; no RNG or lattice is involved, so the same data imply the same fit.
52//!
53//! Prediction. `predict(x1, x2)` builds the 16-entry basis row; the mean is
54//! its dot with `c` and the variance is the Bayesian posterior
55//! `σ̂²·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor. Outside the
56//! bounding box the boundary cell's cubic polynomial extends naturally (the
57//! cell index clamps, the local coordinate does not).
58
59/// Dimension of the penalty null space: span{1, x1, x2}. The mixed
60/// `2·a1·a2·f_{x1x2}²` term excludes `x1·x2` (its cross derivative is 1).
61const PENALTY_NULLITY: usize = 3;
62
63/// Cholesky pivot floor below which the penalized system is declared singular.
64/// Dense-Cholesky sizing contract documented in the module header.
65const MAX_CELLS_PER_AXIS: usize = 32;
66
67/// 4-point Gauss–Legendre nodes and weights on [−1, 1]. Exact through degree
68/// 2·4−1 = 7, which dominates the degree-6 worst per-axis factor of the
69/// penalty integrands (see the module header for the degree arithmetic).
70const GL4_NODES: [f64; 4] = [
71 -0.861_136_311_594_052_6,
72 -0.339_981_043_584_856_26,
73 0.339_981_043_584_856_26,
74 0.861_136_311_594_052_6,
75];
76const GL4_WEIGHTS: [f64; 4] = [
77 0.347_854_845_137_453_85,
78 0.652_145_154_862_546_2,
79 0.652_145_154_862_546_2,
80 0.347_854_845_137_453_85,
81];
82
83/// Cubic B-spline segment values at local coordinate `u` within a cell.
84/// Entry `m` weights basis `cell + m`: m = 0 is the spline ENDING in this
85/// cell (`(1−u)³/6`), m = 3 the one STARTING (`u³/6`). The four entries sum
86/// to 1 (partition of unity) for u ∈ [0, 1].
87#[inline]
88fn bspline_value(u: f64) -> [f64; 4] {
89 let v = 1.0 - u;
90 [
91 v * v * v / 6.0,
92 (3.0 * u * u * u - 6.0 * u * u + 4.0) / 6.0,
93 (-3.0 * u * u * u + 3.0 * u * u + 3.0 * u + 1.0) / 6.0,
94 u * u * u / 6.0,
95 ]
96}
97
98/// d/du of `bspline_value` (caller scales by 1/h for d/dx). Entries sum to 0.
99#[inline]
100fn bspline_d1(u: f64) -> [f64; 4] {
101 let v = 1.0 - u;
102 [
103 -0.5 * v * v,
104 0.5 * (3.0 * u * u - 4.0 * u),
105 0.5 * (-3.0 * u * u + 2.0 * u + 1.0),
106 0.5 * u * u,
107 ]
108}
109
110/// d²/du² of `bspline_value` (caller scales by 1/h²). Piecewise LINEAR in u —
111/// the degree-1 factor in the quadrature-exactness argument. Entries sum to 0.
112#[inline]
113fn bspline_d2(u: f64) -> [f64; 4] {
114 [1.0 - u, 3.0 * u - 2.0, 1.0 - 3.0 * u, u]
115}
116
117/// One uniform B-spline axis over `[lo, lo + cells·h]`.
118#[derive(Clone, Copy, Debug)]
119struct Axis {
120 lo: f64,
121 h: f64,
122 cells: usize,
123}
124
125impl Axis {
126 /// Cell index and local coordinate. Inside the box `u ∈ [0, 1]`; outside,
127 /// the cell clamps and `u` leaves [0, 1], extending the boundary cell's
128 /// cubic polynomial (deterministic extrapolation, no special casing).
129 #[inline]
130 fn locate(&self, x: f64) -> (usize, f64) {
131 let t = (x - self.lo) / self.h;
132 let cell = (t.floor().max(0.0) as usize).min(self.cells - 1);
133 (cell, t - cell as f64)
134 }
135}
136
137/// The four active cubic B-spline values of one uniform axis `(lo, h, cells)`
138/// at `x`: `(first basis index, values)`, where `values[i]` weights basis
139/// `first + i` of the `cells + 3` axis splines. Outside `[lo, lo + cells·h]`
140/// the boundary cell's cubic polynomial extends — the single convention
141/// shared by fitting, prediction, and every consumer-rebuilt basis row.
142pub fn axis_basis_at(lo: f64, h: f64, cells: usize, x: f64) -> (usize, [f64; 4]) {
143 let (cell, u) = Axis { lo, h, cells }.locate(x);
144 (cell, bspline_value(u))
145}
146
147/// The 16 active tensor-basis entries `(flat index, value)` at `(x1, x2)`.
148/// Flat indices are strictly increasing across the returned arrays.
149#[inline]
150fn basis_row(axes: &[Axis; 2], m_axis: usize, x1: f64, x2: f64) -> ([usize; 16], [f64; 16]) {
151 let (c1, u1) = axes[0].locate(x1);
152 let (c2, u2) = axes[1].locate(x2);
153 let b1 = bspline_value(u1);
154 let b2 = bspline_value(u2);
155 let mut idx = [0usize; 16];
156 let mut val = [0f64; 16];
157 for i in 0..4 {
158 for j in 0..4 {
159 idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
160 val[4 * i + j] = b1[i] * b2[j];
161 }
162 }
163 (idx, val)
164}
165
166/// Dense lower-Cholesky in place (row-major `p×p`); returns the exact
167/// `log det` (twice the log of the pivot products). The strict upper triangle
168/// is zeroed so the buffer is exactly `L` afterwards.
169pub fn cholesky_logdet(a: &mut [f64], p: usize) -> Result<f64, String> {
170 let mut logdet = 0.0;
171 for j in 0..p {
172 let diag = a[j * p + j];
173 let mut s = diag;
174 let mut subtracted = 0.0_f64;
175 for t in 0..j {
176 let sq = a[j * p + t] * a[j * p + t];
177 s -= sq;
178 subtracted += sq;
179 }
180 // The pivot is `a_jj − Σ_t l_jt²`: one product and one subtraction per
181 // term, so a pivot inside the rounding band of that accumulation is
182 // not distinguishable from zero and the system is not positive definite
183 // as computed (#2469). An absolute `1e-300` passed pure roundoff of an
184 // O(1) row and refused an honest tiny pivot.
185 let band = gam_linalg::roundoff::accumulation_growth(2 * j + 1) * (diag.abs() + subtracted);
186 if !(s.is_finite() && s > band) {
187 return Err(format!(
188 "grid spline 2d: penalized system not positive definite at pivot {j} (value {s})"
189 ));
190 }
191 let l = s.sqrt();
192 a[j * p + j] = l;
193 logdet += 2.0 * l.ln();
194 for i in j + 1..p {
195 let mut s2 = a[i * p + j];
196 for t in 0..j {
197 s2 -= a[i * p + t] * a[j * p + t];
198 }
199 a[i * p + j] = s2 / l;
200 }
201 }
202 for i in 0..p {
203 for j in i + 1..p {
204 a[i * p + j] = 0.0;
205 }
206 }
207 Ok(logdet)
208}
209
210/// Solve `L z = b` from a dense row-major lower-triangular factor.
211fn lower_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
212 let mut z = b.to_vec();
213 for i in 0..p {
214 let mut s = z[i];
215 for t in 0..i {
216 s -= l[i * p + t] * z[t];
217 }
218 z[i] = s / l[i * p + i];
219 }
220 z
221}
222
223/// Solve `L Lᵀ x = b` from the stored lower factor.
224pub fn chol_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
225 let mut z = lower_solve(l, p, b);
226 for i in (0..p).rev() {
227 let mut s = z[i];
228 for t in i + 1..p {
229 s -= l[t * p + i] * z[t];
230 }
231 z[i] = s / l[i * p + i];
232 }
233 z
234}
235
236/// Banded sufficient statistics of one streaming pass plus the exact penalty:
237/// everything needed to evaluate the REML criterion and solve at any λ.
238pub struct GridSpline2dDesign {
239 axes: [Axis; 2],
240 /// Basis count per axis, `K + 3`.
241 /// Total coefficients, `(K + 3)²`.
242 p: usize,
243 /// Upper half-bandwidth `3·(K+3) + 3` of both banded matrices.
244 band_half: usize,
245 /// Upper band of `X'WX`: entry `(g, g+d)` at `g·(band_half+1) + d`.
246 gram_band: Vec<f64>,
247 /// Upper band of the exact anisotropic biharmonic penalty `S`.
248 pen_band: Vec<f64>,
249 /// `X'Wy_d`, one length-`p` vector per response dimension. The design
250 /// (gram and penalty bands) is shared across dimensions; only these
251 /// right-hand sides and the response cross-moments are per-dimension.
252 rhs: Vec<Vec<f64>>,
253 /// Response cross-moments `y_d'W y_e` (`D × D` row-major), for the
254 /// profiled-σ² residual quadratics and the residual cross-covariance.
255 cross_moments: Vec<f64>,
256 n_obs: usize,
257}
258
259impl GridSpline2dDesign {
260 /// Single-response entry: see [`Self::build_multi`].
261 pub fn build(
262 x1: &[f64],
263 x2: &[f64],
264 y: &[f64],
265 w: &[f64],
266 k: usize,
267 metric: [f64; 2],
268 ) -> Result<Self, String> {
269 Self::build_multi(x1, x2, &[y], w, k, metric)
270 }
271
272 /// One streaming pass over the rows plus the exact per-cell quadrature
273 /// assembly of the penalty. `k` is the number of cells per axis;
274 /// `metric = [a1, a2]` is the diagonal anisotropy of the biharmonic form.
275 /// `responses` holds one length-`n` response per dimension; the design,
276 /// penalty, and the REML-shared λ are common to all dimensions (one
277 /// surface smoothness), only the right-hand sides differ.
278 pub fn build_multi(
279 x1: &[f64],
280 x2: &[f64],
281 responses: &[&[f64]],
282 w: &[f64],
283 k: usize,
284 metric: [f64; 2],
285 ) -> Result<Self, String> {
286 let n = x1.len();
287 if responses.is_empty() {
288 return Err("grid spline 2d: no response dimensions supplied".to_string());
289 }
290 if x2.len() != n || w.len() != n {
291 return Err(format!(
292 "grid spline 2d: length mismatch x1={n}, x2={}, w={}",
293 x2.len(),
294 w.len()
295 ));
296 }
297 for (d, y) in responses.iter().enumerate() {
298 if y.len() != n {
299 return Err(format!(
300 "grid spline 2d: response dimension {d} has length {} != {n}",
301 y.len()
302 ));
303 }
304 }
305 if n <= PENALTY_NULLITY {
306 return Err(format!(
307 "grid spline 2d: needs more than {PENALTY_NULLITY} rows for the profiled REML \
308 degrees of freedom, got {n}"
309 ));
310 }
311 if k == 0 || k > MAX_CELLS_PER_AXIS {
312 return Err(format!(
313 "grid spline 2d: k must be in 1..={MAX_CELLS_PER_AXIS} (dense Cholesky on \
314 (k+3)² coefficients — see module sizing contract), got {k}"
315 ));
316 }
317 if !(metric[0].is_finite() && metric[0] > 0.0 && metric[1].is_finite() && metric[1] > 0.0) {
318 return Err(format!(
319 "grid spline 2d: metric diagonal must be finite and positive, got [{}, {}]",
320 metric[0], metric[1]
321 ));
322 }
323 for i in 0..n {
324 if !(x1[i].is_finite() && x2[i].is_finite()) || !(w[i] > 0.0) || !w[i].is_finite() {
325 return Err(format!(
326 "grid spline 2d: non-finite or non-positive input at row {i} \
327 (x1={}, x2={}, w={})",
328 x1[i], x2[i], w[i]
329 ));
330 }
331 for (d, y) in responses.iter().enumerate() {
332 if !y[i].is_finite() {
333 return Err(format!(
334 "grid spline 2d: non-finite response at row {i}, dimension {d} ({})",
335 y[i]
336 ));
337 }
338 }
339 }
340 let mut axes = [Axis {
341 lo: 0.0,
342 h: 1.0,
343 cells: k,
344 }; 2];
345 for (axis, xs) in axes.iter_mut().zip([x1, x2]) {
346 let mut lo = f64::INFINITY;
347 let mut hi = f64::NEG_INFINITY;
348 for &v in xs {
349 lo = lo.min(v);
350 hi = hi.max(v);
351 }
352 if !(hi > lo) {
353 return Err(format!(
354 "grid spline 2d: degenerate axis bounding box [{lo}, {hi}]"
355 ));
356 }
357 axis.lo = lo;
358 axis.h = (hi - lo) / k as f64;
359 }
360 let m_axis = k + 3;
361 let p = m_axis * m_axis;
362 let band_half = 3 * m_axis + 3;
363 let stride = band_half + 1;
364 let n_dims = responses.len();
365 let mut gram_band = vec![0.0_f64; p * stride];
366 let mut rhs = vec![vec![0.0_f64; p]; n_dims];
367 let mut cross_moments = vec![0.0_f64; n_dims * n_dims];
368
369 // ── ONE streaming pass: scatter-add X'WX (upper band) and X'Wy_d ──
370 // Each row touches exactly 16 basis entries with strictly increasing
371 // flat indices, so the in-row pair loop (a ≤ b) lands directly in the
372 // upper band: O(n·(16² + 16·D)) total work.
373 for i in 0..n {
374 let (idx, val) = basis_row(&axes, m_axis, x1[i], x2[i]);
375 let wi = w[i];
376 for (d, y) in responses.iter().enumerate() {
377 let wy = wi * y[i];
378 for e in 0..16 {
379 rhs[d][idx[e]] += wy * val[e];
380 }
381 for (e, ye) in responses.iter().enumerate().skip(d) {
382 cross_moments[d * n_dims + e] += wy * ye[i];
383 }
384 }
385 for a in 0..16 {
386 let base = idx[a] * stride - idx[a];
387 let wa = wi * val[a];
388 for b in a..16 {
389 gram_band[base + idx[b]] += wa * val[b];
390 }
391 }
392 }
393 for d in 0..n_dims {
394 for e in 0..d {
395 cross_moments[d * n_dims + e] = cross_moments[e * n_dims + d];
396 }
397 }
398
399 // ── Exact penalty assembly: 4-pt Gauss–Legendre per axis per cell ──
400 // Per-axis quadrature tables (cell-independent on a uniform grid):
401 // values, d/dx (scaled 1/h), d²/dx² (scaled 1/h²) at each GL node.
402 let mut tab = [[[[0.0_f64; 4]; 4]; 3]; 2]; // [axis][channel][node][basis offset]
403 for ax in 0..2 {
404 let h = axes[ax].h;
405 for q in 0..4 {
406 let u = 0.5 * (1.0 + GL4_NODES[q]);
407 let v0 = bspline_value(u);
408 let v1 = bspline_d1(u);
409 let v2 = bspline_d2(u);
410 for e in 0..4 {
411 tab[ax][0][q][e] = v0[e];
412 tab[ax][1][q][e] = v1[e] / h;
413 tab[ax][2][q][e] = v2[e] / (h * h);
414 }
415 }
416 }
417 // Channel scales: J = ∫ a1²·f11² + 2·a1·a2·f12² + a2²·f22².
418 let s11 = metric[0] * metric[0];
419 let s12 = 2.0 * metric[0] * metric[1];
420 let s22 = metric[1] * metric[1];
421 let cell_area_jac = 0.25 * axes[0].h * axes[1].h; // d(x1,x2)/d(ξ1,ξ2) on [−1,1]²
422 let mut pen_band = vec![0.0_f64; p * stride];
423 let mut r11 = [0.0_f64; 16];
424 let mut r12 = [0.0_f64; 16];
425 let mut r22 = [0.0_f64; 16];
426 let mut idx = [0usize; 16];
427 for c1 in 0..k {
428 for c2 in 0..k {
429 for i in 0..4 {
430 for j in 0..4 {
431 idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
432 }
433 }
434 for q1 in 0..4 {
435 for q2 in 0..4 {
436 let wq = cell_area_jac * GL4_WEIGHTS[q1] * GL4_WEIGHTS[q2];
437 for i in 0..4 {
438 for j in 0..4 {
439 let e = 4 * i + j;
440 r11[e] = tab[0][2][q1][i] * tab[1][0][q2][j];
441 r12[e] = tab[0][1][q1][i] * tab[1][1][q2][j];
442 r22[e] = tab[0][0][q1][i] * tab[1][2][q2][j];
443 }
444 }
445 for a in 0..16 {
446 let base = idx[a] * stride - idx[a];
447 let (pa11, pa12, pa22) =
448 (wq * s11 * r11[a], wq * s12 * r12[a], wq * s22 * r22[a]);
449 for b in a..16 {
450 pen_band[base + idx[b]] +=
451 pa11 * r11[b] + pa12 * r12[b] + pa22 * r22[b];
452 }
453 }
454 }
455 }
456 }
457 }
458
459 Ok(GridSpline2dDesign {
460 axes,
461 p,
462 band_half,
463 gram_band,
464 pen_band,
465 rhs,
466 cross_moments,
467 n_obs: n,
468 })
469 }
470
471 /// Lower corner of the data bounding box per axis.
472 pub fn lower_corner(&self) -> [f64; 2] {
473 [self.axes[0].lo, self.axes[1].lo]
474 }
475
476 /// Knot-cell width per axis.
477 pub fn cell_widths(&self) -> [f64; 2] {
478 [self.axes[0].h, self.axes[1].h]
479 }
480
481 /// Exact penalty quadratic form `J(f) = c'Sc` of a coefficient vector —
482 /// the assembled anisotropic biharmonic energy of the spline it encodes.
483 pub fn penalty_value(&self, coeff: &[f64]) -> Result<f64, String> {
484 if coeff.len() != self.p {
485 return Err(format!(
486 "grid spline 2d: coefficient length {} != {}",
487 coeff.len(),
488 self.p
489 ));
490 }
491 let stride = self.band_half + 1;
492 let mut j = 0.0;
493 for g in 0..self.p {
494 let dmax = self.band_half.min(self.p - 1 - g);
495 j += self.pen_band[g * stride] * coeff[g] * coeff[g];
496 for d in 1..=dmax {
497 j += 2.0 * self.pen_band[g * stride + d] * coeff[g] * coeff[g + d];
498 }
499 }
500 Ok(j)
501 }
502
503 /// `a'(X'WX)b` through the retained upper band (exact, O(p·bandwidth)).
504 fn gram_quadratic(&self, a: &[f64], b: &[f64]) -> f64 {
505 let stride = self.band_half + 1;
506 let mut q = 0.0;
507 for g in 0..self.p {
508 let dmax = self.band_half.min(self.p - 1 - g);
509 q += self.gram_band[g * stride] * a[g] * b[g];
510 for d in 1..=dmax {
511 q += self.gram_band[g * stride + d] * (a[g] * b[g + d] + a[g + d] * b[g]);
512 }
513 }
514 q
515 }
516
517 /// Posterior summary of a fit FROM THIS DESIGN, in the exact algebra of
518 /// the solved system (no approximation):
519 /// - `unit_covariance = (X'WX + λS)⁻¹` (scale-free Bayesian posterior
520 /// covariance of the row-major coefficient vec, shared by dimensions);
521 /// - `edf = tr[(X'WX + λS)⁻¹ X'WX]` (the smoother's effective degrees of
522 /// freedom at the fitted λ);
523 /// - `residual_cross_cov[d,e] = r_d'W r_e / (n − edf)` assembled from the
524 /// streamed sufficient statistics
525 /// (`y_d'Wy_e − c_d'X'Wy_e − c_e'X'Wy_d + c_d'X'WX c_e`).
526 pub fn posterior(&self, fit: &GridSpline2dFit) -> Result<GridSpline2dPosterior, String> {
527 let p = self.p;
528 let n_dims = self.rhs.len();
529 if fit.coeffs.len() != n_dims || fit.coeffs.iter().any(|c| c.len() != p) {
530 return Err(format!(
531 "grid spline 2d: posterior asked for a fit with {} dimensions of length {}, \
532 design has {n_dims} of {p}",
533 fit.coeffs.len(),
534 fit.coeffs.first().map_or(0, Vec::len)
535 ));
536 }
537 // H⁻¹ column by column through the retained factor (symmetric, O(p³)).
538 let mut unit_covariance = vec![0.0_f64; p * p];
539 let mut e_g = vec![0.0_f64; p];
540 for g in 0..p {
541 e_g[g] = 1.0;
542 let col = chol_solve(&fit.chol, p, &e_g);
543 e_g[g] = 0.0;
544 for (r, &v) in col.iter().enumerate() {
545 unit_covariance[r * p + g] = v;
546 }
547 }
548 // edf = tr(H⁻¹ X'WX) via the gram band (diagonal once, off-band twice).
549 let stride = self.band_half + 1;
550 let mut edf = 0.0;
551 for g in 0..p {
552 let dmax = self.band_half.min(p - 1 - g);
553 edf += self.gram_band[g * stride] * unit_covariance[g * p + g];
554 for d in 1..=dmax {
555 edf += 2.0 * self.gram_band[g * stride + d] * unit_covariance[g * p + g + d];
556 }
557 }
558 let residual_df = self.n_obs as f64 - edf;
559 if !(residual_df >= 1.0) {
560 return Err(format!(
561 "grid spline 2d: too few rows for a scale estimate \
562 (n = {}, edf = {edf:.2}; need n − edf ≥ 1)",
563 self.n_obs
564 ));
565 }
566 let mut residual_cross_cov = vec![0.0_f64; n_dims * n_dims];
567 for d in 0..n_dims {
568 for e in d..n_dims {
569 let mut cd_rhse = 0.0;
570 let mut ce_rhsd = 0.0;
571 for g in 0..p {
572 cd_rhse += fit.coeffs[d][g] * self.rhs[e][g];
573 ce_rhsd += fit.coeffs[e][g] * self.rhs[d][g];
574 }
575 let quad = self.gram_quadratic(&fit.coeffs[d], &fit.coeffs[e]);
576 let v =
577 (self.cross_moments[d * n_dims + e] - cd_rhse - ce_rhsd + quad) / residual_df;
578 residual_cross_cov[d * n_dims + e] = v;
579 residual_cross_cov[e * n_dims + d] = v;
580 }
581 }
582 Ok(GridSpline2dPosterior {
583 unit_covariance,
584 edf,
585 residual_df,
586 residual_cross_cov,
587 })
588 }
589}
590
591/// Exact posterior summary of a [`GridSpline2dFit`] (see
592/// [`GridSpline2dDesign::posterior`]): the bridge from the streaming engine
593/// to covariance-consuming clients (the ANOVA pair-component carve).
594pub struct GridSpline2dPosterior {
595 /// `(X'WX + λS)⁻¹`, `p × p` row-major — scale-free posterior covariance
596 /// of the row-major coefficient vec, shared by all response dimensions.
597 pub unit_covariance: Vec<f64>,
598 /// `tr[(X'WX + λS)⁻¹ X'WX]`.
599 pub edf: f64,
600 /// `n − edf`.
601 pub residual_df: f64,
602 /// `D × D` row-major residual cross-covariance at `n − edf`.
603 pub residual_cross_cov: Vec<f64>,
604}
605
606/// Fitted penalized tensor-product smoother with its factored covariance.
607pub struct GridSpline2dFit {
608 /// Per response dimension: coefficients in row-major flat order
609 /// `g = j1·(K+3) + j2`.
610 pub coeffs: Vec<Vec<f64>>,
611 /// Selected (or supplied) log smoothing parameter, shared by all
612 /// response dimensions.
613 pub log_lambda: f64,
614 /// Per response dimension: profiled (or supplied) observation variance σ².
615 pub sigma2: Vec<f64>,
616 /// Pooled restricted log-likelihood at the optimum, up to λ- and
617 /// data-independent additive constants (exact REML differences across λ).
618 pub restricted_loglik: f64,
619 /// Lower Cholesky factor of `X'WX + λS` — the factored posterior precision
620 /// (unit-σ² scale) used for prediction variances, shared by all dimensions.
621 chol: Vec<f64>,
622 axes: [Axis; 2],
623 m_axis: usize,
624}
625
626/// Serializable snapshot of a [`GridSpline2dFit`] (#1031 persistence
627/// prerequisite). The grid is deliberately NOT a formula fast path — it is an
628/// ANOVA pair component (#975 carve) — so there is no `FitResult` variant; this
629/// state is what the carve's persistence payload serializes and what
630/// `from_state` replays for an exact predict.
631///
632/// Predict needs the MEAN (`coeffs` + the 16-entry tensor basis row, which is a
633/// pure function of `axes`/`m_axis`) and the VARIANCE
634/// (`σ²·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor `chol`). All of
635/// that — and nothing about the training rows — lives on the fit already, so the
636/// state is a verbatim snapshot: no design CSR, no re-factor on load.
637#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
638pub struct GridSpline2dState {
639 /// Per response dimension: row-major coefficients `g = j1·(K+3) + j2`.
640 pub coeffs: Vec<Vec<f64>>,
641 pub log_lambda: f64,
642 /// Per response dimension: profiled (or supplied) observation variance σ².
643 pub sigma2: Vec<f64>,
644 pub restricted_loglik: f64,
645 /// Lower Cholesky factor of `X'WX + λS` (unit-σ² scale), `p × p` row-major —
646 /// the factored posterior precision the variance term solves against.
647 pub chol: Vec<f64>,
648 /// Per axis lower corner of the basis bounding box.
649 pub axis_lo: [f64; 2],
650 /// Per axis cell width `h = (hi − lo)/K`.
651 pub axis_h: [f64; 2],
652 /// Per axis cell count `K`.
653 pub axis_cells: [u64; 2],
654 /// Basis count per axis, `K + 3` (so `p = m_axis²`).
655 pub m_axis: u64,
656}
657
658impl GridSpline2dFit {
659
660 /// Posterior `(mean, variance)` of response dimension `dim` at an
661 /// arbitrary point: the 16-entry basis row dotted with the coefficients,
662 /// and `σ̂²_dim·x'(X'WX+λS)⁻¹x` through the retained Cholesky factor.
663 /// Outside the bounding box the boundary cell's cubic polynomial extends.
664 pub fn predict(&self, dim: usize, x1: f64, x2: f64) -> Result<(f64, f64), String> {
665 if dim >= self.coeffs.len() {
666 return Err(format!(
667 "grid spline 2d: response dimension {dim} out of range (D = {})",
668 self.coeffs.len()
669 ));
670 }
671 if !(x1.is_finite() && x2.is_finite()) {
672 return Err(format!(
673 "grid spline 2d: non-finite prediction point ({x1}, {x2})"
674 ));
675 }
676 let (idx, val) = basis_row(&self.axes, self.m_axis, x1, x2);
677 let p = self.coeffs[dim].len();
678 let mut mean = 0.0;
679 let mut row = vec![0.0_f64; p];
680 for e in 0..16 {
681 mean += val[e] * self.coeffs[dim][idx[e]];
682 row[idx[e]] += val[e];
683 }
684 let z = chol_solve(&self.chol, p, &row);
685 let mut quad = 0.0;
686 for g in 0..p {
687 quad += row[g] * z[g];
688 }
689 Ok((mean, self.sigma2[dim] * quad))
690 }
691}
692