1use faer::{Mat, Side};
60use gam_math::score_opt::{
61 AffineRemlProfile, ClosedInterval, ScoreOptimumLocation, certified_ln_positive,
62};
63
64const PENALTY_NULLITY: usize = 3;
67
68const PIVOT_FLOOR: f64 = 1e-300;
70const MAX_CELLS_PER_AXIS: usize = 32;
72
73const GL4_NODES: [f64; 4] = [
77 -0.861_136_311_594_052_6,
78 -0.339_981_043_584_856_26,
79 0.339_981_043_584_856_26,
80 0.861_136_311_594_052_6,
81];
82const GL4_WEIGHTS: [f64; 4] = [
83 0.347_854_845_137_453_85,
84 0.652_145_154_862_546_2,
85 0.652_145_154_862_546_2,
86 0.347_854_845_137_453_85,
87];
88
89#[inline]
94fn bspline_value(u: f64) -> [f64; 4] {
95 let v = 1.0 - u;
96 [
97 v * v * v / 6.0,
98 (3.0 * u * u * u - 6.0 * u * u + 4.0) / 6.0,
99 (-3.0 * u * u * u + 3.0 * u * u + 3.0 * u + 1.0) / 6.0,
100 u * u * u / 6.0,
101 ]
102}
103
104#[inline]
106fn bspline_d1(u: f64) -> [f64; 4] {
107 let v = 1.0 - u;
108 [
109 -0.5 * v * v,
110 0.5 * (3.0 * u * u - 4.0 * u),
111 0.5 * (-3.0 * u * u + 2.0 * u + 1.0),
112 0.5 * u * u,
113 ]
114}
115
116#[inline]
119fn bspline_d2(u: f64) -> [f64; 4] {
120 [1.0 - u, 3.0 * u - 2.0, 1.0 - 3.0 * u, u]
121}
122
123#[derive(Clone, Copy, Debug)]
125struct Axis {
126 lo: f64,
127 h: f64,
128 cells: usize,
129}
130
131impl Axis {
132 #[inline]
136 fn locate(&self, x: f64) -> (usize, f64) {
137 let t = (x - self.lo) / self.h;
138 let cell = (t.floor().max(0.0) as usize).min(self.cells - 1);
139 (cell, t - cell as f64)
140 }
141}
142
143pub fn axis_basis_at(lo: f64, h: f64, cells: usize, x: f64) -> (usize, [f64; 4]) {
149 let (cell, u) = Axis { lo, h, cells }.locate(x);
150 (cell, bspline_value(u))
151}
152
153#[inline]
156fn basis_row(axes: &[Axis; 2], m_axis: usize, x1: f64, x2: f64) -> ([usize; 16], [f64; 16]) {
157 let (c1, u1) = axes[0].locate(x1);
158 let (c2, u2) = axes[1].locate(x2);
159 let b1 = bspline_value(u1);
160 let b2 = bspline_value(u2);
161 let mut idx = [0usize; 16];
162 let mut val = [0f64; 16];
163 for i in 0..4 {
164 for j in 0..4 {
165 idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
166 val[4 * i + j] = b1[i] * b2[j];
167 }
168 }
169 (idx, val)
170}
171
172pub fn cholesky_logdet(a: &mut [f64], p: usize) -> Result<f64, String> {
176 let mut logdet = 0.0;
177 for j in 0..p {
178 let mut s = a[j * p + j];
179 for t in 0..j {
180 s -= a[j * p + t] * a[j * p + t];
181 }
182 if !(s.is_finite() && s > PIVOT_FLOOR) {
183 return Err(format!(
184 "grid spline 2d: penalized system not positive definite at pivot {j} (value {s})"
185 ));
186 }
187 let l = s.sqrt();
188 a[j * p + j] = l;
189 logdet += 2.0 * l.ln();
190 for i in j + 1..p {
191 let mut s2 = a[i * p + j];
192 for t in 0..j {
193 s2 -= a[i * p + t] * a[j * p + t];
194 }
195 a[i * p + j] = s2 / l;
196 }
197 }
198 for i in 0..p {
199 for j in i + 1..p {
200 a[i * p + j] = 0.0;
201 }
202 }
203 Ok(logdet)
204}
205
206fn lower_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
208 let mut z = b.to_vec();
209 for i in 0..p {
210 let mut s = z[i];
211 for t in 0..i {
212 s -= l[i * p + t] * z[t];
213 }
214 z[i] = s / l[i * p + i];
215 }
216 z
217}
218
219pub fn chol_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
221 let mut z = lower_solve(l, p, b);
222 for i in (0..p).rev() {
223 let mut s = z[i];
224 for t in i + 1..p {
225 s -= l[t * p + i] * z[t];
226 }
227 z[i] = s / l[i * p + i];
228 }
229 z
230}
231
232pub struct GridSpline2dDesign {
235 axes: [Axis; 2],
236 m_axis: usize,
238 p: usize,
240 band_half: usize,
242 gram_band: Vec<f64>,
244 pen_band: Vec<f64>,
246 rhs: Vec<Vec<f64>>,
250 cross_moments: Vec<f64>,
253 n_obs: usize,
254}
255
256struct Solved {
258 chol: Vec<f64>,
259 logdet: f64,
260 coeffs: Vec<Vec<f64>>,
261 rss_pen: Vec<f64>,
264}
265
266struct RemlSpectrum {
270 gram_modes: Vec<f64>,
271 penalty_modes: Vec<f64>,
272 projected_rhs_squared: Vec<f64>,
273 response_energy: Vec<f64>,
274 residual_dof: f64,
275 logdet_constant: f64,
276}
277
278impl RemlSpectrum {
279 fn profile(&self) -> Result<AffineRemlProfile<'_>, String> {
280 AffineRemlProfile::new(
281 &self.gram_modes,
282 &self.penalty_modes,
283 &self.projected_rhs_squared,
284 &self.response_energy,
285 self.residual_dof,
286 self.penalty_modes.len() - PENALTY_NULLITY,
287 self.logdet_constant,
288 )
289 .map_err(|error| format!("grid spline 2d: invalid REML spectrum: {error}"))
290 }
291
292 fn log_lambda_domain(&self) -> Result<(f64, f64), String> {
298 let mut lowest_transition = f64::INFINITY;
299 let mut highest_transition = f64::NEG_INFINITY;
300 for (&gram, &penalty) in self.gram_modes.iter().zip(&self.penalty_modes) {
301 if gram > 0.0 && penalty > 0.0 {
302 let transition = certified_ln_positive(gram)
303 .ok_or_else(|| {
304 "grid spline 2d: could not enclose a Gram-mode logarithm".to_string()
305 })?
306 .sub(certified_ln_positive(penalty).ok_or_else(|| {
307 "grid spline 2d: could not enclose a penalty-mode logarithm".to_string()
308 })?);
309 lowest_transition = lowest_transition.min(transition.lo);
310 highest_transition = highest_transition.max(transition.hi);
311 }
312 }
313 if !(lowest_transition.is_finite() && highest_transition.is_finite()) {
314 lowest_transition = 0.0;
315 highest_transition = 0.0;
316 }
317 let margin = certified_ln_positive(f64::EPSILON.sqrt())
318 .ok_or_else(|| {
319 "grid spline 2d: could not enclose the spectral-domain margin".to_string()
320 })?
321 .neg();
322 let minimum_log = certified_ln_positive(f64::MIN_POSITIVE)
323 .ok_or_else(|| {
324 "grid spline 2d: could not enclose the minimum-normal logarithm".to_string()
325 })?;
326 let maximum_log = certified_ln_positive(f64::MAX)
327 .ok_or_else(|| {
328 "grid spline 2d: could not enclose the maximum-finite logarithm".to_string()
329 })?;
330 let lo = ClosedInterval::point(lowest_transition)
331 .sub(margin)
332 .lo
333 .max(minimum_log.lo);
334 let hi = ClosedInterval::point(highest_transition)
335 .add(margin)
336 .hi
337 .min(maximum_log.lo);
338 if !(lo < hi) {
339 return Err(format!(
340 "grid spline 2d: no representable REML search domain after spectral scaling ({lo}, {hi})"
341 ));
342 }
343 Ok((lo, hi))
344 }
345}
346
347impl GridSpline2dDesign {
348 pub fn build(
350 x1: &[f64],
351 x2: &[f64],
352 y: &[f64],
353 w: &[f64],
354 k: usize,
355 metric: [f64; 2],
356 ) -> Result<Self, String> {
357 Self::build_multi(x1, x2, &[y], w, k, metric)
358 }
359
360 pub fn build_multi(
367 x1: &[f64],
368 x2: &[f64],
369 responses: &[&[f64]],
370 w: &[f64],
371 k: usize,
372 metric: [f64; 2],
373 ) -> Result<Self, String> {
374 let n = x1.len();
375 if responses.is_empty() {
376 return Err("grid spline 2d: no response dimensions supplied".to_string());
377 }
378 if x2.len() != n || w.len() != n {
379 return Err(format!(
380 "grid spline 2d: length mismatch x1={n}, x2={}, w={}",
381 x2.len(),
382 w.len()
383 ));
384 }
385 for (d, y) in responses.iter().enumerate() {
386 if y.len() != n {
387 return Err(format!(
388 "grid spline 2d: response dimension {d} has length {} != {n}",
389 y.len()
390 ));
391 }
392 }
393 if n <= PENALTY_NULLITY {
394 return Err(format!(
395 "grid spline 2d: needs more than {PENALTY_NULLITY} rows for the profiled REML \
396 degrees of freedom, got {n}"
397 ));
398 }
399 if k == 0 || k > MAX_CELLS_PER_AXIS {
400 return Err(format!(
401 "grid spline 2d: k must be in 1..={MAX_CELLS_PER_AXIS} (dense Cholesky on \
402 (k+3)² coefficients — see module sizing contract), got {k}"
403 ));
404 }
405 if !(metric[0].is_finite() && metric[0] > 0.0 && metric[1].is_finite() && metric[1] > 0.0) {
406 return Err(format!(
407 "grid spline 2d: metric diagonal must be finite and positive, got [{}, {}]",
408 metric[0], metric[1]
409 ));
410 }
411 for i in 0..n {
412 if !(x1[i].is_finite() && x2[i].is_finite()) || !(w[i] > 0.0) || !w[i].is_finite() {
413 return Err(format!(
414 "grid spline 2d: non-finite or non-positive input at row {i} \
415 (x1={}, x2={}, w={})",
416 x1[i], x2[i], w[i]
417 ));
418 }
419 for (d, y) in responses.iter().enumerate() {
420 if !y[i].is_finite() {
421 return Err(format!(
422 "grid spline 2d: non-finite response at row {i}, dimension {d} ({})",
423 y[i]
424 ));
425 }
426 }
427 }
428 let mut axes = [Axis {
429 lo: 0.0,
430 h: 1.0,
431 cells: k,
432 }; 2];
433 for (axis, xs) in axes.iter_mut().zip([x1, x2]) {
434 let mut lo = f64::INFINITY;
435 let mut hi = f64::NEG_INFINITY;
436 for &v in xs {
437 lo = lo.min(v);
438 hi = hi.max(v);
439 }
440 if !(hi > lo) {
441 return Err(format!(
442 "grid spline 2d: degenerate axis bounding box [{lo}, {hi}]"
443 ));
444 }
445 axis.lo = lo;
446 axis.h = (hi - lo) / k as f64;
447 }
448 let m_axis = k + 3;
449 let p = m_axis * m_axis;
450 let band_half = 3 * m_axis + 3;
451 let stride = band_half + 1;
452 let n_dims = responses.len();
453 let mut gram_band = vec![0.0_f64; p * stride];
454 let mut rhs = vec![vec![0.0_f64; p]; n_dims];
455 let mut cross_moments = vec![0.0_f64; n_dims * n_dims];
456
457 for i in 0..n {
462 let (idx, val) = basis_row(&axes, m_axis, x1[i], x2[i]);
463 let wi = w[i];
464 for (d, y) in responses.iter().enumerate() {
465 let wy = wi * y[i];
466 for e in 0..16 {
467 rhs[d][idx[e]] += wy * val[e];
468 }
469 for (e, ye) in responses.iter().enumerate().skip(d) {
470 cross_moments[d * n_dims + e] += wy * ye[i];
471 }
472 }
473 for a in 0..16 {
474 let base = idx[a] * stride - idx[a];
475 let wa = wi * val[a];
476 for b in a..16 {
477 gram_band[base + idx[b]] += wa * val[b];
478 }
479 }
480 }
481 for d in 0..n_dims {
482 for e in 0..d {
483 cross_moments[d * n_dims + e] = cross_moments[e * n_dims + d];
484 }
485 }
486
487 let mut tab = [[[[0.0_f64; 4]; 4]; 3]; 2]; for ax in 0..2 {
492 let h = axes[ax].h;
493 for q in 0..4 {
494 let u = 0.5 * (1.0 + GL4_NODES[q]);
495 let v0 = bspline_value(u);
496 let v1 = bspline_d1(u);
497 let v2 = bspline_d2(u);
498 for e in 0..4 {
499 tab[ax][0][q][e] = v0[e];
500 tab[ax][1][q][e] = v1[e] / h;
501 tab[ax][2][q][e] = v2[e] / (h * h);
502 }
503 }
504 }
505 let s11 = metric[0] * metric[0];
507 let s12 = 2.0 * metric[0] * metric[1];
508 let s22 = metric[1] * metric[1];
509 let cell_area_jac = 0.25 * axes[0].h * axes[1].h; let mut pen_band = vec![0.0_f64; p * stride];
511 let mut r11 = [0.0_f64; 16];
512 let mut r12 = [0.0_f64; 16];
513 let mut r22 = [0.0_f64; 16];
514 let mut idx = [0usize; 16];
515 for c1 in 0..k {
516 for c2 in 0..k {
517 for i in 0..4 {
518 for j in 0..4 {
519 idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
520 }
521 }
522 for q1 in 0..4 {
523 for q2 in 0..4 {
524 let wq = cell_area_jac * GL4_WEIGHTS[q1] * GL4_WEIGHTS[q2];
525 for i in 0..4 {
526 for j in 0..4 {
527 let e = 4 * i + j;
528 r11[e] = tab[0][2][q1][i] * tab[1][0][q2][j];
529 r12[e] = tab[0][1][q1][i] * tab[1][1][q2][j];
530 r22[e] = tab[0][0][q1][i] * tab[1][2][q2][j];
531 }
532 }
533 for a in 0..16 {
534 let base = idx[a] * stride - idx[a];
535 let (pa11, pa12, pa22) =
536 (wq * s11 * r11[a], wq * s12 * r12[a], wq * s22 * r22[a]);
537 for b in a..16 {
538 pen_band[base + idx[b]] +=
539 pa11 * r11[b] + pa12 * r12[b] + pa22 * r22[b];
540 }
541 }
542 }
543 }
544 }
545 }
546
547 Ok(GridSpline2dDesign {
548 axes,
549 m_axis,
550 p,
551 band_half,
552 gram_band,
553 pen_band,
554 rhs,
555 cross_moments,
556 n_obs: n,
557 })
558 }
559
560
561 pub fn basis_per_axis(&self) -> usize {
563 self.m_axis
564 }
565
566 pub fn num_coeffs(&self) -> usize {
568 self.p
569 }
570
571 pub fn lower_corner(&self) -> [f64; 2] {
573 [self.axes[0].lo, self.axes[1].lo]
574 }
575
576 pub fn cell_widths(&self) -> [f64; 2] {
578 [self.axes[0].h, self.axes[1].h]
579 }
580
581 pub fn num_rows(&self) -> usize {
583 self.n_obs
584 }
585
586 pub fn num_responses(&self) -> usize {
588 self.rhs.len()
589 }
590
591 pub fn axis_basis(&self, axis: usize, x: f64) -> Result<(usize, [f64; 4]), String> {
597 if axis > 1 {
598 return Err(format!("grid spline 2d: axis {axis} out of range"));
599 }
600 if !x.is_finite() {
601 return Err(format!("grid spline 2d: non-finite axis-{axis} point {x}"));
602 }
603 let ax = self.axes[axis];
604 Ok(axis_basis_at(ax.lo, ax.h, ax.cells, x))
605 }
606
607 pub fn penalty_value(&self, coeff: &[f64]) -> Result<f64, String> {
610 if coeff.len() != self.p {
611 return Err(format!(
612 "grid spline 2d: coefficient length {} != {}",
613 coeff.len(),
614 self.p
615 ));
616 }
617 let stride = self.band_half + 1;
618 let mut j = 0.0;
619 for g in 0..self.p {
620 let dmax = self.band_half.min(self.p - 1 - g);
621 j += self.pen_band[g * stride] * coeff[g] * coeff[g];
622 for d in 1..=dmax {
623 j += 2.0 * self.pen_band[g * stride + d] * coeff[g] * coeff[g + d];
624 }
625 }
626 Ok(j)
627 }
628
629 fn dense_system(&self, lambda: f64) -> Vec<f64> {
631 let p = self.p;
632 let stride = self.band_half + 1;
633 let mut a = vec![0.0_f64; p * p];
634 for g in 0..p {
635 let dmax = self.band_half.min(p - 1 - g);
636 for d in 0..=dmax {
637 let v = self.gram_band[g * stride + d] + lambda * self.pen_band[g * stride + d];
638 a[g * p + g + d] = v;
639 a[(g + d) * p + g] = v;
640 }
641 }
642 a
643 }
644
645 fn dense_penalty(&self) -> Vec<f64> {
647 let p = self.p;
648 let stride = self.band_half + 1;
649 let mut penalty = vec![0.0_f64; p * p];
650 for g in 0..p {
651 let dmax = self.band_half.min(p - 1 - g);
652 for d in 0..=dmax {
653 let value = self.pen_band[g * stride + d];
654 penalty[g * p + g + d] = value;
655 penalty[(g + d) * p + g] = value;
656 }
657 }
658 penalty
659 }
660
661 fn reml_spectrum(&self) -> Result<RemlSpectrum, String> {
667 let p = self.p;
668 let mut reference_chol = self.dense_system(1.0);
669 let logdet_constant = cholesky_logdet(&mut reference_chol, p)?;
670
671 let lower = Mat::from_fn(p, p, |row, col| {
672 if row >= col {
673 reference_chol[row * p + col]
674 } else {
675 0.0
676 }
677 });
678 let dense_penalty = self.dense_penalty();
679 let mut whitened = Mat::from_fn(p, p, |row, col| dense_penalty[row * p + col]);
680 lower
684 .as_ref()
685 .solve_lower_triangular_in_place(whitened.as_mut());
686 lower
687 .as_ref()
688 .solve_lower_triangular_in_place(whitened.as_mut().transpose_mut());
689 if (0..p).any(|row| (0..p).any(|col| !whitened[(row, col)].is_finite())) {
690 return Err("grid spline 2d: non-finite whitened penalty".to_string());
691 }
692 let mut symmetry_correction_rows = vec![0.0_f64; p];
695 for row in 0..p {
696 for col in row + 1..p {
697 let correction = 0.5 * (whitened[(row, col)] - whitened[(col, row)]).abs();
698 symmetry_correction_rows[row] += correction;
699 symmetry_correction_rows[col] += correction;
700 let value = 0.5 * (whitened[(row, col)] + whitened[(col, row)]);
701 whitened[(row, col)] = value;
702 whitened[(col, row)] = value;
703 }
704 }
705 let matrix_inf_norm = (0..p).fold(0.0_f64, |norm, row| {
706 let row_sum = (0..p).map(|col| whitened[(row, col)].abs()).sum();
707 norm.max(row_sum)
708 });
709 let p_epsilon = p as f64 * f64::EPSILON;
713 let symmetrization_error = symmetry_correction_rows.into_iter().fold(0.0_f64, f64::max);
714 let eigenvalue_roundoff =
715 symmetrization_error + (p_epsilon / (1.0 - p_epsilon)) * matrix_inf_norm.max(1.0);
716 let eigensystem = whitened
717 .as_ref()
718 .self_adjoint_eigen(Side::Lower)
719 .map_err(|error| {
720 format!("grid spline 2d: reference-pencil eigendecomposition failed: {error:?}")
721 })?;
722 let eigenvalues = eigensystem.S();
723 let eigenvectors = eigensystem.U();
724
725 let mut order: Vec<usize> = (0..p).collect();
727 order.sort_unstable_by(|&left, &right| eigenvalues[left].total_cmp(&eigenvalues[right]));
728
729 let mut gram_modes = Vec::with_capacity(p);
730 let mut penalty_modes = Vec::with_capacity(p);
731 for (position, &mode) in order.iter().enumerate() {
732 let raw = eigenvalues[mode];
733 if !raw.is_finite() {
734 return Err(format!(
735 "grid spline 2d: non-finite reference-pencil eigenvalue at mode {position}"
736 ));
737 }
738
739 if raw < -eigenvalue_roundoff || raw > 1.0 + eigenvalue_roundoff {
743 return Err(format!(
744 "grid spline 2d: reference-pencil eigenvalue {raw} at mode {position} lies outside the certified [0, 1] roundoff band ±{eigenvalue_roundoff}"
745 ));
746 }
747 let penalty = if position < PENALTY_NULLITY {
748 if raw.abs() > eigenvalue_roundoff {
749 return Err(format!(
750 "grid spline 2d: expected null mode {position} has eigenvalue {raw}, outside zero roundoff band ±{eigenvalue_roundoff}"
751 ));
752 }
753 0.0
754 } else if raw <= eigenvalue_roundoff {
755 return Err(format!(
756 "grid spline 2d: penalty rank is below {}: non-null mode {position} has eigenvalue {raw} inside zero roundoff band ±{eigenvalue_roundoff}",
757 p - PENALTY_NULLITY,
758 ));
759 } else {
760 raw.min(1.0)
763 };
764 penalty_modes.push(penalty);
765 gram_modes.push(1.0 - penalty);
766 }
767
768 let n_dims = self.rhs.len();
769 let mut projected_rhs_squared = Vec::with_capacity(n_dims * p);
770 for rhs in &self.rhs {
771 let whitened_rhs = lower_solve(&reference_chol, p, rhs);
772 for &mode in &order {
773 let mut coordinate = 0.0;
774 for row in 0..p {
775 coordinate += eigenvectors[(row, mode)] * whitened_rhs[row];
776 }
777 projected_rhs_squared.push(coordinate * coordinate);
778 }
779 }
780
781 let response_energy = (0..n_dims)
782 .map(|dimension| self.cross_moments[dimension * n_dims + dimension])
783 .collect();
784 Ok(RemlSpectrum {
785 gram_modes,
786 penalty_modes,
787 projected_rhs_squared,
788 response_energy,
789 residual_dof: (self.n_obs - PENALTY_NULLITY) as f64,
790 logdet_constant,
791 })
792 }
793
794 fn solve_at(&self, log_lambda: f64) -> Result<Solved, String> {
795 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
796 .map_err(|error| format!("grid spline 2d: {error}"))?;
797 let mut a = self.dense_system(lambda);
798 let logdet = cholesky_logdet(&mut a, self.p)?;
799 let n_dims = self.rhs.len();
800 let mut coeffs = Vec::with_capacity(n_dims);
801 let mut rss_pen = Vec::with_capacity(n_dims);
802 for (d, rhs) in self.rhs.iter().enumerate() {
803 let coeff = chol_solve(&a, self.p, rhs);
804 let mut quad = 0.0;
805 for g in 0..self.p {
806 quad += rhs[g] * coeff[g];
807 }
808 rss_pen.push(self.cross_moments[d * n_dims + d] - quad);
809 coeffs.push(coeff);
810 }
811 Ok(Solved {
812 chol: a,
813 logdet,
814 coeffs,
815 rss_pen,
816 })
817 }
818
819 pub fn fit_at(&self, log_lambda: f64, sigma2: Option<f64>) -> Result<GridSpline2dFit, String> {
822 let solved = self.solve_at(log_lambda)?;
823 let dof = (self.n_obs - PENALTY_NULLITY) as f64;
824 let mut sigma2_dims = Vec::with_capacity(solved.rss_pen.len());
825 for &rss in &solved.rss_pen {
826 match sigma2 {
827 Some(s) => {
828 if !(s.is_finite() && s > 0.0) {
829 return Err(format!("grid spline 2d: invalid sigma2 {s}"));
830 }
831 sigma2_dims.push(s);
832 }
833 None => {
834 if !(rss > 0.0) {
835 return Err(format!(
836 "grid spline 2d: degenerate penalized residual {rss}"
837 ));
838 }
839 sigma2_dims.push(rss / dof);
840 }
841 }
842 }
843 let r = (self.p - PENALTY_NULLITY) as f64;
848 let mut restricted_loglik = 0.0;
849 for (d, &rss) in solved.rss_pen.iter().enumerate() {
850 restricted_loglik -= 0.5
851 * (solved.logdet - r * log_lambda
852 + dof * sigma2_dims[d].ln()
853 + rss / sigma2_dims[d]);
854 }
855 Ok(GridSpline2dFit {
856 coeffs: solved.coeffs,
857 log_lambda,
858 sigma2: sigma2_dims,
859 restricted_loglik,
860 chol: solved.chol,
861 axes: self.axes,
862 m_axis: self.m_axis,
863 })
864 }
865
866 pub fn fit_reml(&self) -> Result<GridSpline2dFit, String> {
871 let spectrum = self.reml_spectrum()?;
872 let profile = spectrum.profile()?;
873 let (log_lambda_lo, log_lambda_hi) = spectrum.log_lambda_domain()?;
874 let search = profile
875 .maximize_value_ordered(log_lambda_lo, log_lambda_hi, f64::EPSILON.sqrt())
876 .map_err(|error| format!("grid spline 2d: REML optimization failed: {error}"))?;
877 if search.value_certificate.maximum_excess
878 > search.value_certificate.comparison_resolution
879 {
880 return Err(format!(
881 "grid spline 2d: REML candidates are not globally ordered \
882 (maximum excess {}, comparison resolution {})",
883 search.value_certificate.maximum_excess,
884 search.value_certificate.comparison_resolution
885 ));
886 }
887 enum KktKind {
888 LowerBoundary,
889 UpperBoundary,
890 Stationary,
891 }
892 let (bracket, kkt_kind) = match search.location {
893 ScoreOptimumLocation::LowerBoundary => (
894 gam_math::score_opt::ClosedInterval::point(search.lower_boundary.x),
895 KktKind::LowerBoundary,
896 ),
897 ScoreOptimumLocation::UpperBoundary => (
898 gam_math::score_opt::ClosedInterval::point(search.upper_boundary.x),
899 KktKind::UpperBoundary,
900 ),
901 ScoreOptimumLocation::Stationary(index) => (
902 search
903 .stationary_points
904 .get(index)
905 .ok_or_else(|| {
906 "grid spline 2d: optimizer returned an invalid stationary index".to_string()
907 })?
908 .bracket,
909 KktKind::Stationary,
910 ),
911 ScoreOptimumLocation::ResolutionFlat(index) => {
912 let flat = search.resolution_flat_regions.get(index).ok_or_else(|| {
913 "grid spline 2d: optimizer returned an invalid resolution-flat index"
914 .to_string()
915 })?;
916 return Err(format!(
917 "grid spline 2d: REML optimum is value-resolved but not stationary on \
918 {:?} (gap {}, resolution {})",
919 flat.bracket, flat.max_score_gap, flat.score_resolution
920 ));
921 }
922 };
923 let kkt = profile
924 .enclose(bracket.lo, bracket.hi)
925 .map_err(|error| format!("grid spline 2d: {error}"))?;
926 let kkt_holds = match kkt_kind {
927 KktKind::LowerBoundary => kkt.derivative.hi <= 0.0,
928 KktKind::UpperBoundary => kkt.derivative.lo >= 0.0,
929 KktKind::Stationary => {
930 kkt.derivative.contains_zero() && kkt.curvature.hi < 0.0
931 }
932 };
933 if !kkt_holds {
934 return Err(format!(
935 "grid spline 2d: exact-real REML KKT certificate failed on {bracket:?}: {kkt:?}"
936 ));
937 }
938 self.fit_at(search.optimum.x, None)
939 }
940
941 fn gram_quadratic(&self, a: &[f64], b: &[f64]) -> f64 {
943 let stride = self.band_half + 1;
944 let mut q = 0.0;
945 for g in 0..self.p {
946 let dmax = self.band_half.min(self.p - 1 - g);
947 q += self.gram_band[g * stride] * a[g] * b[g];
948 for d in 1..=dmax {
949 q += self.gram_band[g * stride + d] * (a[g] * b[g + d] + a[g + d] * b[g]);
950 }
951 }
952 q
953 }
954
955 pub fn posterior(&self, fit: &GridSpline2dFit) -> Result<GridSpline2dPosterior, String> {
965 let p = self.p;
966 let n_dims = self.rhs.len();
967 if fit.coeffs.len() != n_dims || fit.coeffs.iter().any(|c| c.len() != p) {
968 return Err(format!(
969 "grid spline 2d: posterior asked for a fit with {} dimensions of length {}, \
970 design has {n_dims} of {p}",
971 fit.coeffs.len(),
972 fit.coeffs.first().map_or(0, Vec::len)
973 ));
974 }
975 let mut unit_covariance = vec![0.0_f64; p * p];
977 let mut e_g = vec![0.0_f64; p];
978 for g in 0..p {
979 e_g[g] = 1.0;
980 let col = chol_solve(&fit.chol, p, &e_g);
981 e_g[g] = 0.0;
982 for (r, &v) in col.iter().enumerate() {
983 unit_covariance[r * p + g] = v;
984 }
985 }
986 let stride = self.band_half + 1;
988 let mut edf = 0.0;
989 for g in 0..p {
990 let dmax = self.band_half.min(p - 1 - g);
991 edf += self.gram_band[g * stride] * unit_covariance[g * p + g];
992 for d in 1..=dmax {
993 edf += 2.0 * self.gram_band[g * stride + d] * unit_covariance[g * p + g + d];
994 }
995 }
996 let residual_df = self.n_obs as f64 - edf;
997 if !(residual_df >= 1.0) {
998 return Err(format!(
999 "grid spline 2d: too few rows for a scale estimate \
1000 (n = {}, edf = {edf:.2}; need n − edf ≥ 1)",
1001 self.n_obs
1002 ));
1003 }
1004 let mut residual_cross_cov = vec![0.0_f64; n_dims * n_dims];
1005 for d in 0..n_dims {
1006 for e in d..n_dims {
1007 let mut cd_rhse = 0.0;
1008 let mut ce_rhsd = 0.0;
1009 for g in 0..p {
1010 cd_rhse += fit.coeffs[d][g] * self.rhs[e][g];
1011 ce_rhsd += fit.coeffs[e][g] * self.rhs[d][g];
1012 }
1013 let quad = self.gram_quadratic(&fit.coeffs[d], &fit.coeffs[e]);
1014 let v =
1015 (self.cross_moments[d * n_dims + e] - cd_rhse - ce_rhsd + quad) / residual_df;
1016 residual_cross_cov[d * n_dims + e] = v;
1017 residual_cross_cov[e * n_dims + d] = v;
1018 }
1019 }
1020 Ok(GridSpline2dPosterior {
1021 unit_covariance,
1022 edf,
1023 residual_df,
1024 residual_cross_cov,
1025 })
1026 }
1027}
1028
1029pub struct GridSpline2dPosterior {
1033 pub unit_covariance: Vec<f64>,
1036 pub edf: f64,
1038 pub residual_df: f64,
1040 pub residual_cross_cov: Vec<f64>,
1042}
1043
1044pub struct GridSpline2dFit {
1046 pub coeffs: Vec<Vec<f64>>,
1049 pub log_lambda: f64,
1052 pub sigma2: Vec<f64>,
1054 pub restricted_loglik: f64,
1057 chol: Vec<f64>,
1060 axes: [Axis; 2],
1061 m_axis: usize,
1062}
1063
1064#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1076pub struct GridSpline2dState {
1077 pub coeffs: Vec<Vec<f64>>,
1079 pub log_lambda: f64,
1080 pub sigma2: Vec<f64>,
1082 pub restricted_loglik: f64,
1083 pub chol: Vec<f64>,
1086 pub axis_lo: [f64; 2],
1088 pub axis_h: [f64; 2],
1090 pub axis_cells: [u64; 2],
1092 pub m_axis: u64,
1094}
1095
1096impl GridSpline2dFit {
1097 pub fn to_state(&self) -> GridSpline2dState {
1101 GridSpline2dState {
1102 coeffs: self.coeffs.clone(),
1103 log_lambda: self.log_lambda,
1104 sigma2: self.sigma2.clone(),
1105 restricted_loglik: self.restricted_loglik,
1106 chol: self.chol.clone(),
1107 axis_lo: [self.axes[0].lo, self.axes[1].lo],
1108 axis_h: [self.axes[0].h, self.axes[1].h],
1109 axis_cells: [self.axes[0].cells as u64, self.axes[1].cells as u64],
1110 m_axis: self.m_axis as u64,
1111 }
1112 }
1113
1114 pub fn from_state(state: &GridSpline2dState) -> Result<Self, String> {
1121 let m_axis = state.m_axis as usize;
1122 let p = m_axis * m_axis;
1123 for a in 0..2 {
1124 let cells = state.axis_cells[a] as usize;
1125 if cells == 0 {
1126 return Err(format!(
1127 "grid spline 2d state: axis {a} must have at least one cell"
1128 ));
1129 }
1130 if m_axis != cells + 3 {
1131 return Err(format!(
1132 "grid spline 2d state: m_axis {m_axis} must equal K+3 = {} for axis {a}",
1133 cells + 3
1134 ));
1135 }
1136 if !(state.axis_lo[a].is_finite()
1137 && state.axis_h[a].is_finite()
1138 && state.axis_h[a] > 0.0)
1139 {
1140 return Err(format!(
1141 "grid spline 2d state: axis {a} must have finite lo and positive h, got lo={}, h={}",
1142 state.axis_lo[a], state.axis_h[a]
1143 ));
1144 }
1145 }
1146 if state.chol.len() != p * p {
1147 return Err(format!(
1148 "grid spline 2d state: chol must be p×p = {p}² = {}, got {}",
1149 p * p,
1150 state.chol.len()
1151 ));
1152 }
1153 let d = state.coeffs.len();
1154 if d == 0 || state.sigma2.len() != d {
1155 return Err(format!(
1156 "grid spline 2d state: need ≥1 response dimension with matching σ² (coeffs D={d}, sigma2 D={})",
1157 state.sigma2.len()
1158 ));
1159 }
1160 for (dim, c) in state.coeffs.iter().enumerate() {
1161 if c.len() != p {
1162 return Err(format!(
1163 "grid spline 2d state: response dimension {dim} has {} coeffs, expected p = {p}",
1164 c.len()
1165 ));
1166 }
1167 }
1168 for (dim, &s2) in state.sigma2.iter().enumerate() {
1169 if !(s2.is_finite() && s2 > 0.0) {
1170 return Err(format!(
1171 "grid spline 2d state: response dimension {dim} has non-positive σ² = {s2}"
1172 ));
1173 }
1174 }
1175 for (i, v) in state
1176 .chol
1177 .iter()
1178 .chain(state.coeffs.iter().flatten())
1179 .enumerate()
1180 {
1181 if !v.is_finite() {
1182 return Err(format!("grid spline 2d state: non-finite entry at {i}"));
1183 }
1184 }
1185 for g in 0..p {
1189 let piv = state.chol[g * p + g];
1190 if !(piv.is_finite() && piv > 0.0) {
1191 return Err(format!(
1192 "grid spline 2d state: non-positive Cholesky pivot {piv} at index {g}"
1193 ));
1194 }
1195 }
1196 if !(state.log_lambda.is_finite() && state.restricted_loglik.is_finite()) {
1197 return Err(format!(
1198 "grid spline 2d state: invalid scalars (log_lambda={}, restricted_loglik={})",
1199 state.log_lambda, state.restricted_loglik
1200 ));
1201 }
1202 let axes = [
1203 Axis {
1204 lo: state.axis_lo[0],
1205 h: state.axis_h[0],
1206 cells: state.axis_cells[0] as usize,
1207 },
1208 Axis {
1209 lo: state.axis_lo[1],
1210 h: state.axis_h[1],
1211 cells: state.axis_cells[1] as usize,
1212 },
1213 ];
1214 Ok(GridSpline2dFit {
1215 coeffs: state.coeffs.clone(),
1216 log_lambda: state.log_lambda,
1217 sigma2: state.sigma2.clone(),
1218 restricted_loglik: state.restricted_loglik,
1219 chol: state.chol.clone(),
1220 axes,
1221 m_axis,
1222 })
1223 }
1224
1225 pub fn predict(&self, dim: usize, x1: f64, x2: f64) -> Result<(f64, f64), String> {
1230 if dim >= self.coeffs.len() {
1231 return Err(format!(
1232 "grid spline 2d: response dimension {dim} out of range (D = {})",
1233 self.coeffs.len()
1234 ));
1235 }
1236 if !(x1.is_finite() && x2.is_finite()) {
1237 return Err(format!(
1238 "grid spline 2d: non-finite prediction point ({x1}, {x2})"
1239 ));
1240 }
1241 let (idx, val) = basis_row(&self.axes, self.m_axis, x1, x2);
1242 let p = self.coeffs[dim].len();
1243 let mut mean = 0.0;
1244 let mut row = vec![0.0_f64; p];
1245 for e in 0..16 {
1246 mean += val[e] * self.coeffs[dim][idx[e]];
1247 row[idx[e]] += val[e];
1248 }
1249 let z = chol_solve(&self.chol, p, &row);
1250 let mut quad = 0.0;
1251 for g in 0..p {
1252 quad += row[g] * z[g];
1253 }
1254 Ok((mean, self.sigma2[dim] * quad))
1255 }
1256}
1257
1258pub fn fit_grid_spline_2d(
1260 x1: &[f64],
1261 x2: &[f64],
1262 y: &[f64],
1263 w: &[f64],
1264 k: usize,
1265 metric: [f64; 2],
1266) -> Result<GridSpline2dFit, String> {
1267 GridSpline2dDesign::build(x1, x2, y, w, k, metric)?.fit_reml()
1268}
1269
1270pub fn fit_grid_spline_2d_at(
1272 x1: &[f64],
1273 x2: &[f64],
1274 y: &[f64],
1275 w: &[f64],
1276 k: usize,
1277 metric: [f64; 2],
1278 log_lambda: f64,
1279 sigma2: Option<f64>,
1280) -> Result<GridSpline2dFit, String> {
1281 GridSpline2dDesign::build(x1, x2, y, w, k, metric)?.fit_at(log_lambda, sigma2)
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287
1288 #[test]
1289 fn affine_reml_profile_matches_direct_factorizations() {
1290 let side = 10usize;
1291 let mut x1 = Vec::with_capacity(side * side);
1292 let mut x2 = Vec::with_capacity(side * side);
1293 let mut y0 = Vec::with_capacity(side * side);
1294 let mut y1 = Vec::with_capacity(side * side);
1295 for i in 0..side {
1296 for j in 0..side {
1297 let a = i as f64 / (side - 1) as f64;
1298 let b = j as f64 / (side - 1) as f64;
1299 x1.push(a);
1300 x2.push(b);
1301 y0.push((2.0 * a).sin() * (3.0 * b).cos() + a * b);
1302 y1.push(a * a - b * b + (a + 2.0 * b).sin());
1303 }
1304 }
1305 let weights = vec![1.0; x1.len()];
1306 let responses: [&[f64]; 2] = [&y0, &y1];
1307 let design = GridSpline2dDesign::build_multi(&x1, &x2, &responses, &weights, 3, [1.0, 1.5])
1308 .expect("design");
1309 let spectrum = design.reml_spectrum().expect("reference pencil");
1310 let profile = spectrum.profile().expect("affine profile");
1311 let dof = (design.n_obs - PENALTY_NULLITY) as f64;
1312 let rank = (design.p - PENALTY_NULLITY) as f64;
1313
1314 for log_lambda in [-5.0, 0.0, 6.0] {
1315 let solved = design.solve_at(log_lambda).expect("direct solve");
1316 let shared = solved.logdet - rank * log_lambda;
1317 let direct = -0.5
1318 * solved
1319 .rss_pen
1320 .iter()
1321 .map(|rss| shared + dof * (rss / dof).ln())
1322 .sum::<f64>();
1323 let spectral = profile.evaluate(log_lambda).expect("spectral score").value;
1324 assert!(
1325 (direct - spectral).abs() <= f64::EPSILON.sqrt() * (1.0 + direct.abs()),
1326 "score mismatch at log lambda {log_lambda}: direct={direct}, spectral={spectral}"
1327 );
1328 }
1329 }
1330
1331 #[test]
1336 fn grid_spline_2d_state_roundtrip_reproduces_predict() {
1337 let k = 8usize;
1338 let mut x1 = Vec::new();
1340 let mut x2 = Vec::new();
1341 let mut y0 = Vec::new();
1342 let mut y1 = Vec::new();
1343 for i in 0..24 {
1344 for j in 0..24 {
1345 let a = i as f64 / 23.0;
1346 let b = j as f64 / 23.0;
1347 x1.push(a);
1348 x2.push(b);
1349 y0.push((2.5 * a).sin() * (1.7 * b).cos() + 0.3 * a * b);
1350 y1.push(a * a - 0.5 * b + 0.2 * (3.0 * a * b).cos());
1351 }
1352 }
1353 let n = x1.len();
1354 let w = vec![1.0_f64; n];
1355 let ys: Vec<&[f64]> = vec![&y0, &y1];
1356 let fit = GridSpline2dDesign::build_multi(&x1, &x2, &ys, &w, k, [1.0, 1.0])
1357 .expect("design")
1358 .fit_reml()
1359 .expect("fit");
1360
1361 let json = serde_json::to_string(&fit.to_state()).expect("serialize");
1362 let state: GridSpline2dState = serde_json::from_str(&json).expect("deserialize");
1363 let restored = GridSpline2dFit::from_state(&state).expect("restore");
1364
1365 let probes = [
1368 (0.13, 0.77),
1369 (0.41, 0.05),
1370 (0.66, 0.92),
1371 (0.99, 0.31),
1372 (1.20, -0.10),
1373 ];
1374 for dim in 0..2 {
1375 for &(p1, p2) in &probes {
1376 let (m0, v0) = fit.predict(dim, p1, p2).expect("orig predict");
1377 let (m1, v1) = restored.predict(dim, p1, p2).expect("restored predict");
1378 assert!(
1379 (m0 - m1).abs() <= 1e-12 * (1.0 + m0.abs()),
1380 "mean drift dim={dim} at ({p1},{p2}): {m0} vs {m1}"
1381 );
1382 assert!(
1383 (v0 - v1).abs() <= 1e-12 * (1.0 + v0.abs()),
1384 "variance drift dim={dim} at ({p1},{p2}): {v0} vs {v1}"
1385 );
1386 }
1387 }
1388 assert!((fit.log_lambda - restored.log_lambda).abs() <= 0.0);
1389 assert!((fit.restricted_loglik - restored.restricted_loglik).abs() <= 0.0);
1390 }
1391
1392 #[test]
1394 fn grid_spline_2d_state_rejects_corruption() {
1395 let k = 6usize;
1396 let side = 12usize;
1401 let mut x1 = Vec::new();
1402 let mut x2 = Vec::new();
1403 for i in 0..side {
1404 for j in 0..side {
1405 x1.push(i as f64 / (side - 1) as f64);
1406 x2.push(j as f64 / (side - 1) as f64);
1407 }
1408 }
1409 let n = x1.len();
1410 let y: Vec<f64> = x1
1420 .iter()
1421 .zip(&x2)
1422 .map(|(&a, &b)| a + b + (3.0 * a).sin() * (2.5 * b).cos())
1423 .collect();
1424 let w = vec![1.0_f64; n];
1425 let fit = fit_grid_spline_2d(&x1, &x2, &y, &w, k, [1.0, 1.0]).expect("fit");
1426
1427 let good = fit.to_state();
1428 let mut bad = good.clone();
1429 bad.chol.pop();
1430 assert!(
1431 GridSpline2dFit::from_state(&bad).is_err(),
1432 "chol length mismatch must error"
1433 );
1434
1435 let mut bad = good.clone();
1436 bad.sigma2[0] = -1.0;
1437 assert!(
1438 GridSpline2dFit::from_state(&bad).is_err(),
1439 "non-positive σ² must error"
1440 );
1441
1442 let mut bad = good.clone();
1443 bad.m_axis += 1;
1444 assert!(
1445 GridSpline2dFit::from_state(&bad).is_err(),
1446 "m_axis ≠ K+3 must error"
1447 );
1448
1449 let mut bad = good.clone();
1450 bad.axis_h[0] = 0.0;
1451 assert!(
1452 GridSpline2dFit::from_state(&bad).is_err(),
1453 "non-positive cell width must error"
1454 );
1455
1456 let mut bad = good;
1457 bad.chol[0] = 0.0;
1458 assert!(
1459 GridSpline2dFit::from_state(&bad).is_err(),
1460 "zero Cholesky pivot must error"
1461 );
1462 }
1463}