1use faer::{Mat, Side};
60use gam_math::score_opt::AffineRemlProfile;
61
62const PENALTY_NULLITY: usize = 3;
65
66const PIVOT_FLOOR: f64 = 1e-300;
68const MAX_CELLS_PER_AXIS: usize = 32;
70
71const GL4_NODES: [f64; 4] = [
75 -0.861_136_311_594_052_6,
76 -0.339_981_043_584_856_26,
77 0.339_981_043_584_856_26,
78 0.861_136_311_594_052_6,
79];
80const GL4_WEIGHTS: [f64; 4] = [
81 0.347_854_845_137_453_85,
82 0.652_145_154_862_546_2,
83 0.652_145_154_862_546_2,
84 0.347_854_845_137_453_85,
85];
86
87#[inline]
92fn bspline_value(u: f64) -> [f64; 4] {
93 let v = 1.0 - u;
94 [
95 v * v * v / 6.0,
96 (3.0 * u * u * u - 6.0 * u * u + 4.0) / 6.0,
97 (-3.0 * u * u * u + 3.0 * u * u + 3.0 * u + 1.0) / 6.0,
98 u * u * u / 6.0,
99 ]
100}
101
102#[inline]
104fn bspline_d1(u: f64) -> [f64; 4] {
105 let v = 1.0 - u;
106 [
107 -0.5 * v * v,
108 0.5 * (3.0 * u * u - 4.0 * u),
109 0.5 * (-3.0 * u * u + 2.0 * u + 1.0),
110 0.5 * u * u,
111 ]
112}
113
114#[inline]
117fn bspline_d2(u: f64) -> [f64; 4] {
118 [1.0 - u, 3.0 * u - 2.0, 1.0 - 3.0 * u, u]
119}
120
121#[derive(Clone, Copy, Debug)]
123struct Axis {
124 lo: f64,
125 h: f64,
126 cells: usize,
127}
128
129impl Axis {
130 #[inline]
134 fn locate(&self, x: f64) -> (usize, f64) {
135 let t = (x - self.lo) / self.h;
136 let cell = (t.floor().max(0.0) as usize).min(self.cells - 1);
137 (cell, t - cell as f64)
138 }
139}
140
141pub fn axis_basis_at(lo: f64, h: f64, cells: usize, x: f64) -> (usize, [f64; 4]) {
147 let (cell, u) = Axis { lo, h, cells }.locate(x);
148 (cell, bspline_value(u))
149}
150
151#[inline]
154fn basis_row(axes: &[Axis; 2], m_axis: usize, x1: f64, x2: f64) -> ([usize; 16], [f64; 16]) {
155 let (c1, u1) = axes[0].locate(x1);
156 let (c2, u2) = axes[1].locate(x2);
157 let b1 = bspline_value(u1);
158 let b2 = bspline_value(u2);
159 let mut idx = [0usize; 16];
160 let mut val = [0f64; 16];
161 for i in 0..4 {
162 for j in 0..4 {
163 idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
164 val[4 * i + j] = b1[i] * b2[j];
165 }
166 }
167 (idx, val)
168}
169
170pub fn cholesky_logdet(a: &mut [f64], p: usize) -> Result<f64, String> {
174 let mut logdet = 0.0;
175 for j in 0..p {
176 let mut s = a[j * p + j];
177 for t in 0..j {
178 s -= a[j * p + t] * a[j * p + t];
179 }
180 if !(s.is_finite() && s > PIVOT_FLOOR) {
181 return Err(format!(
182 "grid spline 2d: penalized system not positive definite at pivot {j} (value {s})"
183 ));
184 }
185 let l = s.sqrt();
186 a[j * p + j] = l;
187 logdet += 2.0 * l.ln();
188 for i in j + 1..p {
189 let mut s2 = a[i * p + j];
190 for t in 0..j {
191 s2 -= a[i * p + t] * a[j * p + t];
192 }
193 a[i * p + j] = s2 / l;
194 }
195 }
196 for i in 0..p {
197 for j in i + 1..p {
198 a[i * p + j] = 0.0;
199 }
200 }
201 Ok(logdet)
202}
203
204fn lower_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
206 let mut z = b.to_vec();
207 for i in 0..p {
208 let mut s = z[i];
209 for t in 0..i {
210 s -= l[i * p + t] * z[t];
211 }
212 z[i] = s / l[i * p + i];
213 }
214 z
215}
216
217pub fn chol_solve(l: &[f64], p: usize, b: &[f64]) -> Vec<f64> {
219 let mut z = lower_solve(l, p, b);
220 for i in (0..p).rev() {
221 let mut s = z[i];
222 for t in i + 1..p {
223 s -= l[t * p + i] * z[t];
224 }
225 z[i] = s / l[i * p + i];
226 }
227 z
228}
229
230pub struct GridSpline2dDesign {
233 axes: [Axis; 2],
234 m_axis: usize,
236 p: usize,
238 band_half: usize,
240 gram_band: Vec<f64>,
242 pen_band: Vec<f64>,
244 rhs: Vec<Vec<f64>>,
248 cross_moments: Vec<f64>,
251 n_obs: usize,
252}
253
254struct Solved {
256 chol: Vec<f64>,
257 logdet: f64,
258 coeffs: Vec<Vec<f64>>,
259 rss_pen: Vec<f64>,
262}
263
264struct RemlSpectrum {
268 gram_modes: Vec<f64>,
269 penalty_modes: Vec<f64>,
270 projected_rhs_squared: Vec<f64>,
271 response_energy: Vec<f64>,
272 residual_dof: f64,
273 logdet_constant: f64,
274}
275
276impl RemlSpectrum {
277 fn profile(&self) -> Result<AffineRemlProfile<'_>, String> {
278 AffineRemlProfile::new(
279 &self.gram_modes,
280 &self.penalty_modes,
281 &self.projected_rhs_squared,
282 &self.response_energy,
283 self.residual_dof,
284 self.penalty_modes.len() - PENALTY_NULLITY,
285 self.logdet_constant,
286 )
287 .map_err(|error| format!("grid spline 2d: invalid REML spectrum: {error}"))
288 }
289
290 fn log_lambda_domain(&self) -> Result<(f64, f64), String> {
296 let mut lowest_transition = f64::INFINITY;
297 let mut highest_transition = f64::NEG_INFINITY;
298 for (&gram, &penalty) in self.gram_modes.iter().zip(&self.penalty_modes) {
299 if gram > 0.0 && penalty > 0.0 {
300 let transition = gram.ln() - penalty.ln();
301 lowest_transition = lowest_transition.min(transition);
302 highest_transition = highest_transition.max(transition);
303 }
304 }
305 if !(lowest_transition.is_finite() && highest_transition.is_finite()) {
306 lowest_transition = 0.0;
307 highest_transition = 0.0;
308 }
309 let margin = -f64::EPSILON.sqrt().ln();
310 let lo = (lowest_transition - margin).max(f64::MIN_POSITIVE.ln());
311 let hi = (highest_transition + margin).min(f64::MAX.ln());
312 if !(lo < hi) {
313 return Err(format!(
314 "grid spline 2d: no representable REML search domain after spectral scaling ({lo}, {hi})"
315 ));
316 }
317 Ok((lo, hi))
318 }
319}
320
321impl GridSpline2dDesign {
322 pub fn build(
324 x1: &[f64],
325 x2: &[f64],
326 y: &[f64],
327 w: &[f64],
328 k: usize,
329 metric: [f64; 2],
330 ) -> Result<Self, String> {
331 Self::build_multi(x1, x2, &[y], w, k, metric)
332 }
333
334 pub fn build_multi(
341 x1: &[f64],
342 x2: &[f64],
343 responses: &[&[f64]],
344 w: &[f64],
345 k: usize,
346 metric: [f64; 2],
347 ) -> Result<Self, String> {
348 let n = x1.len();
349 if responses.is_empty() {
350 return Err("grid spline 2d: no response dimensions supplied".to_string());
351 }
352 if x2.len() != n || w.len() != n {
353 return Err(format!(
354 "grid spline 2d: length mismatch x1={n}, x2={}, w={}",
355 x2.len(),
356 w.len()
357 ));
358 }
359 for (d, y) in responses.iter().enumerate() {
360 if y.len() != n {
361 return Err(format!(
362 "grid spline 2d: response dimension {d} has length {} != {n}",
363 y.len()
364 ));
365 }
366 }
367 if n <= PENALTY_NULLITY {
368 return Err(format!(
369 "grid spline 2d: needs more than {PENALTY_NULLITY} rows for the profiled REML \
370 degrees of freedom, got {n}"
371 ));
372 }
373 if k == 0 || k > MAX_CELLS_PER_AXIS {
374 return Err(format!(
375 "grid spline 2d: k must be in 1..={MAX_CELLS_PER_AXIS} (dense Cholesky on \
376 (k+3)² coefficients — see module sizing contract), got {k}"
377 ));
378 }
379 if !(metric[0].is_finite() && metric[0] > 0.0 && metric[1].is_finite() && metric[1] > 0.0) {
380 return Err(format!(
381 "grid spline 2d: metric diagonal must be finite and positive, got [{}, {}]",
382 metric[0], metric[1]
383 ));
384 }
385 for i in 0..n {
386 if !(x1[i].is_finite() && x2[i].is_finite()) || !(w[i] > 0.0) || !w[i].is_finite() {
387 return Err(format!(
388 "grid spline 2d: non-finite or non-positive input at row {i} \
389 (x1={}, x2={}, w={})",
390 x1[i], x2[i], w[i]
391 ));
392 }
393 for (d, y) in responses.iter().enumerate() {
394 if !y[i].is_finite() {
395 return Err(format!(
396 "grid spline 2d: non-finite response at row {i}, dimension {d} ({})",
397 y[i]
398 ));
399 }
400 }
401 }
402 let mut axes = [Axis {
403 lo: 0.0,
404 h: 1.0,
405 cells: k,
406 }; 2];
407 for (axis, xs) in axes.iter_mut().zip([x1, x2]) {
408 let mut lo = f64::INFINITY;
409 let mut hi = f64::NEG_INFINITY;
410 for &v in xs {
411 lo = lo.min(v);
412 hi = hi.max(v);
413 }
414 if !(hi > lo) {
415 return Err(format!(
416 "grid spline 2d: degenerate axis bounding box [{lo}, {hi}]"
417 ));
418 }
419 axis.lo = lo;
420 axis.h = (hi - lo) / k as f64;
421 }
422 let m_axis = k + 3;
423 let p = m_axis * m_axis;
424 let band_half = 3 * m_axis + 3;
425 let stride = band_half + 1;
426 let n_dims = responses.len();
427 let mut gram_band = vec![0.0_f64; p * stride];
428 let mut rhs = vec![vec![0.0_f64; p]; n_dims];
429 let mut cross_moments = vec![0.0_f64; n_dims * n_dims];
430
431 for i in 0..n {
436 let (idx, val) = basis_row(&axes, m_axis, x1[i], x2[i]);
437 let wi = w[i];
438 for (d, y) in responses.iter().enumerate() {
439 let wy = wi * y[i];
440 for e in 0..16 {
441 rhs[d][idx[e]] += wy * val[e];
442 }
443 for (e, ye) in responses.iter().enumerate().skip(d) {
444 cross_moments[d * n_dims + e] += wy * ye[i];
445 }
446 }
447 for a in 0..16 {
448 let base = idx[a] * stride - idx[a];
449 let wa = wi * val[a];
450 for b in a..16 {
451 gram_band[base + idx[b]] += wa * val[b];
452 }
453 }
454 }
455 for d in 0..n_dims {
456 for e in 0..d {
457 cross_moments[d * n_dims + e] = cross_moments[e * n_dims + d];
458 }
459 }
460
461 let mut tab = [[[[0.0_f64; 4]; 4]; 3]; 2]; for ax in 0..2 {
466 let h = axes[ax].h;
467 for q in 0..4 {
468 let u = 0.5 * (1.0 + GL4_NODES[q]);
469 let v0 = bspline_value(u);
470 let v1 = bspline_d1(u);
471 let v2 = bspline_d2(u);
472 for e in 0..4 {
473 tab[ax][0][q][e] = v0[e];
474 tab[ax][1][q][e] = v1[e] / h;
475 tab[ax][2][q][e] = v2[e] / (h * h);
476 }
477 }
478 }
479 let s11 = metric[0] * metric[0];
481 let s12 = 2.0 * metric[0] * metric[1];
482 let s22 = metric[1] * metric[1];
483 let cell_area_jac = 0.25 * axes[0].h * axes[1].h; let mut pen_band = vec![0.0_f64; p * stride];
485 let mut r11 = [0.0_f64; 16];
486 let mut r12 = [0.0_f64; 16];
487 let mut r22 = [0.0_f64; 16];
488 let mut idx = [0usize; 16];
489 for c1 in 0..k {
490 for c2 in 0..k {
491 for i in 0..4 {
492 for j in 0..4 {
493 idx[4 * i + j] = (c1 + i) * m_axis + (c2 + j);
494 }
495 }
496 for q1 in 0..4 {
497 for q2 in 0..4 {
498 let wq = cell_area_jac * GL4_WEIGHTS[q1] * GL4_WEIGHTS[q2];
499 for i in 0..4 {
500 for j in 0..4 {
501 let e = 4 * i + j;
502 r11[e] = tab[0][2][q1][i] * tab[1][0][q2][j];
503 r12[e] = tab[0][1][q1][i] * tab[1][1][q2][j];
504 r22[e] = tab[0][0][q1][i] * tab[1][2][q2][j];
505 }
506 }
507 for a in 0..16 {
508 let base = idx[a] * stride - idx[a];
509 let (pa11, pa12, pa22) =
510 (wq * s11 * r11[a], wq * s12 * r12[a], wq * s22 * r22[a]);
511 for b in a..16 {
512 pen_band[base + idx[b]] +=
513 pa11 * r11[b] + pa12 * r12[b] + pa22 * r22[b];
514 }
515 }
516 }
517 }
518 }
519 }
520
521 Ok(GridSpline2dDesign {
522 axes,
523 m_axis,
524 p,
525 band_half,
526 gram_band,
527 pen_band,
528 rhs,
529 cross_moments,
530 n_obs: n,
531 })
532 }
533
534 pub fn num_cells(&self) -> usize {
536 self.axes[0].cells
537 }
538
539 pub fn basis_per_axis(&self) -> usize {
541 self.m_axis
542 }
543
544 pub fn num_coeffs(&self) -> usize {
546 self.p
547 }
548
549 pub fn lower_corner(&self) -> [f64; 2] {
551 [self.axes[0].lo, self.axes[1].lo]
552 }
553
554 pub fn cell_widths(&self) -> [f64; 2] {
556 [self.axes[0].h, self.axes[1].h]
557 }
558
559 pub fn num_rows(&self) -> usize {
561 self.n_obs
562 }
563
564 pub fn num_responses(&self) -> usize {
566 self.rhs.len()
567 }
568
569 pub fn axis_basis(&self, axis: usize, x: f64) -> Result<(usize, [f64; 4]), String> {
575 if axis > 1 {
576 return Err(format!("grid spline 2d: axis {axis} out of range"));
577 }
578 if !x.is_finite() {
579 return Err(format!("grid spline 2d: non-finite axis-{axis} point {x}"));
580 }
581 let ax = self.axes[axis];
582 Ok(axis_basis_at(ax.lo, ax.h, ax.cells, x))
583 }
584
585 pub fn penalty_value(&self, coeff: &[f64]) -> Result<f64, String> {
588 if coeff.len() != self.p {
589 return Err(format!(
590 "grid spline 2d: coefficient length {} != {}",
591 coeff.len(),
592 self.p
593 ));
594 }
595 let stride = self.band_half + 1;
596 let mut j = 0.0;
597 for g in 0..self.p {
598 let dmax = self.band_half.min(self.p - 1 - g);
599 j += self.pen_band[g * stride] * coeff[g] * coeff[g];
600 for d in 1..=dmax {
601 j += 2.0 * self.pen_band[g * stride + d] * coeff[g] * coeff[g + d];
602 }
603 }
604 Ok(j)
605 }
606
607 fn dense_system(&self, lambda: f64) -> Vec<f64> {
609 let p = self.p;
610 let stride = self.band_half + 1;
611 let mut a = vec![0.0_f64; p * p];
612 for g in 0..p {
613 let dmax = self.band_half.min(p - 1 - g);
614 for d in 0..=dmax {
615 let v = self.gram_band[g * stride + d] + lambda * self.pen_band[g * stride + d];
616 a[g * p + g + d] = v;
617 a[(g + d) * p + g] = v;
618 }
619 }
620 a
621 }
622
623 fn dense_penalty(&self) -> Vec<f64> {
625 let p = self.p;
626 let stride = self.band_half + 1;
627 let mut penalty = vec![0.0_f64; p * p];
628 for g in 0..p {
629 let dmax = self.band_half.min(p - 1 - g);
630 for d in 0..=dmax {
631 let value = self.pen_band[g * stride + d];
632 penalty[g * p + g + d] = value;
633 penalty[(g + d) * p + g] = value;
634 }
635 }
636 penalty
637 }
638
639 fn reml_spectrum(&self) -> Result<RemlSpectrum, String> {
645 let p = self.p;
646 let mut reference_chol = self.dense_system(1.0);
647 let logdet_constant = cholesky_logdet(&mut reference_chol, p)?;
648
649 let lower = Mat::from_fn(p, p, |row, col| {
650 if row >= col {
651 reference_chol[row * p + col]
652 } else {
653 0.0
654 }
655 });
656 let dense_penalty = self.dense_penalty();
657 let mut whitened = Mat::from_fn(p, p, |row, col| dense_penalty[row * p + col]);
658 lower
662 .as_ref()
663 .solve_lower_triangular_in_place(whitened.as_mut());
664 lower
665 .as_ref()
666 .solve_lower_triangular_in_place(whitened.as_mut().transpose_mut());
667 if (0..p).any(|row| (0..p).any(|col| !whitened[(row, col)].is_finite())) {
668 return Err("grid spline 2d: non-finite whitened penalty".to_string());
669 }
670 let mut symmetry_correction_rows = vec![0.0_f64; p];
673 for row in 0..p {
674 for col in row + 1..p {
675 let correction = 0.5 * (whitened[(row, col)] - whitened[(col, row)]).abs();
676 symmetry_correction_rows[row] += correction;
677 symmetry_correction_rows[col] += correction;
678 let value = 0.5 * (whitened[(row, col)] + whitened[(col, row)]);
679 whitened[(row, col)] = value;
680 whitened[(col, row)] = value;
681 }
682 }
683 let matrix_inf_norm = (0..p).fold(0.0_f64, |norm, row| {
684 let row_sum = (0..p).map(|col| whitened[(row, col)].abs()).sum();
685 norm.max(row_sum)
686 });
687 let p_epsilon = p as f64 * f64::EPSILON;
691 let symmetrization_error = symmetry_correction_rows.into_iter().fold(0.0_f64, f64::max);
692 let eigenvalue_roundoff =
693 symmetrization_error + (p_epsilon / (1.0 - p_epsilon)) * matrix_inf_norm.max(1.0);
694 let eigensystem = whitened
695 .as_ref()
696 .self_adjoint_eigen(Side::Lower)
697 .map_err(|error| {
698 format!("grid spline 2d: reference-pencil eigendecomposition failed: {error:?}")
699 })?;
700 let eigenvalues = eigensystem.S();
701 let eigenvectors = eigensystem.U();
702
703 let mut order: Vec<usize> = (0..p).collect();
705 order.sort_unstable_by(|&left, &right| eigenvalues[left].total_cmp(&eigenvalues[right]));
706
707 let mut gram_modes = Vec::with_capacity(p);
708 let mut penalty_modes = Vec::with_capacity(p);
709 for (position, &mode) in order.iter().enumerate() {
710 let raw = eigenvalues[mode];
711 if !raw.is_finite() {
712 return Err(format!(
713 "grid spline 2d: non-finite reference-pencil eigenvalue at mode {position}"
714 ));
715 }
716
717 if raw < -eigenvalue_roundoff || raw > 1.0 + eigenvalue_roundoff {
721 return Err(format!(
722 "grid spline 2d: reference-pencil eigenvalue {raw} at mode {position} lies outside the certified [0, 1] roundoff band ±{eigenvalue_roundoff}"
723 ));
724 }
725 let penalty = if position < PENALTY_NULLITY {
726 if raw.abs() > eigenvalue_roundoff {
727 return Err(format!(
728 "grid spline 2d: expected null mode {position} has eigenvalue {raw}, outside zero roundoff band ±{eigenvalue_roundoff}"
729 ));
730 }
731 0.0
732 } else if raw <= eigenvalue_roundoff {
733 return Err(format!(
734 "grid spline 2d: penalty rank is below {}: non-null mode {position} has eigenvalue {raw} inside zero roundoff band ±{eigenvalue_roundoff}",
735 p - PENALTY_NULLITY,
736 ));
737 } else {
738 raw.min(1.0)
741 };
742 penalty_modes.push(penalty);
743 gram_modes.push(1.0 - penalty);
744 }
745
746 let n_dims = self.rhs.len();
747 let mut projected_rhs_squared = Vec::with_capacity(n_dims * p);
748 for rhs in &self.rhs {
749 let whitened_rhs = lower_solve(&reference_chol, p, rhs);
750 for &mode in &order {
751 let mut coordinate = 0.0;
752 for row in 0..p {
753 coordinate += eigenvectors[(row, mode)] * whitened_rhs[row];
754 }
755 projected_rhs_squared.push(coordinate * coordinate);
756 }
757 }
758
759 let response_energy = (0..n_dims)
760 .map(|dimension| self.cross_moments[dimension * n_dims + dimension])
761 .collect();
762 Ok(RemlSpectrum {
763 gram_modes,
764 penalty_modes,
765 projected_rhs_squared,
766 response_energy,
767 residual_dof: (self.n_obs - PENALTY_NULLITY) as f64,
768 logdet_constant,
769 })
770 }
771
772 fn solve_at(&self, log_lambda: f64) -> Result<Solved, String> {
773 let lambda = gam_problem::checked_exp_log_strength(log_lambda)
774 .map_err(|error| format!("grid spline 2d: {error}"))?;
775 let mut a = self.dense_system(lambda);
776 let logdet = cholesky_logdet(&mut a, self.p)?;
777 let n_dims = self.rhs.len();
778 let mut coeffs = Vec::with_capacity(n_dims);
779 let mut rss_pen = Vec::with_capacity(n_dims);
780 for (d, rhs) in self.rhs.iter().enumerate() {
781 let coeff = chol_solve(&a, self.p, rhs);
782 let mut quad = 0.0;
783 for g in 0..self.p {
784 quad += rhs[g] * coeff[g];
785 }
786 rss_pen.push(self.cross_moments[d * n_dims + d] - quad);
787 coeffs.push(coeff);
788 }
789 Ok(Solved {
790 chol: a,
791 logdet,
792 coeffs,
793 rss_pen,
794 })
795 }
796
797 pub fn fit_at(&self, log_lambda: f64, sigma2: Option<f64>) -> Result<GridSpline2dFit, String> {
800 let solved = self.solve_at(log_lambda)?;
801 let dof = (self.n_obs - PENALTY_NULLITY) as f64;
802 let mut sigma2_dims = Vec::with_capacity(solved.rss_pen.len());
803 for &rss in &solved.rss_pen {
804 match sigma2 {
805 Some(s) => {
806 if !(s.is_finite() && s > 0.0) {
807 return Err(format!("grid spline 2d: invalid sigma2 {s}"));
808 }
809 sigma2_dims.push(s);
810 }
811 None => {
812 if !(rss > 0.0) {
813 return Err(format!(
814 "grid spline 2d: degenerate penalized residual {rss}"
815 ));
816 }
817 sigma2_dims.push(rss / dof);
818 }
819 }
820 }
821 let r = (self.p - PENALTY_NULLITY) as f64;
826 let mut restricted_loglik = 0.0;
827 for (d, &rss) in solved.rss_pen.iter().enumerate() {
828 restricted_loglik -= 0.5
829 * (solved.logdet - r * log_lambda
830 + dof * sigma2_dims[d].ln()
831 + rss / sigma2_dims[d]);
832 }
833 Ok(GridSpline2dFit {
834 coeffs: solved.coeffs,
835 log_lambda,
836 sigma2: sigma2_dims,
837 restricted_loglik,
838 chol: solved.chol,
839 axes: self.axes,
840 m_axis: self.m_axis,
841 })
842 }
843
844 pub fn fit_reml(&self) -> Result<GridSpline2dFit, String> {
849 let spectrum = self.reml_spectrum()?;
850 let profile = spectrum.profile()?;
851 let (log_lambda_lo, log_lambda_hi) = spectrum.log_lambda_domain()?;
852 let search = profile
853 .maximize(log_lambda_lo, log_lambda_hi, f64::EPSILON.sqrt())
854 .map_err(|error| format!("grid spline 2d: REML optimization failed: {error}"))?;
855 self.fit_at(search.optimum.x, None)
856 }
857
858 fn gram_quadratic(&self, a: &[f64], b: &[f64]) -> f64 {
860 let stride = self.band_half + 1;
861 let mut q = 0.0;
862 for g in 0..self.p {
863 let dmax = self.band_half.min(self.p - 1 - g);
864 q += self.gram_band[g * stride] * a[g] * b[g];
865 for d in 1..=dmax {
866 q += self.gram_band[g * stride + d] * (a[g] * b[g + d] + a[g + d] * b[g]);
867 }
868 }
869 q
870 }
871
872 pub fn posterior(&self, fit: &GridSpline2dFit) -> Result<GridSpline2dPosterior, String> {
882 let p = self.p;
883 let n_dims = self.rhs.len();
884 if fit.coeffs.len() != n_dims || fit.coeffs.iter().any(|c| c.len() != p) {
885 return Err(format!(
886 "grid spline 2d: posterior asked for a fit with {} dimensions of length {}, \
887 design has {n_dims} of {p}",
888 fit.coeffs.len(),
889 fit.coeffs.first().map_or(0, Vec::len)
890 ));
891 }
892 let mut unit_covariance = vec![0.0_f64; p * p];
894 let mut e_g = vec![0.0_f64; p];
895 for g in 0..p {
896 e_g[g] = 1.0;
897 let col = chol_solve(&fit.chol, p, &e_g);
898 e_g[g] = 0.0;
899 for (r, &v) in col.iter().enumerate() {
900 unit_covariance[r * p + g] = v;
901 }
902 }
903 let stride = self.band_half + 1;
905 let mut edf = 0.0;
906 for g in 0..p {
907 let dmax = self.band_half.min(p - 1 - g);
908 edf += self.gram_band[g * stride] * unit_covariance[g * p + g];
909 for d in 1..=dmax {
910 edf += 2.0 * self.gram_band[g * stride + d] * unit_covariance[g * p + g + d];
911 }
912 }
913 let residual_df = self.n_obs as f64 - edf;
914 if !(residual_df >= 1.0) {
915 return Err(format!(
916 "grid spline 2d: too few rows for a scale estimate \
917 (n = {}, edf = {edf:.2}; need n − edf ≥ 1)",
918 self.n_obs
919 ));
920 }
921 let mut residual_cross_cov = vec![0.0_f64; n_dims * n_dims];
922 for d in 0..n_dims {
923 for e in d..n_dims {
924 let mut cd_rhse = 0.0;
925 let mut ce_rhsd = 0.0;
926 for g in 0..p {
927 cd_rhse += fit.coeffs[d][g] * self.rhs[e][g];
928 ce_rhsd += fit.coeffs[e][g] * self.rhs[d][g];
929 }
930 let quad = self.gram_quadratic(&fit.coeffs[d], &fit.coeffs[e]);
931 let v =
932 (self.cross_moments[d * n_dims + e] - cd_rhse - ce_rhsd + quad) / residual_df;
933 residual_cross_cov[d * n_dims + e] = v;
934 residual_cross_cov[e * n_dims + d] = v;
935 }
936 }
937 Ok(GridSpline2dPosterior {
938 unit_covariance,
939 edf,
940 residual_df,
941 residual_cross_cov,
942 })
943 }
944}
945
946pub struct GridSpline2dPosterior {
950 pub unit_covariance: Vec<f64>,
953 pub edf: f64,
955 pub residual_df: f64,
957 pub residual_cross_cov: Vec<f64>,
959}
960
961pub struct GridSpline2dFit {
963 pub coeffs: Vec<Vec<f64>>,
966 pub log_lambda: f64,
969 pub sigma2: Vec<f64>,
971 pub restricted_loglik: f64,
974 chol: Vec<f64>,
977 axes: [Axis; 2],
978 m_axis: usize,
979}
980
981#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
993pub struct GridSpline2dState {
994 pub coeffs: Vec<Vec<f64>>,
996 pub log_lambda: f64,
997 pub sigma2: Vec<f64>,
999 pub restricted_loglik: f64,
1000 pub chol: Vec<f64>,
1003 pub axis_lo: [f64; 2],
1005 pub axis_h: [f64; 2],
1007 pub axis_cells: [u64; 2],
1009 pub m_axis: u64,
1011}
1012
1013impl GridSpline2dFit {
1014 pub fn to_state(&self) -> GridSpline2dState {
1018 GridSpline2dState {
1019 coeffs: self.coeffs.clone(),
1020 log_lambda: self.log_lambda,
1021 sigma2: self.sigma2.clone(),
1022 restricted_loglik: self.restricted_loglik,
1023 chol: self.chol.clone(),
1024 axis_lo: [self.axes[0].lo, self.axes[1].lo],
1025 axis_h: [self.axes[0].h, self.axes[1].h],
1026 axis_cells: [self.axes[0].cells as u64, self.axes[1].cells as u64],
1027 m_axis: self.m_axis as u64,
1028 }
1029 }
1030
1031 pub fn from_state(state: &GridSpline2dState) -> Result<Self, String> {
1038 let m_axis = state.m_axis as usize;
1039 let p = m_axis * m_axis;
1040 for a in 0..2 {
1041 let cells = state.axis_cells[a] as usize;
1042 if cells == 0 {
1043 return Err(format!(
1044 "grid spline 2d state: axis {a} must have at least one cell"
1045 ));
1046 }
1047 if m_axis != cells + 3 {
1048 return Err(format!(
1049 "grid spline 2d state: m_axis {m_axis} must equal K+3 = {} for axis {a}",
1050 cells + 3
1051 ));
1052 }
1053 if !(state.axis_lo[a].is_finite()
1054 && state.axis_h[a].is_finite()
1055 && state.axis_h[a] > 0.0)
1056 {
1057 return Err(format!(
1058 "grid spline 2d state: axis {a} must have finite lo and positive h, got lo={}, h={}",
1059 state.axis_lo[a], state.axis_h[a]
1060 ));
1061 }
1062 }
1063 if state.chol.len() != p * p {
1064 return Err(format!(
1065 "grid spline 2d state: chol must be p×p = {p}² = {}, got {}",
1066 p * p,
1067 state.chol.len()
1068 ));
1069 }
1070 let d = state.coeffs.len();
1071 if d == 0 || state.sigma2.len() != d {
1072 return Err(format!(
1073 "grid spline 2d state: need ≥1 response dimension with matching σ² (coeffs D={d}, sigma2 D={})",
1074 state.sigma2.len()
1075 ));
1076 }
1077 for (dim, c) in state.coeffs.iter().enumerate() {
1078 if c.len() != p {
1079 return Err(format!(
1080 "grid spline 2d state: response dimension {dim} has {} coeffs, expected p = {p}",
1081 c.len()
1082 ));
1083 }
1084 }
1085 for (dim, &s2) in state.sigma2.iter().enumerate() {
1086 if !(s2.is_finite() && s2 > 0.0) {
1087 return Err(format!(
1088 "grid spline 2d state: response dimension {dim} has non-positive σ² = {s2}"
1089 ));
1090 }
1091 }
1092 for (i, v) in state
1093 .chol
1094 .iter()
1095 .chain(state.coeffs.iter().flatten())
1096 .enumerate()
1097 {
1098 if !v.is_finite() {
1099 return Err(format!("grid spline 2d state: non-finite entry at {i}"));
1100 }
1101 }
1102 for g in 0..p {
1106 let piv = state.chol[g * p + g];
1107 if !(piv.is_finite() && piv > 0.0) {
1108 return Err(format!(
1109 "grid spline 2d state: non-positive Cholesky pivot {piv} at index {g}"
1110 ));
1111 }
1112 }
1113 if !(state.log_lambda.is_finite() && state.restricted_loglik.is_finite()) {
1114 return Err(format!(
1115 "grid spline 2d state: invalid scalars (log_lambda={}, restricted_loglik={})",
1116 state.log_lambda, state.restricted_loglik
1117 ));
1118 }
1119 let axes = [
1120 Axis {
1121 lo: state.axis_lo[0],
1122 h: state.axis_h[0],
1123 cells: state.axis_cells[0] as usize,
1124 },
1125 Axis {
1126 lo: state.axis_lo[1],
1127 h: state.axis_h[1],
1128 cells: state.axis_cells[1] as usize,
1129 },
1130 ];
1131 Ok(GridSpline2dFit {
1132 coeffs: state.coeffs.clone(),
1133 log_lambda: state.log_lambda,
1134 sigma2: state.sigma2.clone(),
1135 restricted_loglik: state.restricted_loglik,
1136 chol: state.chol.clone(),
1137 axes,
1138 m_axis,
1139 })
1140 }
1141
1142 pub fn predict(&self, dim: usize, x1: f64, x2: f64) -> Result<(f64, f64), String> {
1147 if dim >= self.coeffs.len() {
1148 return Err(format!(
1149 "grid spline 2d: response dimension {dim} out of range (D = {})",
1150 self.coeffs.len()
1151 ));
1152 }
1153 if !(x1.is_finite() && x2.is_finite()) {
1154 return Err(format!(
1155 "grid spline 2d: non-finite prediction point ({x1}, {x2})"
1156 ));
1157 }
1158 let (idx, val) = basis_row(&self.axes, self.m_axis, x1, x2);
1159 let p = self.coeffs[dim].len();
1160 let mut mean = 0.0;
1161 let mut row = vec![0.0_f64; p];
1162 for e in 0..16 {
1163 mean += val[e] * self.coeffs[dim][idx[e]];
1164 row[idx[e]] += val[e];
1165 }
1166 let z = chol_solve(&self.chol, p, &row);
1167 let mut quad = 0.0;
1168 for g in 0..p {
1169 quad += row[g] * z[g];
1170 }
1171 Ok((mean, self.sigma2[dim] * quad))
1172 }
1173}
1174
1175pub fn fit_grid_spline_2d(
1177 x1: &[f64],
1178 x2: &[f64],
1179 y: &[f64],
1180 w: &[f64],
1181 k: usize,
1182 metric: [f64; 2],
1183) -> Result<GridSpline2dFit, String> {
1184 GridSpline2dDesign::build(x1, x2, y, w, k, metric)?.fit_reml()
1185}
1186
1187pub fn fit_grid_spline_2d_at(
1189 x1: &[f64],
1190 x2: &[f64],
1191 y: &[f64],
1192 w: &[f64],
1193 k: usize,
1194 metric: [f64; 2],
1195 log_lambda: f64,
1196 sigma2: Option<f64>,
1197) -> Result<GridSpline2dFit, String> {
1198 GridSpline2dDesign::build(x1, x2, y, w, k, metric)?.fit_at(log_lambda, sigma2)
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 #[test]
1206 fn affine_reml_profile_matches_direct_factorizations() {
1207 let side = 10usize;
1208 let mut x1 = Vec::with_capacity(side * side);
1209 let mut x2 = Vec::with_capacity(side * side);
1210 let mut y0 = Vec::with_capacity(side * side);
1211 let mut y1 = Vec::with_capacity(side * side);
1212 for i in 0..side {
1213 for j in 0..side {
1214 let a = i as f64 / (side - 1) as f64;
1215 let b = j as f64 / (side - 1) as f64;
1216 x1.push(a);
1217 x2.push(b);
1218 y0.push((2.0 * a).sin() * (3.0 * b).cos() + a * b);
1219 y1.push(a * a - b * b + (a + 2.0 * b).sin());
1220 }
1221 }
1222 let weights = vec![1.0; x1.len()];
1223 let responses: [&[f64]; 2] = [&y0, &y1];
1224 let design = GridSpline2dDesign::build_multi(&x1, &x2, &responses, &weights, 3, [1.0, 1.5])
1225 .expect("design");
1226 let spectrum = design.reml_spectrum().expect("reference pencil");
1227 let profile = spectrum.profile().expect("affine profile");
1228 let dof = (design.n_obs - PENALTY_NULLITY) as f64;
1229 let rank = (design.p - PENALTY_NULLITY) as f64;
1230
1231 for log_lambda in [-5.0, 0.0, 6.0] {
1232 let solved = design.solve_at(log_lambda).expect("direct solve");
1233 let shared = solved.logdet - rank * log_lambda;
1234 let direct = -0.5
1235 * solved
1236 .rss_pen
1237 .iter()
1238 .map(|rss| shared + dof * (rss / dof).ln())
1239 .sum::<f64>();
1240 let spectral = profile.evaluate(log_lambda).expect("spectral score").value;
1241 assert!(
1242 (direct - spectral).abs() <= f64::EPSILON.sqrt() * (1.0 + direct.abs()),
1243 "score mismatch at log lambda {log_lambda}: direct={direct}, spectral={spectral}"
1244 );
1245 }
1246 }
1247
1248 #[test]
1253 fn grid_spline_2d_state_roundtrip_reproduces_predict() {
1254 let k = 8usize;
1255 let mut x1 = Vec::new();
1257 let mut x2 = Vec::new();
1258 let mut y0 = Vec::new();
1259 let mut y1 = Vec::new();
1260 for i in 0..24 {
1261 for j in 0..24 {
1262 let a = i as f64 / 23.0;
1263 let b = j as f64 / 23.0;
1264 x1.push(a);
1265 x2.push(b);
1266 y0.push((2.5 * a).sin() * (1.7 * b).cos() + 0.3 * a * b);
1267 y1.push(a * a - 0.5 * b + 0.2 * (3.0 * a * b).cos());
1268 }
1269 }
1270 let n = x1.len();
1271 let w = vec![1.0_f64; n];
1272 let ys: Vec<&[f64]> = vec![&y0, &y1];
1273 let fit = GridSpline2dDesign::build_multi(&x1, &x2, &ys, &w, k, [1.0, 1.0])
1274 .expect("design")
1275 .fit_reml()
1276 .expect("fit");
1277
1278 let json = serde_json::to_string(&fit.to_state()).expect("serialize");
1279 let state: GridSpline2dState = serde_json::from_str(&json).expect("deserialize");
1280 let restored = GridSpline2dFit::from_state(&state).expect("restore");
1281
1282 let probes = [
1285 (0.13, 0.77),
1286 (0.41, 0.05),
1287 (0.66, 0.92),
1288 (0.99, 0.31),
1289 (1.20, -0.10),
1290 ];
1291 for dim in 0..2 {
1292 for &(p1, p2) in &probes {
1293 let (m0, v0) = fit.predict(dim, p1, p2).expect("orig predict");
1294 let (m1, v1) = restored.predict(dim, p1, p2).expect("restored predict");
1295 assert!(
1296 (m0 - m1).abs() <= 1e-12 * (1.0 + m0.abs()),
1297 "mean drift dim={dim} at ({p1},{p2}): {m0} vs {m1}"
1298 );
1299 assert!(
1300 (v0 - v1).abs() <= 1e-12 * (1.0 + v0.abs()),
1301 "variance drift dim={dim} at ({p1},{p2}): {v0} vs {v1}"
1302 );
1303 }
1304 }
1305 assert!((fit.log_lambda - restored.log_lambda).abs() <= 0.0);
1306 assert!((fit.restricted_loglik - restored.restricted_loglik).abs() <= 0.0);
1307 }
1308
1309 #[test]
1311 fn grid_spline_2d_state_rejects_corruption() {
1312 let k = 6usize;
1313 let side = 12usize;
1318 let mut x1 = Vec::new();
1319 let mut x2 = Vec::new();
1320 for i in 0..side {
1321 for j in 0..side {
1322 x1.push(i as f64 / (side - 1) as f64);
1323 x2.push(j as f64 / (side - 1) as f64);
1324 }
1325 }
1326 let n = x1.len();
1327 let y: Vec<f64> = x1
1337 .iter()
1338 .zip(&x2)
1339 .map(|(&a, &b)| a + b + (3.0 * a).sin() * (2.5 * b).cos())
1340 .collect();
1341 let w = vec![1.0_f64; n];
1342 let fit = fit_grid_spline_2d(&x1, &x2, &y, &w, k, [1.0, 1.0]).expect("fit");
1343
1344 let good = fit.to_state();
1345 let mut bad = good.clone();
1346 bad.chol.pop();
1347 assert!(
1348 GridSpline2dFit::from_state(&bad).is_err(),
1349 "chol length mismatch must error"
1350 );
1351
1352 let mut bad = good.clone();
1353 bad.sigma2[0] = -1.0;
1354 assert!(
1355 GridSpline2dFit::from_state(&bad).is_err(),
1356 "non-positive σ² must error"
1357 );
1358
1359 let mut bad = good.clone();
1360 bad.m_axis += 1;
1361 assert!(
1362 GridSpline2dFit::from_state(&bad).is_err(),
1363 "m_axis ≠ K+3 must error"
1364 );
1365
1366 let mut bad = good.clone();
1367 bad.axis_h[0] = 0.0;
1368 assert!(
1369 GridSpline2dFit::from_state(&bad).is_err(),
1370 "non-positive cell width must error"
1371 );
1372
1373 let mut bad = good;
1374 bad.chol[0] = 0.0;
1375 assert!(
1376 GridSpline2dFit::from_state(&bad).is_err(),
1377 "zero Cholesky pivot must error"
1378 );
1379 }
1380}