1use crate::error::FdarError;
19use crate::function_on_scalar::{
20 build_fosr_design, compute_xtx, compute_xty_matrix, penalty_matrix, pointwise_r_squared,
21};
22use crate::matrix::FdMatrix;
23
24#[derive(Debug, Clone, PartialEq)]
30pub struct Grid2d {
31 pub argvals_s: Vec<f64>,
33 pub argvals_t: Vec<f64>,
35}
36
37impl Grid2d {
38 pub fn new(argvals_s: Vec<f64>, argvals_t: Vec<f64>) -> Self {
40 Self {
41 argvals_s,
42 argvals_t,
43 }
44 }
45
46 #[inline]
48 pub fn m1(&self) -> usize {
49 self.argvals_s.len()
50 }
51
52 #[inline]
54 pub fn m2(&self) -> usize {
55 self.argvals_t.len()
56 }
57
58 #[inline]
60 pub fn total(&self) -> usize {
61 self.m1() * self.m2()
62 }
63}
64
65#[derive(Debug, Clone, PartialEq)]
67#[non_exhaustive]
68pub struct FosrResult2d {
69 pub intercept: Vec<f64>,
71 pub beta: FdMatrix,
73 pub fitted: FdMatrix,
75 pub residuals: FdMatrix,
77 pub r_squared_pointwise: Vec<f64>,
79 pub r_squared: f64,
81 pub beta_se: Option<FdMatrix>,
84 pub lambda_s: f64,
86 pub lambda_t: f64,
88 pub gcv: f64,
90 pub grid: Grid2d,
92}
93
94impl FosrResult2d {
95 #[must_use]
100 pub fn beta_surface(&self, j: usize) -> FdMatrix {
101 let m1 = self.grid.m1();
102 let m2 = self.grid.m2();
103 let m_total = m1 * m2;
104 let mut mat = FdMatrix::zeros(m1, m2);
105 for g in 0..m_total {
106 let s_idx = g % m1;
108 let t_idx = g / m1;
109 mat[(s_idx, t_idx)] = self.beta[(j, g)];
110 }
111 mat
112 }
113
114 #[must_use]
116 pub fn r_squared_surface(&self) -> FdMatrix {
117 let m1 = self.grid.m1();
118 let m2 = self.grid.m2();
119 let m_total = m1 * m2;
120 let mut mat = FdMatrix::zeros(m1, m2);
121 for g in 0..m_total {
122 let s_idx = g % m1;
123 let t_idx = g / m1;
124 mat[(s_idx, t_idx)] = self.r_squared_pointwise[g];
125 }
126 mat
127 }
128
129 #[must_use]
134 pub fn residual_surface(&self, i: usize) -> FdMatrix {
135 let m1 = self.grid.m1();
136 let m2 = self.grid.m2();
137 let m_total = m1 * m2;
138 let mut mat = FdMatrix::zeros(m1, m2);
139 for g in 0..m_total {
140 let s_idx = g % m1;
141 let t_idx = g / m1;
142 mat[(s_idx, t_idx)] = self.residuals[(i, g)];
143 }
144 mat
145 }
146
147 pub fn predict(&self, new_predictors: &FdMatrix) -> Result<FdMatrix, FdarError> {
150 predict_fosr_2d(self, new_predictors)
151 }
152}
153
154fn cholesky_factor(a: &[f64], p: usize) -> Option<Vec<f64>> {
161 let mut l = vec![0.0; p * p];
162 for j in 0..p {
163 let mut diag = a[j * p + j];
164 for k in 0..j {
165 diag -= l[j * p + k] * l[j * p + k];
166 }
167 if diag <= 1e-12 {
168 return None;
169 }
170 l[j * p + j] = diag.sqrt();
171 for i in (j + 1)..p {
172 let mut s = a[i * p + j];
173 for k in 0..j {
174 s -= l[i * p + k] * l[j * p + k];
175 }
176 l[i * p + j] = s / l[j * p + j];
177 }
178 }
179 Some(l)
180}
181
182fn cholesky_forward_back(l: &[f64], b: &[f64], p: usize) -> Vec<f64> {
184 let mut z = b.to_vec();
185 for j in 0..p {
186 for k in 0..j {
187 z[j] -= l[j * p + k] * z[k];
188 }
189 z[j] /= l[j * p + j];
190 }
191 for j in (0..p).rev() {
192 for k in (j + 1)..p {
193 z[j] -= l[k * p + j] * z[k];
194 }
195 z[j] /= l[j * p + j];
196 }
197 z
198}
199
200fn kronecker_product(
206 a: &[f64],
207 rows_a: usize,
208 cols_a: usize,
209 b: &[f64],
210 rows_b: usize,
211 cols_b: usize,
212) -> Vec<f64> {
213 let out_rows = rows_a * rows_b;
214 let out_cols = cols_a * cols_b;
215 let mut c = vec![0.0; out_rows * out_cols];
216 for ia in 0..rows_a {
217 for ja in 0..cols_a {
218 let a_val = a[ia * cols_a + ja];
219 for ib in 0..rows_b {
220 for jb in 0..cols_b {
221 let row = ia * rows_b + ib;
222 let col = ja * cols_b + jb;
223 c[row * out_cols + col] = a_val * b[ib * cols_b + jb];
224 }
225 }
226 }
227 }
228 c
229}
230
231fn identity_matrix(n: usize) -> Vec<f64> {
233 let mut m = vec![0.0; n * n];
234 for i in 0..n {
235 m[i * n + i] = 1.0;
236 }
237 m
238}
239
240fn penalty_matrix_2d(m1: usize, m2: usize, lambda_s: f64, lambda_t: f64) -> Vec<f64> {
247 let m_total = m1 * m2;
248 let ps = penalty_matrix(m1);
249 let pt = penalty_matrix(m2);
250 let i_t = identity_matrix(m2);
251 let i_s = identity_matrix(m1);
252
253 let ps_kron_it = kronecker_product(&ps, m1, m1, &i_t, m2, m2);
254 let is_kron_pt = kronecker_product(&i_s, m1, m1, &pt, m2, m2);
255
256 let mut p2d = vec![0.0; m_total * m_total];
257 for i in 0..m_total * m_total {
258 p2d[i] = lambda_s * ps_kron_it[i] + lambda_t * is_kron_pt[i];
259 }
260 p2d
261}
262
263fn compute_fitted_residuals(
269 design: &FdMatrix,
270 beta: &FdMatrix,
271 data: &FdMatrix,
272) -> (FdMatrix, FdMatrix) {
273 let (n, m_total) = data.shape();
274 let p_total = design.ncols();
275 let mut fitted = FdMatrix::zeros(n, m_total);
276 let mut residuals = FdMatrix::zeros(n, m_total);
277 for i in 0..n {
278 for g in 0..m_total {
279 let mut yhat = 0.0;
280 for j in 0..p_total {
281 yhat += design[(i, j)] * beta[(j, g)];
282 }
283 fitted[(i, g)] = yhat;
284 residuals[(i, g)] = data[(i, g)] - yhat;
285 }
286 }
287 (fitted, residuals)
288}
289
290fn compute_gcv(residuals: &FdMatrix, trace_h: f64) -> f64 {
292 let (n, m) = residuals.shape();
293 let denom = (1.0 - trace_h / n as f64).max(1e-10);
294 let ss_res: f64 = residuals.as_slice().iter().map(|v| v * v).sum();
295 ss_res / (n as f64 * m as f64 * denom * denom)
296}
297
298fn trace_hat_ols(p_total: usize) -> f64 {
301 p_total as f64
302}
303
304fn smooth_coefficient_surface(
308 beta_raw: &[f64],
309 penalty_2d: &[f64],
310 m_total: usize,
311) -> Result<Vec<f64>, FdarError> {
312 let mut a = penalty_2d.to_vec();
314 for i in 0..m_total {
315 a[i * m_total + i] += 1.0;
316 }
317 let l = cholesky_factor(&a, m_total).ok_or_else(|| FdarError::ComputationFailed {
318 operation: "smooth_coefficient_surface",
319 detail: "Cholesky factorization of (I + P_2d) failed; try increasing the smoothing parameter (lambda)".to_string(),
320 })?;
321 Ok(cholesky_forward_back(&l, beta_raw, m_total))
322}
323
324fn compute_beta_se_2d(
327 xtx: &[f64],
328 residuals: &FdMatrix,
329 p_total: usize,
330 n: usize,
331) -> Option<FdMatrix> {
332 let m_total = residuals.ncols();
333 let l = cholesky_factor(xtx, p_total)?;
334
335 let a_inv_diag: Vec<f64> = (0..p_total)
337 .map(|j| {
338 let mut ej = vec![0.0; p_total];
339 ej[j] = 1.0;
340 let v = cholesky_forward_back(&l, &ej, p_total);
341 v[j]
342 })
343 .collect();
344
345 let df = (n - p_total).max(1) as f64;
346 let p = p_total - 1;
348 let mut se = FdMatrix::zeros(p, m_total);
349 for g in 0..m_total {
350 let sigma2: f64 = (0..n).map(|i| residuals[(i, g)].powi(2)).sum::<f64>() / df;
351 for j in 0..p {
352 se[(j, g)] = (sigma2 * a_inv_diag[j + 1]).max(0.0).sqrt();
354 }
355 }
356 Some(se)
357}
358
359fn select_lambdas_gcv(
365 xtx: &[f64],
366 xty: &FdMatrix,
367 design: &FdMatrix,
368 data: &FdMatrix,
369 m1: usize,
370 m2: usize,
371 fix_lambda_s: Option<f64>,
372 fix_lambda_t: Option<f64>,
373) -> (f64, f64) {
374 let candidates = [0.0, 1e-6, 1e-4, 1e-2, 0.1, 1.0, 10.0, 100.0, 1000.0];
375 let p_total = design.ncols();
376 let m_total = m1 * m2;
377
378 let ls_candidates: Vec<f64> = if let Some(ls) = fix_lambda_s {
379 vec![ls]
380 } else {
381 candidates.to_vec()
382 };
383 let lt_candidates: Vec<f64> = if let Some(lt) = fix_lambda_t {
384 vec![lt]
385 } else {
386 candidates.to_vec()
387 };
388
389 let Some(l_xtx) = cholesky_factor(xtx, p_total) else {
391 return (0.0, 0.0);
392 };
393
394 let mut beta_ols = FdMatrix::zeros(p_total, m_total);
396 for g in 0..m_total {
397 let b: Vec<f64> = (0..p_total).map(|j| xty[(j, g)]).collect();
398 let x = cholesky_forward_back(&l_xtx, &b, p_total);
399 for j in 0..p_total {
400 beta_ols[(j, g)] = x[j];
401 }
402 }
403
404 let trace_h = trace_hat_ols(p_total);
405 let mut best_ls = 0.0;
406 let mut best_lt = 0.0;
407 let mut best_gcv = f64::INFINITY;
408
409 for &ls in &ls_candidates {
410 for < in <_candidates {
411 if ls == 0.0 && lt == 0.0 {
412 let (_, residuals) = compute_fitted_residuals(design, &beta_ols, data);
414 let gcv = compute_gcv(&residuals, trace_h);
415 if gcv < best_gcv {
416 best_gcv = gcv;
417 best_ls = ls;
418 best_lt = lt;
419 }
420 continue;
421 }
422
423 let p2d = penalty_matrix_2d(m1, m2, ls, lt);
424 let mut beta_smooth = FdMatrix::zeros(p_total, m_total);
425 let mut ok = true;
426 for j in 0..p_total {
427 let raw: Vec<f64> = (0..m_total).map(|g| beta_ols[(j, g)]).collect();
428 if let Ok(smoothed) = smooth_coefficient_surface(&raw, &p2d, m_total) {
429 for g in 0..m_total {
430 beta_smooth[(j, g)] = smoothed[g];
431 }
432 } else {
433 ok = false;
434 break;
435 }
436 }
437 if !ok {
438 continue;
439 }
440
441 let (_, residuals) = compute_fitted_residuals(design, &beta_smooth, data);
442 let gcv = compute_gcv(&residuals, trace_h);
443 if gcv < best_gcv {
444 best_gcv = gcv;
445 best_ls = ls;
446 best_lt = lt;
447 }
448 }
449 }
450
451 (best_ls, best_lt)
452}
453
454#[must_use = "expensive computation whose result should not be discarded"]
516pub fn fosr_2d(
517 data: &FdMatrix,
518 predictors: &FdMatrix,
519 grid: &Grid2d,
520 lambda_s: f64,
521 lambda_t: f64,
522) -> Result<FosrResult2d, FdarError> {
523 let (n, m_data) = data.shape();
524 let p = predictors.ncols();
525 let m1 = grid.m1();
526 let m2 = grid.m2();
527 let m_total = grid.total();
528
529 if m1 == 0 {
532 return Err(FdarError::InvalidParameter {
533 parameter: "grid",
534 message: "argvals_s must not be empty".to_string(),
535 });
536 }
537 if m2 == 0 {
538 return Err(FdarError::InvalidParameter {
539 parameter: "grid",
540 message: "argvals_t must not be empty".to_string(),
541 });
542 }
543 if m_data != m_total {
544 return Err(FdarError::InvalidDimension {
545 parameter: "data",
546 expected: format!("{m_total} columns (grid m1*m2 = {m1}*{m2})"),
547 actual: format!("{m_data} columns"),
548 });
549 }
550 if predictors.nrows() != n {
551 return Err(FdarError::InvalidDimension {
552 parameter: "predictors",
553 expected: format!("{n} rows (matching data)"),
554 actual: format!("{} rows", predictors.nrows()),
555 });
556 }
557 if n < p + 2 {
558 return Err(FdarError::InvalidDimension {
559 parameter: "data",
560 expected: format!("at least {} observations (p + 2)", p + 2),
561 actual: format!("{n} observations"),
562 });
563 }
564
565 let design = build_fosr_design(predictors, n);
568 let p_total = design.ncols(); let xtx = compute_xtx(&design);
570 let xty = compute_xty_matrix(&design, data);
571
572 let l_xtx = cholesky_factor(&xtx, p_total).ok_or_else(|| FdarError::ComputationFailed {
573 operation: "fosr_2d",
574 detail: "Cholesky factorization of X'X failed; design matrix is rank-deficient — remove constant or collinear predictors, or add regularization".to_string(),
575 })?;
576
577 let mut beta_ols = FdMatrix::zeros(p_total, m_total);
579 for g in 0..m_total {
580 let b: Vec<f64> = (0..p_total).map(|j| xty[(j, g)]).collect();
581 let x = cholesky_forward_back(&l_xtx, &b, p_total);
582 for j in 0..p_total {
583 beta_ols[(j, g)] = x[j];
584 }
585 }
586
587 let fix_ls = if lambda_s >= 0.0 {
590 Some(lambda_s)
591 } else {
592 None
593 };
594 let fix_lt = if lambda_t >= 0.0 {
595 Some(lambda_t)
596 } else {
597 None
598 };
599
600 let (lambda_s_final, lambda_t_final) = if fix_ls.is_some() && fix_lt.is_some() {
601 (lambda_s, lambda_t)
602 } else {
603 select_lambdas_gcv(&xtx, &xty, &design, data, m1, m2, fix_ls, fix_lt)
604 };
605
606 let beta_smooth = if lambda_s_final == 0.0 && lambda_t_final == 0.0 {
609 beta_ols
610 } else {
611 let p2d = penalty_matrix_2d(m1, m2, lambda_s_final, lambda_t_final);
612 let mut smoothed = FdMatrix::zeros(p_total, m_total);
613 for j in 0..p_total {
614 let raw: Vec<f64> = (0..m_total).map(|g| beta_ols[(j, g)]).collect();
615 let s = smooth_coefficient_surface(&raw, &p2d, m_total)?;
616 for g in 0..m_total {
617 smoothed[(j, g)] = s[g];
618 }
619 }
620 smoothed
621 };
622
623 let (fitted, residuals) = compute_fitted_residuals(&design, &beta_smooth, data);
626
627 let r_squared_pointwise = pointwise_r_squared(data, &fitted);
628 let r_squared = if m_total > 0 {
629 r_squared_pointwise.iter().sum::<f64>() / m_total as f64
630 } else {
631 0.0
632 };
633
634 let trace_h = trace_hat_ols(p_total);
635 let gcv = compute_gcv(&residuals, trace_h);
636
637 let beta_se = compute_beta_se_2d(&xtx, &residuals, p_total, n);
638
639 let intercept: Vec<f64> = (0..m_total).map(|g| beta_smooth[(0, g)]).collect();
641
642 let mut beta_out = FdMatrix::zeros(p, m_total);
644 for j in 0..p {
645 for g in 0..m_total {
646 beta_out[(j, g)] = beta_smooth[(j + 1, g)];
647 }
648 }
649
650 Ok(FosrResult2d {
651 intercept,
652 beta: beta_out,
653 fitted,
654 residuals,
655 r_squared_pointwise,
656 r_squared,
657 beta_se,
658 lambda_s: lambda_s_final,
659 lambda_t: lambda_t_final,
660 gcv,
661 grid: grid.clone(),
662 })
663}
664
665#[must_use = "prediction result should not be discarded"]
676pub fn predict_fosr_2d(
677 result: &FosrResult2d,
678 new_predictors: &FdMatrix,
679) -> Result<FdMatrix, FdarError> {
680 let n_new = new_predictors.nrows();
681 let m_total = result.intercept.len();
682 let p = result.beta.nrows();
683
684 if new_predictors.ncols() != p {
685 return Err(FdarError::InvalidDimension {
686 parameter: "new_predictors",
687 expected: format!("{p} columns (matching fitted model)"),
688 actual: format!("{} columns", new_predictors.ncols()),
689 });
690 }
691
692 let mut predicted = FdMatrix::zeros(n_new, m_total);
693 for i in 0..n_new {
694 for g in 0..m_total {
695 let mut yhat = result.intercept[g];
696 for j in 0..p {
697 yhat += new_predictors[(i, j)] * result.beta[(j, g)];
698 }
699 predicted[(i, g)] = yhat;
700 }
701 }
702 Ok(predicted)
703}
704
705#[cfg(test)]
710mod tests {
711 use super::*;
712
713 fn uniform_grid_1d(m: usize) -> Vec<f64> {
714 (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect()
715 }
716
717 fn make_grid(m1: usize, m2: usize) -> Grid2d {
718 Grid2d::new(uniform_grid_1d(m1), uniform_grid_1d(m2))
719 }
720
721 fn generate_2d_data(
723 n: usize,
724 m1: usize,
725 m2: usize,
726 noise_scale: f64,
727 ) -> (FdMatrix, FdMatrix, Grid2d) {
728 let grid = make_grid(m1, m2);
729 let m_total = m1 * m2;
730 let mut y = FdMatrix::zeros(n, m_total);
731 let mut z = FdMatrix::zeros(n, 2);
732
733 for i in 0..n {
734 let z1 = (i as f64) / (n as f64);
735 let z2 = if i % 2 == 0 { 1.0 } else { 0.0 };
736 z[(i, 0)] = z1;
737 z[(i, 1)] = z2;
738
739 for si in 0..m1 {
740 for ti in 0..m2 {
741 let g = si + ti * m1; let s = grid.argvals_s[si];
743 let t = grid.argvals_t[ti];
744
745 let intercept = s + t;
746 let beta1 = s * t;
747 let beta2 = s - t;
748 let noise = noise_scale * ((i * 13 + si * 7 + ti * 3) % 100) as f64 / 100.0;
749
750 y[(i, g)] = intercept + z1 * beta1 + z2 * beta2 + noise;
751 }
752 }
753 }
754 (y, z, grid)
755 }
756
757 #[test]
758 fn test_grid2d_basic() {
759 let grid = make_grid(5, 4);
760 assert_eq!(grid.m1(), 5);
761 assert_eq!(grid.m2(), 4);
762 assert_eq!(grid.total(), 20);
763 }
764
765 #[test]
766 fn test_kronecker_product_small() {
767 let a = vec![1.0, 2.0, 3.0, 4.0];
770 let b = vec![0.0, 5.0, 6.0, 7.0];
771 let c = kronecker_product(&a, 2, 2, &b, 2, 2);
772 assert_eq!(c.len(), 16);
773 #[rustfmt::skip]
774 let expected = vec![
775 0.0, 5.0, 0.0, 10.0,
776 6.0, 7.0, 12.0, 14.0,
777 0.0, 15.0, 0.0, 20.0,
778 18.0, 21.0, 24.0, 28.0,
779 ];
780 for (i, (&ci, &ei)) in c.iter().zip(expected.iter()).enumerate() {
781 assert!(
782 (ci - ei).abs() < 1e-12,
783 "kronecker mismatch at index {i}: got {ci}, expected {ei}"
784 );
785 }
786 }
787
788 #[test]
789 fn test_penalty_2d_symmetry() {
790 let m1 = 5;
791 let m2 = 4;
792 let p2d = penalty_matrix_2d(m1, m2, 1.0, 1.0);
793 let m_total = m1 * m2;
794 for i in 0..m_total {
795 for j in 0..m_total {
796 assert!(
797 (p2d[i * m_total + j] - p2d[j * m_total + i]).abs() < 1e-12,
798 "P_2d not symmetric at ({i},{j})"
799 );
800 }
801 }
802 }
803
804 #[test]
805 fn test_penalty_2d_shape() {
806 let m1 = 5;
807 let m2 = 4;
808 let p2d = penalty_matrix_2d(m1, m2, 1.0, 2.0);
809 assert_eq!(p2d.len(), (m1 * m2) * (m1 * m2));
810 }
811
812 #[test]
813 fn test_fosr_2d_constant_response() {
814 let n = 20;
815 let m1 = 5;
816 let m2 = 4;
817 let grid = make_grid(m1, m2);
818 let m_total = m1 * m2;
819
820 let mut y = FdMatrix::zeros(n, m_total);
822 for i in 0..n {
823 for g in 0..m_total {
824 y[(i, g)] = 3.0;
825 }
826 }
827
828 let mut z = FdMatrix::zeros(n, 2);
829 for i in 0..n {
830 z[(i, 0)] = i as f64;
831 z[(i, 1)] = (i % 3) as f64;
832 }
833
834 let result = fosr_2d(&y, &z, &grid, 0.0, 0.0).unwrap();
835
836 for g in 0..m_total {
838 assert!(
839 (result.intercept[g] - 3.0).abs() < 1e-8,
840 "intercept[{g}] = {}, expected 3.0",
841 result.intercept[g]
842 );
843 }
844
845 for j in 0..2 {
847 for g in 0..m_total {
848 assert!(
849 result.beta[(j, g)].abs() < 1e-8,
850 "beta[{j},{g}] = {}, expected ~0",
851 result.beta[(j, g)]
852 );
853 }
854 }
855 }
856
857 #[test]
858 fn test_fosr_2d_single_predictor() {
859 let (y, z, grid) = generate_2d_data(20, 5, 4, 0.01);
860 let result = fosr_2d(&y, &z, &grid, 0.0, 0.0).unwrap();
861
862 assert!(
864 result.r_squared > 0.8,
865 "R^2 = {}, expected > 0.8",
866 result.r_squared
867 );
868 }
869
870 #[test]
871 fn test_fosr_2d_fitted_plus_residuals() {
872 let (y, z, grid) = generate_2d_data(20, 5, 4, 0.05);
873 let result = fosr_2d(&y, &z, &grid, 0.0, 0.0).unwrap();
874
875 let (n, m_total) = y.shape();
876 for i in 0..n {
877 for g in 0..m_total {
878 let reconstructed = result.fitted[(i, g)] + result.residuals[(i, g)];
879 assert!(
880 (reconstructed - y[(i, g)]).abs() < 1e-10,
881 "fitted + residuals != y at ({i},{g})"
882 );
883 }
884 }
885 }
886
887 #[test]
888 fn test_fosr_2d_r_squared_range() {
889 let (y, z, grid) = generate_2d_data(20, 5, 4, 0.05);
890 let result = fosr_2d(&y, &z, &grid, 0.0, 0.0).unwrap();
891
892 for (g, &r2) in result.r_squared_pointwise.iter().enumerate() {
893 assert!(
894 (-0.01..=1.0 + 1e-10).contains(&r2),
895 "R^2 out of range at grid point {g}: {r2}"
896 );
897 }
898 }
899
900 #[test]
901 fn test_fosr_2d_predict_matches_fitted() {
902 let (y, z, grid) = generate_2d_data(20, 5, 4, 0.05);
903 let result = fosr_2d(&y, &z, &grid, 0.0, 0.0).unwrap();
904
905 let preds = predict_fosr_2d(&result, &z).unwrap();
906 let (n, m_total) = y.shape();
907 for i in 0..n {
908 for g in 0..m_total {
909 assert!(
910 (preds[(i, g)] - result.fitted[(i, g)]).abs() < 1e-8,
911 "prediction != fitted at ({i},{g})"
912 );
913 }
914 }
915 }
916
917 #[test]
918 fn test_fosr_2d_reshape_beta_surface() {
919 let (y, z, grid) = generate_2d_data(20, 5, 4, 0.05);
920 let result = fosr_2d(&y, &z, &grid, 0.0, 0.0).unwrap();
921
922 let surface = result.beta_surface(0);
923 assert_eq!(surface.shape(), (5, 4));
924
925 let r2_surface = result.r_squared_surface();
926 assert_eq!(r2_surface.shape(), (5, 4));
927
928 let resid_surface = result.residual_surface(0);
929 assert_eq!(resid_surface.shape(), (5, 4));
930 }
931
932 #[test]
933 fn test_fosr_2d_dimension_mismatch() {
934 let grid = make_grid(5, 4);
935
936 let y = FdMatrix::zeros(20, 10); let z = FdMatrix::zeros(20, 2);
939 assert!(fosr_2d(&y, &z, &grid, 0.0, 0.0).is_err());
940
941 let y = FdMatrix::zeros(20, 20);
943 let z = FdMatrix::zeros(10, 2);
944 assert!(fosr_2d(&y, &z, &grid, 0.0, 0.0).is_err());
945
946 let y = FdMatrix::zeros(3, 20);
948 let z = FdMatrix::zeros(3, 2);
949 assert!(fosr_2d(&y, &z, &grid, 0.0, 0.0).is_err());
950
951 let empty_grid = Grid2d::new(vec![], vec![0.0, 1.0]);
953 let y = FdMatrix::zeros(20, 0);
954 let z = FdMatrix::zeros(20, 2);
955 assert!(fosr_2d(&y, &z, &empty_grid, 0.0, 0.0).is_err());
956
957 let grid = make_grid(3, 3);
959 let y = FdMatrix::zeros(20, 9);
960 let mut z = FdMatrix::zeros(20, 2);
961 for i in 0..20 {
962 z[(i, 0)] = i as f64;
963 z[(i, 1)] = (i * 3 % 7) as f64;
964 }
965 let result = fosr_2d(&y, &z, &grid, 0.0, 0.0).unwrap();
966 let z_bad = FdMatrix::zeros(5, 3); assert!(predict_fosr_2d(&result, &z_bad).is_err());
968 }
969
970 #[test]
971 fn test_fosr_2d_gcv() {
972 let (y, z, grid) = generate_2d_data(20, 5, 4, 0.05);
973 let result = fosr_2d(&y, &z, &grid, -1.0, -1.0).unwrap();
975 assert!(result.lambda_s >= 0.0);
976 assert!(result.lambda_t >= 0.0);
977 assert!(result.gcv > 0.0);
978 assert!(result.r_squared > 0.5);
979 }
980}