fdars_core/fof_regression.rs
1//! Function-on-function regression.
2//!
3//! Model: `Y(s) = α(s) + ∫ β(s,t) X(t) dt + ε(s)`
4//!
5//! Uses double FPCA: decompose both response Y and predictor X into
6//! FPC scores, regress Y-scores on X-scores, then reconstruct β(s,t)
7//! and fitted curves.
8//!
9//! # References
10//!
11//! - Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 16-17.
12//! - Yao, F., Müller, H.-G. & Wang, J.-L. (2005). Functional linear regression
13//! analysis for longitudinal data. *Annals of Statistics*, 33(6), 2873--2903.
14//! - Ivanescu, A. E., Staicu, A.-M., Scheipl, F. & Greven, S. (2015).
15//! Penalized function-on-function regression. *Computational Statistics*,
16//! 30(2), 539--568.
17
18use crate::error::FdarError;
19use crate::linalg::{cholesky_factor, cholesky_forward_back, compute_xtx};
20use crate::matrix::FdMatrix;
21use crate::regression::{fdata_to_pc_1d, FpcaResult};
22
23// ---------------------------------------------------------------------------
24// Result type
25// ---------------------------------------------------------------------------
26
27/// Result of function-on-function regression.
28#[derive(Debug, Clone, PartialEq)]
29#[non_exhaustive]
30pub struct FofResult {
31 /// Intercept function α(s) (length m_y)
32 pub intercept: Vec<f64>,
33 /// Coefficient surface β(s,t) stored as (m_y x m_x) matrix
34 pub beta_surface: FdMatrix,
35 /// Fitted response curves (n x m_y)
36 pub fitted: FdMatrix,
37 /// Residual curves (n x m_y)
38 pub residuals: FdMatrix,
39 /// R-squared per response grid point (length m_y)
40 pub r_squared_t: Vec<f64>,
41 /// Overall R-squared (mean of pointwise R-squared)
42 pub r_squared: f64,
43 /// Number of predictor FPC components used
44 pub ncomp_x: usize,
45 /// Number of response FPC components used
46 pub ncomp_y: usize,
47 /// FPCA of predictor (for projection)
48 pub fpca_x: FpcaResult,
49 /// FPCA of response (for reconstruction)
50 pub fpca_y: FpcaResult,
51 /// Coefficient matrix B: Y-scores = X-scores * B (ncomp_x x ncomp_y)
52 pub coef_matrix: FdMatrix,
53}
54
55// ---------------------------------------------------------------------------
56// Main function
57// ---------------------------------------------------------------------------
58
59/// Function-on-function regression via double FPCA.
60///
61/// Decomposes both predictor and response via FPCA, regresses Y-scores
62/// on X-scores using OLS, then reconstructs the coefficient surface
63/// β(s,t) and fitted curves.
64///
65/// # Arguments
66/// * `x_data` - Functional predictor (n x m_x)
67/// * `y_data` - Functional response (n x m_y)
68/// * `x_argvals` - Predictor grid (length m_x)
69/// * `y_argvals` - Response grid (length m_y)
70/// * `ncomp_x` - Number of predictor FPC components
71/// * `ncomp_y` - Number of response FPC components
72///
73/// # Errors
74///
75/// Returns [`FdarError::InvalidDimension`] if `x_data` and `y_data` have
76/// different row counts, or argvals lengths do not match column counts.
77/// Returns [`FdarError::InvalidParameter`] if `ncomp_x` or `ncomp_y` is zero.
78/// Returns [`FdarError::ComputationFailed`] if FPCA or OLS fails.
79///
80/// # References
81///
82/// Ramsay, J. O. & Silverman, B. W. (2005). *Functional Data Analysis*, Ch. 16-17.
83///
84/// # Examples
85///
86/// ```
87/// use fdars_core::matrix::FdMatrix;
88/// use fdars_core::fof_regression::fof_regression;
89///
90/// let (n, mx, my) = (25, 30, 20);
91/// let x = FdMatrix::from_column_major(
92/// (0..n * mx).map(|k| {
93/// let i = (k % n) as f64;
94/// let j = (k / n) as f64;
95/// ((i + 1.0) * j * 0.2).sin()
96/// }).collect(), n, mx,
97/// ).unwrap();
98/// let y = FdMatrix::from_column_major(
99/// (0..n * my).map(|k| {
100/// let i = (k % n) as f64;
101/// let j = (k / n) as f64;
102/// 0.5 * ((i + 1.0) * j * 0.15).cos()
103/// }).collect(), n, my,
104/// ).unwrap();
105/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
106/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
107///
108/// let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
109/// assert_eq!(fit.fitted.shape(), (n, my));
110/// assert_eq!(fit.beta_surface.shape(), (my, mx));
111/// ```
112#[must_use = "expensive computation whose result should not be discarded"]
113pub fn fof_regression(
114 x_data: &FdMatrix,
115 y_data: &FdMatrix,
116 x_argvals: &[f64],
117 y_argvals: &[f64],
118 ncomp_x: usize,
119 ncomp_y: usize,
120) -> Result<FofResult, FdarError> {
121 let (n_x, m_x) = x_data.shape();
122 let (n_y, m_y) = y_data.shape();
123
124 if n_x != n_y {
125 return Err(FdarError::InvalidDimension {
126 parameter: "y_data",
127 expected: format!("{n_x} rows (matching x_data)"),
128 actual: format!("{n_y} rows"),
129 });
130 }
131 let n = n_x;
132
133 if n < 3 {
134 return Err(FdarError::InvalidDimension {
135 parameter: "x_data",
136 expected: "at least 3 observations".to_string(),
137 actual: format!("{n}"),
138 });
139 }
140 if x_argvals.len() != m_x {
141 return Err(FdarError::InvalidDimension {
142 parameter: "x_argvals",
143 expected: format!("{m_x} elements"),
144 actual: format!("{} elements", x_argvals.len()),
145 });
146 }
147 if y_argvals.len() != m_y {
148 return Err(FdarError::InvalidDimension {
149 parameter: "y_argvals",
150 expected: format!("{m_y} elements"),
151 actual: format!("{} elements", y_argvals.len()),
152 });
153 }
154 if ncomp_x == 0 {
155 return Err(FdarError::InvalidParameter {
156 parameter: "ncomp_x",
157 message: "must be >= 1".to_string(),
158 });
159 }
160 if ncomp_y == 0 {
161 return Err(FdarError::InvalidParameter {
162 parameter: "ncomp_y",
163 message: "must be >= 1".to_string(),
164 });
165 }
166
167 let ncomp_x = ncomp_x.min(n - 1).min(m_x);
168 let ncomp_y = ncomp_y.min(n - 1).min(m_y);
169
170 // --- FPCA on X and Y ---
171 let fpca_x = fdata_to_pc_1d(x_data, ncomp_x, x_argvals)?;
172 let fpca_y = fdata_to_pc_1d(y_data, ncomp_y, y_argvals)?;
173
174 // --- Multivariate OLS: Y_scores = X_scores * B ---
175 // Use projected scores (weighted inner product with eigenfunctions) rather
176 // than SVD-derived scores so that training and prediction follow the same
177 // computational path, guaranteeing exact agreement on training data.
178 let x_scores = fpca_x.project(x_data)?;
179 let y_scores = fpca_y.project(y_data)?;
180
181 let mut xtx = compute_xtx(&x_scores);
182 // Small ridge regularization for numerical stability (standard in
183 // double-FPCA regression; see Ivanescu et al. 2015).
184 let ridge = 1e-8 * (0..ncomp_x).map(|k| xtx[k * ncomp_x + k]).sum::<f64>() / ncomp_x as f64;
185 for k in 0..ncomp_x {
186 xtx[k * ncomp_x + k] += ridge.max(1e-12);
187 }
188 let l = cholesky_factor(&xtx, ncomp_x)?;
189
190 // Solve for each column of Y_scores separately
191 let mut coef_matrix = FdMatrix::zeros(ncomp_x, ncomp_y);
192 for l_col in 0..ncomp_y {
193 // X' * y_scores[:,l_col]
194 let mut xty = vec![0.0; ncomp_x];
195 for k in 0..ncomp_x {
196 let mut s = 0.0;
197 for i in 0..n {
198 s += x_scores[(i, k)] * y_scores[(i, l_col)];
199 }
200 xty[k] = s;
201 }
202 let b_col = cholesky_forward_back(&l, &xty, ncomp_x);
203 for k in 0..ncomp_x {
204 coef_matrix[(k, l_col)] = b_col[k];
205 }
206 }
207
208 // --- Reconstruct coefficient surface β(s,t) ---
209 // β(s_i, t_j) = Σ_k Σ_l B_{kl} * φ_x^k(t_j) * φ_y^l(s_i)
210 let mut beta_surface = FdMatrix::zeros(m_y, m_x);
211 for si in 0..m_y {
212 for tj in 0..m_x {
213 let mut val = 0.0;
214 for k in 0..ncomp_x {
215 for l_col in 0..ncomp_y {
216 val += coef_matrix[(k, l_col)]
217 * fpca_x.rotation[(tj, k)]
218 * fpca_y.rotation[(si, l_col)];
219 }
220 }
221 beta_surface[(si, tj)] = val;
222 }
223 }
224
225 // --- Fitted Y-scores and reconstruction ---
226 // Ŷ_scores = X_scores * B (n x ncomp_y)
227 let mut fitted_scores = FdMatrix::zeros(n, ncomp_y);
228 for i in 0..n {
229 for l_col in 0..ncomp_y {
230 let mut s = 0.0;
231 for k in 0..ncomp_x {
232 s += x_scores[(i, k)] * coef_matrix[(k, l_col)];
233 }
234 fitted_scores[(i, l_col)] = s;
235 }
236 }
237
238 // Reconstruct fitted curves: Ŷ(s) = mean_y(s) + Σ_l fitted_score_l * φ_y^l(s)
239 let mut fitted = FdMatrix::zeros(n, m_y);
240 for i in 0..n {
241 for j in 0..m_y {
242 let mut val = fpca_y.mean[j];
243 for l_col in 0..ncomp_y {
244 val += fitted_scores[(i, l_col)] * fpca_y.rotation[(j, l_col)];
245 }
246 fitted[(i, j)] = val;
247 }
248 }
249
250 // --- Residuals ---
251 let mut residuals = FdMatrix::zeros(n, m_y);
252 for i in 0..n {
253 for j in 0..m_y {
254 residuals[(i, j)] = y_data[(i, j)] - fitted[(i, j)];
255 }
256 }
257
258 // --- Intercept: α(s) = mean_y(s) (since we centered Y via FPCA) ---
259 let intercept = fpca_y.mean.clone();
260
261 // --- Pointwise R² ---
262 let mut r_squared_t = vec![0.0; m_y];
263 for j in 0..m_y {
264 let y_mean_j = fpca_y.mean[j];
265 let mut ss_tot = 0.0;
266 let mut ss_res = 0.0;
267 for i in 0..n {
268 ss_tot += (y_data[(i, j)] - y_mean_j).powi(2);
269 ss_res += residuals[(i, j)].powi(2);
270 }
271 r_squared_t[j] = if ss_tot > 0.0 {
272 1.0 - ss_res / ss_tot
273 } else {
274 0.0
275 };
276 }
277
278 let r_squared = r_squared_t.iter().sum::<f64>() / m_y as f64;
279
280 Ok(FofResult {
281 intercept,
282 beta_surface,
283 fitted,
284 residuals,
285 r_squared_t,
286 r_squared,
287 ncomp_x,
288 ncomp_y,
289 fpca_x,
290 fpca_y,
291 coef_matrix,
292 })
293}
294
295// ---------------------------------------------------------------------------
296// Prediction
297// ---------------------------------------------------------------------------
298
299/// Predict functional responses from new functional predictors.
300///
301/// Projects `new_x` onto the predictor FPCA, computes predicted Y-scores
302/// via the fitted coefficient matrix, and reconstructs response curves.
303///
304/// # Arguments
305/// * `fit` - A fitted [`FofResult`]
306/// * `new_x` - New functional predictor data (n_new x m_x)
307///
308/// # Errors
309///
310/// Returns [`FdarError::InvalidDimension`] if the column count of `new_x`
311/// does not match the predictor grid used during fitting.
312///
313/// # Examples
314///
315/// ```
316/// use fdars_core::matrix::FdMatrix;
317/// use fdars_core::fof_regression::{fof_regression, predict_fof};
318///
319/// let (n, mx, my) = (25, 30, 20);
320/// let x = FdMatrix::from_column_major(
321/// (0..n * mx).map(|k| {
322/// let i = (k % n) as f64;
323/// let j = (k / n) as f64;
324/// ((i + 1.0) * j * 0.2).sin()
325/// }).collect(), n, mx,
326/// ).unwrap();
327/// let y = FdMatrix::from_column_major(
328/// (0..n * my).map(|k| {
329/// let i = (k % n) as f64;
330/// let j = (k / n) as f64;
331/// 0.5 * ((i + 1.0) * j * 0.15).cos()
332/// }).collect(), n, my,
333/// ).unwrap();
334/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
335/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
336///
337/// let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
338/// let predicted = predict_fof(&fit, &x).unwrap();
339/// assert_eq!(predicted.shape(), (n, my));
340/// ```
341pub fn predict_fof(fit: &FofResult, new_x: &FdMatrix) -> Result<FdMatrix, FdarError> {
342 let (n_new, _m_x) = new_x.shape();
343
344 // Project onto predictor FPCA
345 let x_scores = fit.fpca_x.project(new_x)?;
346
347 let ncomp_x = fit.ncomp_x;
348 let ncomp_y = fit.ncomp_y;
349 let m_y = fit.fpca_y.mean.len();
350
351 // Compute predicted Y-scores: Ŷ_scores = X_scores * B
352 let mut pred_scores = FdMatrix::zeros(n_new, ncomp_y);
353 for i in 0..n_new {
354 for l_col in 0..ncomp_y {
355 let mut s = 0.0;
356 for k in 0..ncomp_x {
357 s += x_scores[(i, k)] * fit.coef_matrix[(k, l_col)];
358 }
359 pred_scores[(i, l_col)] = s;
360 }
361 }
362
363 // Reconstruct: Ŷ(s) = mean_y(s) + Σ_l score_l * φ_y^l(s)
364 let mut predicted = FdMatrix::zeros(n_new, m_y);
365 for i in 0..n_new {
366 for j in 0..m_y {
367 let mut val = fit.fpca_y.mean[j];
368 for l_col in 0..ncomp_y {
369 val += pred_scores[(i, l_col)] * fit.fpca_y.rotation[(j, l_col)];
370 }
371 predicted[(i, j)] = val;
372 }
373 }
374
375 Ok(predicted)
376}
377
378// ---------------------------------------------------------------------------
379// Tests
380// ---------------------------------------------------------------------------
381// Cross-validation
382// ---------------------------------------------------------------------------
383
384/// Result of function-on-function cross-validation.
385#[derive(Debug, Clone, PartialEq)]
386#[non_exhaustive]
387pub struct FofCvResult {
388 /// (ncomp_x, ncomp_y) candidates tested.
389 pub candidates: Vec<(usize, usize)>,
390 /// Integrated CV-MSE for each candidate.
391 pub cv_errors: Vec<f64>,
392 /// Optimal (ncomp_x, ncomp_y).
393 pub optimal: (usize, usize),
394 /// Minimum integrated CV-MSE.
395 pub min_cv_mse: f64,
396}
397
398/// K-fold cross-validation for function-on-function regression.
399///
400/// Searches over a grid of (ncomp_x, ncomp_y) values and selects the
401/// combination minimizing integrated mean squared error (IMSE).
402///
403/// # Arguments
404/// * `x_data` - Functional predictor (n × m_x)
405/// * `y_data` - Functional response (n × m_y)
406/// * `x_argvals` - Predictor grid (length m_x)
407/// * `y_argvals` - Response grid (length m_y)
408/// * `ncomp_x_max` - Maximum predictor components to try
409/// * `ncomp_y_max` - Maximum response components to try
410/// * `n_folds` - Number of CV folds
411/// * `seed` - Random seed for fold assignment
412///
413/// # References
414///
415/// Ivanescu, A. E., Staicu, A.-M., Scheipl, F. & Greven, S. (2015).
416/// Penalized function-on-function regression. *Computational Statistics*,
417/// 30(2), 539--568.
418#[must_use = "expensive computation whose result should not be discarded"]
419pub fn fof_cv(
420 x_data: &FdMatrix,
421 y_data: &FdMatrix,
422 x_argvals: &[f64],
423 y_argvals: &[f64],
424 ncomp_x_max: usize,
425 ncomp_y_max: usize,
426 n_folds: usize,
427 seed: u64,
428) -> Result<FofCvResult, FdarError> {
429 let n = x_data.nrows();
430 if n < n_folds {
431 return Err(FdarError::InvalidDimension {
432 parameter: "x_data",
433 expected: format!("at least {n_folds} rows"),
434 actual: format!("{n}"),
435 });
436 }
437
438 let folds = crate::cv::create_folds(n, n_folds, seed);
439 let ncomp_x_max = ncomp_x_max.min(n - 2);
440 let ncomp_y_max = ncomp_y_max.min(n - 2);
441 let m_y = y_data.ncols();
442
443 // Integration weights for IMSE
444 let y_weights = crate::helpers::simpsons_weights(y_argvals);
445
446 let mut candidates = Vec::new();
447 let mut cv_errors = Vec::new();
448 let mut best = (1, 1);
449 let mut best_mse = f64::INFINITY;
450
451 for ncx in 1..=ncomp_x_max {
452 for ncy in 1..=ncomp_y_max {
453 let mut total_imse = 0.0;
454 let mut count = 0;
455
456 for fold in 0..n_folds {
457 let train_idx: Vec<usize> = (0..n).filter(|&i| folds[i] != fold).collect();
458 let test_idx: Vec<usize> = (0..n).filter(|&i| folds[i] == fold).collect();
459 let n_test = test_idx.len();
460 if n_test == 0 || train_idx.len() < ncx.max(ncy) + 2 {
461 continue;
462 }
463
464 let train_x = x_data.select_rows(&train_idx);
465 let train_y = y_data.select_rows(&train_idx);
466 let test_x = x_data.select_rows(&test_idx);
467 let test_y = y_data.select_rows(&test_idx);
468
469 let Ok(fit) = fof_regression(&train_x, &train_y, x_argvals, y_argvals, ncx, ncy)
470 else {
471 continue;
472 };
473
474 let Ok(predicted) = predict_fof(&fit, &test_x) else {
475 continue;
476 };
477
478 // Integrated MSE per test curve
479 for ti in 0..n_test {
480 let imse: f64 = (0..m_y)
481 .map(|j| (test_y[(ti, j)] - predicted[(ti, j)]).powi(2) * y_weights[j])
482 .sum();
483 total_imse += imse;
484 count += 1;
485 }
486 }
487
488 let mse = if count > 0 {
489 total_imse / count as f64
490 } else {
491 f64::INFINITY
492 };
493
494 candidates.push((ncx, ncy));
495 cv_errors.push(mse);
496
497 if mse < best_mse {
498 best_mse = mse;
499 best = (ncx, ncy);
500 }
501 }
502 }
503
504 if candidates.is_empty() {
505 return Err(FdarError::ComputationFailed {
506 operation: "fof_cv",
507 detail: "no valid (ncomp_x, ncomp_y) produced CV errors".into(),
508 });
509 }
510
511 Ok(FofCvResult {
512 candidates,
513 cv_errors,
514 optimal: best,
515 min_cv_mse: best_mse,
516 })
517}
518
519// ---------------------------------------------------------------------------
520// Random-effects function-on-function regression
521// ---------------------------------------------------------------------------
522
523/// Configuration for [`fof_re_regression`].
524///
525/// No `#[non_exhaustive]` — callers may construct with struct literals.
526#[derive(Debug, Clone, PartialEq)]
527#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
528pub struct FofReConfig {
529 /// Number of predictor FPC components (default: 3)
530 pub ncomp_x: usize,
531 /// Number of response FPC components (default: 3)
532 pub ncomp_y: usize,
533 /// Maximum REML EM iterations per Y-score component (default: 50)
534 pub max_iter: usize,
535 /// Convergence tolerance for variance components (default: 1e-10)
536 pub tol: f64,
537}
538
539impl Default for FofReConfig {
540 fn default() -> Self {
541 Self {
542 ncomp_x: 3,
543 ncomp_y: 3,
544 max_iter: 50,
545 tol: 1e-10,
546 }
547 }
548}
549
550/// Result of a random-effects function-on-function regression.
551///
552/// Extends [`FofResult`] with subject-level random intercepts on Y-score
553/// components, enabling prediction for grouped/longitudinal functional data.
554///
555/// # Divergence from `refund::pffr`
556///
557/// R's `pffr(y ~ pcre(x))` uses a penalized-spline GAMM (mgcv) backend with
558/// functional random effects represented in a spline basis. `fof_re_regression`
559/// instead uses the FPC-score parametrization: both X and Y are decomposed into
560/// FPC scores (`fdata_to_pc_1d`), and for each Y-score component a scalar linear
561/// mixed model (REML EM, reusing `famm::fit_scalar_mixed_model`) is fitted with
562/// the X-scores as fixed-effect covariates and subject IDs as the grouping factor.
563/// No basis penalties are applied — this matches the locked design decision for
564/// Phase 32.
565#[derive(Debug, Clone, PartialEq)]
566#[non_exhaustive]
567#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
568pub struct FofReResult {
569 /// Intercept function α(s) = mean_y(s) (length m_y)
570 pub intercept: Vec<f64>,
571 /// Coefficient surface β(s,t) stored as (m_y × m_x) matrix
572 pub beta_surface: FdMatrix,
573 /// Fitted response curves (n × m_y), including fixed + random effects
574 pub fitted: FdMatrix,
575 /// Residual curves (n × m_y)
576 pub residuals: FdMatrix,
577 /// R-squared per response grid point (length m_y)
578 pub r_squared_t: Vec<f64>,
579 /// Overall R-squared (mean of pointwise R-squared)
580 pub r_squared: f64,
581 /// Number of predictor FPC components used
582 pub ncomp_x: usize,
583 /// Number of response FPC components used
584 pub ncomp_y: usize,
585 /// FPCA of predictor (carried for prediction)
586 pub fpca_x: FpcaResult,
587 /// FPCA of response (carried for reconstruction)
588 pub fpca_y: FpcaResult,
589 /// Fixed-effect coefficient matrix B (ncomp_x × ncomp_y):
590 /// Y-scores ≈ X-scores * B
591 pub coef_matrix: FdMatrix,
592 /// Subject-level random intercept functions (n_subjects × m_y)
593 pub random_effects: FdMatrix,
594 /// Per-Y-component random-intercept variance (length ncomp_y)
595 pub sigma2_u: Vec<f64>,
596 /// Mean residual variance across Y-score components
597 pub sigma2_eps: f64,
598 /// Number of unique subjects
599 pub n_subjects: usize,
600}
601
602/// Flexible random-effects function-on-function regression via double FPCA.
603///
604/// Extends [`fof_regression`] by fitting a scalar linear mixed model (REML EM)
605/// for each Y-score component, adding subject-level random intercepts. This
606/// captures within-subject correlation in grouped/longitudinal functional data.
607///
608/// # Algorithm
609///
610/// 1. **Double FPCA** (same as `fof_regression`): decompose X and Y into FPC
611/// scores via `fdata_to_pc_1d`.
612/// 2. **Per-Y-score mixed model** (new): for each Y-score component `l`,
613/// fit `y_scores[:,l] = x_scores * γ_l + u_{subj(i),l} + ε_il` using
614/// `famm::fit_scalar_mixed_model`. X-scores are passed directly as
615/// covariates without rescaling (Pitfall 2: projection already carries
616/// the L² weighting — do NOT re-apply `h.sqrt()` normalization).
617/// 3. **Reconstruction** (same structure as `fof_regression`): build
618/// `beta_surface` from `coef_matrix`; fitted curves add mean_y + fixed
619/// X-score contribution + subject random-effect contribution;
620/// `random_effects` reconstructed via `famm::recover_random_effects`.
621///
622/// # Arguments
623/// * `x_data` - Functional predictor (n × m_x)
624/// * `y_data` - Functional response (n × m_y)
625/// * `subject_ids` - Subject identifier for each observation (length n)
626/// * `x_argvals` - Predictor evaluation grid (length m_x)
627/// * `y_argvals` - Response evaluation grid (length m_y)
628/// * `config` - [`FofReConfig`] with component counts and convergence settings
629///
630/// # Errors
631///
632/// Returns [`FdarError::InvalidDimension`] if:
633/// - `x_data` and `y_data` have different row counts
634/// - fewer than 3 observations
635/// - argvals lengths do not match column counts
636/// - `subject_ids.len()` does not equal `n`
637///
638/// Returns [`FdarError::InvalidParameter`] if `ncomp_x` or `ncomp_y` is zero.
639/// Returns [`FdarError::ComputationFailed`] if FPCA fails.
640///
641/// # Examples
642///
643/// ```
644/// use fdars_core::matrix::FdMatrix;
645/// use fdars_core::fof_regression::{fof_re_regression, FofReConfig};
646///
647/// let (n, mx, my) = (20, 25, 15);
648/// let n_subjects = 5;
649/// let x = FdMatrix::from_column_major(
650/// (0..n * mx).map(|k| {
651/// let i = (k % n) as f64;
652/// let j = (k / n) as f64;
653/// ((i + 1.0) * j * 0.2).sin()
654/// }).collect(), n, mx,
655/// ).unwrap();
656/// let y = FdMatrix::from_column_major(
657/// (0..n * my).map(|k| {
658/// let i = (k % n) as f64;
659/// let j = (k / n) as f64;
660/// 0.5 * ((i + 1.0) * j * 0.15).cos()
661/// }).collect(), n, my,
662/// ).unwrap();
663/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
664/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
665/// // 4 visits per subject
666/// let subject_ids: Vec<usize> = (0..n).map(|i| i / 4).collect();
667///
668/// let config = FofReConfig::default();
669/// let fit = fof_re_regression(&x, &y, &subject_ids, &tx, &ty, &config).unwrap();
670/// assert_eq!(fit.fitted.shape(), (n, my));
671/// assert_eq!(fit.beta_surface.shape(), (my, mx));
672/// assert_eq!(fit.random_effects.nrows(), n_subjects);
673/// ```
674#[must_use = "expensive computation whose result should not be discarded"]
675pub fn fof_re_regression(
676 x_data: &FdMatrix,
677 y_data: &FdMatrix,
678 subject_ids: &[usize],
679 x_argvals: &[f64],
680 y_argvals: &[f64],
681 config: &FofReConfig,
682) -> Result<FofReResult, FdarError> {
683 let (n_x, m_x) = x_data.shape();
684 let (n_y, m_y) = y_data.shape();
685
686 // --- Input validation ---
687 if n_x != n_y {
688 return Err(FdarError::InvalidDimension {
689 parameter: "y_data",
690 expected: format!("{n_x} rows (matching x_data)"),
691 actual: format!("{n_y} rows"),
692 });
693 }
694 let n = n_x;
695
696 if n < 3 {
697 return Err(FdarError::InvalidDimension {
698 parameter: "x_data",
699 expected: "at least 3 observations".to_string(),
700 actual: format!("{n}"),
701 });
702 }
703 if x_argvals.len() != m_x {
704 return Err(FdarError::InvalidDimension {
705 parameter: "x_argvals",
706 expected: format!("{m_x} elements"),
707 actual: format!("{} elements", x_argvals.len()),
708 });
709 }
710 if y_argvals.len() != m_y {
711 return Err(FdarError::InvalidDimension {
712 parameter: "y_argvals",
713 expected: format!("{m_y} elements"),
714 actual: format!("{} elements", y_argvals.len()),
715 });
716 }
717 if subject_ids.len() != n {
718 return Err(FdarError::InvalidDimension {
719 parameter: "subject_ids",
720 expected: format!("length {n}"),
721 actual: format!("length {}", subject_ids.len()),
722 });
723 }
724 if config.ncomp_x == 0 {
725 return Err(FdarError::InvalidParameter {
726 parameter: "ncomp_x",
727 message: "must be >= 1".to_string(),
728 });
729 }
730 if config.ncomp_y == 0 {
731 return Err(FdarError::InvalidParameter {
732 parameter: "ncomp_y",
733 message: "must be >= 1".to_string(),
734 });
735 }
736
737 let ncomp_x = config.ncomp_x.min(n - 1).min(m_x);
738 let ncomp_y = config.ncomp_y.min(n - 1).min(m_y);
739
740 // --- Step 1: Double FPCA ---
741 let fpca_x = fdata_to_pc_1d(x_data, ncomp_x, x_argvals)?;
742 let fpca_y = fdata_to_pc_1d(y_data, ncomp_y, y_argvals)?;
743
744 // Project to score space using L²-weighted inner product
745 let x_scores = fpca_x.project(x_data)?;
746 let y_scores = fpca_y.project(y_data)?;
747
748 // Build subject structure (non-contiguous IDs handled by build_subject_map)
749 let (subject_map, n_subjects) = crate::famm::build_subject_map(subject_ids);
750
751 // Wrap x_scores as covariates for fit_scalar_mixed_model.
752 // INTENTIONAL: x_scores are passed directly WITHOUT re-applying h.sqrt() normalization.
753 // The L² weighting is already embedded in fpca_x.project(); re-scaling would double-count
754 // it (Pitfall 2, per Phase 32 RESEARCH.md). This diverges from fit_all_components in
755 // famm.rs which applies score_scale = h.sqrt() before calling fit_scalar_mixed_model.
756 let p = ncomp_x; // number of fixed-effect covariates per Y-score model
757
758 // --- Step 2: Per-Y-score mixed model ---
759 let mut coef_matrix = FdMatrix::zeros(ncomp_x, ncomp_y);
760 // u_hat_per_component[l] = Vec of length n_subjects (random intercepts for Y-score l)
761 let mut u_hat_per_component: Vec<Vec<f64>> = Vec::with_capacity(ncomp_y);
762 let mut sigma2_u = vec![0.0; ncomp_y];
763 let mut sigma2_eps_total = 0.0_f64;
764
765 for l in 0..ncomp_y {
766 // Extract y_scores column l into a Vec<f64>
767 let y_scores_l: Vec<f64> = (0..n).map(|i| y_scores[(i, l)]).collect();
768
769 let result = crate::famm::fit_scalar_mixed_model(
770 &y_scores_l,
771 &subject_map,
772 n_subjects,
773 Some(&x_scores),
774 p,
775 );
776
777 // gamma_l: fixed-effect coefficients (length ncomp_x)
778 for k in 0..ncomp_x {
779 if k < result.gamma.len() {
780 coef_matrix[(k, l)] = result.gamma[k];
781 }
782 }
783 u_hat_per_component.push(result.u_hat);
784 sigma2_u[l] = result.sigma2_u;
785 sigma2_eps_total += result.sigma2_eps;
786 }
787 let sigma2_eps = sigma2_eps_total / ncomp_y as f64;
788
789 // --- Step 3: Reconstruction ---
790
791 // Reconstruct β(s,t) surface
792 let mut beta_surface = FdMatrix::zeros(m_y, m_x);
793 for si in 0..m_y {
794 for tj in 0..m_x {
795 let mut val = 0.0;
796 for k in 0..ncomp_x {
797 for l in 0..ncomp_y {
798 val +=
799 coef_matrix[(k, l)] * fpca_x.rotation[(tj, k)] * fpca_y.rotation[(si, l)];
800 }
801 }
802 beta_surface[(si, tj)] = val;
803 }
804 }
805
806 // recover_random_effects expects u_hat[subject][component].
807 // u_hat_per_component is organized as [component][subject], so transpose.
808 let mut u_hat_by_subject: Vec<Vec<f64>> = vec![vec![0.0; ncomp_y]; n_subjects];
809 for l in 0..ncomp_y {
810 for s in 0..n_subjects {
811 u_hat_by_subject[s][l] = u_hat_per_component[l][s];
812 }
813 }
814 // Reconstruct random effects: n_subjects × m_y
815 // random_effects[s, j] = Σ_l u_hat_by_subject[s][l] * phi_y^l(j)
816 let random_effects = crate::famm::recover_random_effects(
817 &u_hat_by_subject,
818 &fpca_y.rotation,
819 n_subjects,
820 m_y,
821 ncomp_y,
822 );
823
824 // Compute fitted curves: mean_y + fixed-effect contribution + subject random effect
825 let mut fitted = FdMatrix::zeros(n, m_y);
826 for i in 0..n {
827 let s = subject_map[i];
828 for j in 0..m_y {
829 let mut val = fpca_y.mean[j];
830 // Fixed-effect contribution: Σ_l (Σ_k x_scores[i,k] * gamma[k,l]) * phi_y^l(j)
831 for l in 0..ncomp_y {
832 let mut score_l = 0.0;
833 for k in 0..ncomp_x {
834 score_l += x_scores[(i, k)] * coef_matrix[(k, l)];
835 }
836 val += score_l * fpca_y.rotation[(j, l)];
837 }
838 // Random-effect contribution: b_s(j) = Σ_l u_hat[l][s] * phi_y^l(j)
839 val += random_effects[(s, j)];
840 fitted[(i, j)] = val;
841 }
842 }
843
844 // Residuals
845 let mut residuals = FdMatrix::zeros(n, m_y);
846 for i in 0..n {
847 for j in 0..m_y {
848 residuals[(i, j)] = y_data[(i, j)] - fitted[(i, j)];
849 }
850 }
851
852 // Intercept: α(s) = mean_y(s)
853 let intercept = fpca_y.mean.clone();
854
855 // Pointwise R²
856 let mut r_squared_t = vec![0.0; m_y];
857 for j in 0..m_y {
858 let y_mean_j = fpca_y.mean[j];
859 let mut ss_tot = 0.0;
860 let mut ss_res = 0.0;
861 for i in 0..n {
862 ss_tot += (y_data[(i, j)] - y_mean_j).powi(2);
863 ss_res += residuals[(i, j)].powi(2);
864 }
865 r_squared_t[j] = if ss_tot > 0.0 {
866 1.0 - ss_res / ss_tot
867 } else {
868 0.0
869 };
870 }
871 let r_squared = r_squared_t.iter().sum::<f64>() / m_y as f64;
872
873 Ok(FofReResult {
874 intercept,
875 beta_surface,
876 fitted,
877 residuals,
878 r_squared_t,
879 r_squared,
880 ncomp_x,
881 ncomp_y,
882 fpca_x,
883 fpca_y,
884 coef_matrix,
885 random_effects,
886 sigma2_u,
887 sigma2_eps,
888 n_subjects,
889 })
890}
891
892// ---------------------------------------------------------------------------
893// Prediction for random-effects FoF model
894// ---------------------------------------------------------------------------
895
896/// Predict functional responses from new functional predictors using a fitted
897/// [`FofReResult`].
898///
899/// Prediction is **fixed-effect only** — no random effects are added for
900/// unseen subjects. This mirrors the `fmm_predict` / `predict_fof` convention:
901/// new subjects receive only the population-level fixed-effect prediction.
902///
903/// # Arguments
904/// * `fit` - A fitted [`FofReResult`]
905/// * `new_x` - New functional predictor data (n_new × m_x)
906///
907/// # Errors
908///
909/// Returns [`FdarError::InvalidDimension`] if the column count of `new_x`
910/// does not match the predictor grid used during fitting.
911///
912/// # Examples
913///
914/// ```
915/// use fdars_core::matrix::FdMatrix;
916/// use fdars_core::fof_regression::{fof_re_regression, predict_fof_re, FofReConfig};
917///
918/// let (n, mx, my) = (20, 25, 15);
919/// let x = FdMatrix::from_column_major(
920/// (0..n * mx).map(|k| {
921/// let i = (k % n) as f64;
922/// let j = (k / n) as f64;
923/// ((i + 1.0) * j * 0.2).sin()
924/// }).collect(), n, mx,
925/// ).unwrap();
926/// let y = FdMatrix::from_column_major(
927/// (0..n * my).map(|k| {
928/// let i = (k % n) as f64;
929/// let j = (k / n) as f64;
930/// 0.5 * ((i + 1.0) * j * 0.15).cos()
931/// }).collect(), n, my,
932/// ).unwrap();
933/// let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1) as f64).collect();
934/// let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1) as f64).collect();
935/// let subject_ids: Vec<usize> = (0..n).map(|i| i / 4).collect();
936///
937/// let config = FofReConfig::default();
938/// let fit = fof_re_regression(&x, &y, &subject_ids, &tx, &ty, &config).unwrap();
939/// let predicted = predict_fof_re(&fit, &x).unwrap();
940/// assert_eq!(predicted.shape(), (n, my));
941/// ```
942#[must_use = "prediction result should not be discarded"]
943pub fn predict_fof_re(fit: &FofReResult, new_x: &FdMatrix) -> Result<FdMatrix, FdarError> {
944 let (n_new, _m_x) = new_x.shape();
945
946 // Project onto predictor FPCA
947 let x_scores = fit.fpca_x.project(new_x)?;
948
949 let ncomp_x = fit.ncomp_x;
950 let ncomp_y = fit.ncomp_y;
951 let m_y = fit.fpca_y.mean.len();
952
953 // Compute predicted Y-scores: Ŷ_scores = X_scores * coef_matrix (fixed effect only)
954 let mut pred_scores = FdMatrix::zeros(n_new, ncomp_y);
955 for i in 0..n_new {
956 for l in 0..ncomp_y {
957 let mut s = 0.0;
958 for k in 0..ncomp_x {
959 s += x_scores[(i, k)] * fit.coef_matrix[(k, l)];
960 }
961 pred_scores[(i, l)] = s;
962 }
963 }
964
965 // Reconstruct: Ŷ(s) = mean_y(s) + Σ_l score_l * φ_y^l(s)
966 // No random effects for new (unseen) subjects — fixed-effect only prediction.
967 let mut predicted = FdMatrix::zeros(n_new, m_y);
968 for i in 0..n_new {
969 for j in 0..m_y {
970 let mut val = fit.fpca_y.mean[j];
971 for l in 0..ncomp_y {
972 val += pred_scores[(i, l)] * fit.fpca_y.rotation[(j, l)];
973 }
974 predicted[(i, j)] = val;
975 }
976 }
977
978 Ok(predicted)
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984 use std::f64::consts::PI;
985
986 /// Generate test data with multiple independent modes of variation so
987 /// that requesting several FPC components produces a well-conditioned
988 /// score matrix.
989 fn make_fof_data(
990 n: usize,
991 mx: usize,
992 my: usize,
993 seed: u64,
994 ) -> (FdMatrix, FdMatrix, Vec<f64>, Vec<f64>) {
995 let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1).max(1) as f64).collect();
996 let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1).max(1) as f64).collect();
997
998 let mut x = FdMatrix::zeros(n, mx);
999 let mut y = FdMatrix::zeros(n, my);
1000
1001 for i in 0..n {
1002 // Multiple independent per-observation loadings for X
1003 let a =
1004 ((seed.wrapping_mul(17).wrapping_add(i as u64 * 31) % 1000) as f64 / 500.0) - 1.0;
1005 let b =
1006 ((seed.wrapping_mul(7).wrapping_add(i as u64 * 53) % 1000) as f64 / 500.0) - 1.0;
1007 let c =
1008 ((seed.wrapping_mul(3).wrapping_add(i as u64 * 79) % 1000) as f64 / 500.0) - 1.0;
1009 for j in 0..mx {
1010 x[(i, j)] = a * (2.0 * PI * tx[j]).sin() + b * (4.0 * PI * tx[j]).cos() + c * tx[j];
1011 }
1012
1013 // Y depends on X via integral-like coupling with distinct modes
1014 for j in 0..my {
1015 y[(i, j)] = 1.5 * a * (2.0 * PI * ty[j]).cos() - 0.8 * b * (3.0 * PI * ty[j]).sin()
1016 + 0.5 * c * ty[j].powi(2)
1017 + 0.01 * (seed.wrapping_add(i as u64 + j as u64) % 10) as f64;
1018 }
1019 }
1020 (x, y, tx, ty)
1021 }
1022
1023 #[test]
1024 fn test_fof_regression_dimensions() {
1025 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1026 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1027
1028 assert_eq!(fit.fitted.shape(), (30, 25));
1029 assert_eq!(fit.residuals.shape(), (30, 25));
1030 assert_eq!(fit.beta_surface.shape(), (25, 40));
1031 assert_eq!(fit.intercept.len(), 25);
1032 assert_eq!(fit.r_squared_t.len(), 25);
1033 assert_eq!(fit.coef_matrix.shape(), (3, 3));
1034 assert_eq!(fit.ncomp_x, 3);
1035 assert_eq!(fit.ncomp_y, 3);
1036 }
1037
1038 #[test]
1039 fn test_fof_regression_r_squared_positive() {
1040 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1041 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1042
1043 // For correlated data, overall R² should be positive
1044 assert!(
1045 fit.r_squared > 0.0,
1046 "R² should be positive for correlated data, got {}",
1047 fit.r_squared
1048 );
1049 }
1050
1051 #[test]
1052 fn test_predict_fof_training_matches_fitted() {
1053 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1054 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1055 let predicted = predict_fof(&fit, &x).unwrap();
1056
1057 assert_eq!(predicted.shape(), fit.fitted.shape());
1058 let (n, my) = predicted.shape();
1059 for i in 0..n {
1060 for j in 0..my {
1061 assert!(
1062 (predicted[(i, j)] - fit.fitted[(i, j)]).abs() < 1e-6,
1063 "predicted should match fitted at ({i}, {j}): {} vs {}",
1064 predicted[(i, j)],
1065 fit.fitted[(i, j)]
1066 );
1067 }
1068 }
1069 }
1070
1071 #[test]
1072 fn test_predict_fof_new_data_finite() {
1073 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1074 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1075
1076 // Create slightly different new data
1077 let n_new = 10;
1078 let mx = 40;
1079 let mut new_x = FdMatrix::zeros(n_new, mx);
1080 for i in 0..n_new {
1081 let p = (i as f64 + 0.5) * PI / n_new as f64;
1082 for j in 0..mx {
1083 new_x[(i, j)] = (2.0 * PI * tx[j] + p).cos();
1084 }
1085 }
1086
1087 let predicted = predict_fof(&fit, &new_x).unwrap();
1088 assert_eq!(predicted.shape(), (n_new, 25));
1089 for i in 0..n_new {
1090 for j in 0..25 {
1091 assert!(
1092 predicted[(i, j)].is_finite(),
1093 "prediction should be finite at ({i}, {j})"
1094 );
1095 }
1096 }
1097 }
1098
1099 #[test]
1100 fn test_fof_regression_mismatched_n() {
1101 let (x, _y, tx, ty) = make_fof_data(30, 40, 25, 42);
1102 let y_bad = FdMatrix::zeros(20, 25);
1103 let result = fof_regression(&x, &y_bad, &tx, &ty, 3, 3);
1104 assert!(result.is_err());
1105 }
1106
1107 #[test]
1108 fn test_fof_regression_bad_argvals() {
1109 let (x, y, _tx, ty) = make_fof_data(30, 40, 25, 42);
1110 let bad_tx: Vec<f64> = (0..10).map(|j| j as f64).collect(); // wrong length
1111 let result = fof_regression(&x, &y, &bad_tx, &ty, 3, 3);
1112 assert!(result.is_err());
1113 }
1114
1115 #[test]
1116 fn test_fof_regression_zero_ncomp() {
1117 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1118 assert!(fof_regression(&x, &y, &tx, &ty, 0, 3).is_err());
1119 assert!(fof_regression(&x, &y, &tx, &ty, 3, 0).is_err());
1120 }
1121
1122 #[test]
1123 fn test_fof_cv() {
1124 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1125 let cv = fof_cv(&x, &y, &tx, &ty, 4, 4, 5, 42).unwrap();
1126 assert!(!cv.candidates.is_empty());
1127 assert!(cv.optimal.0 >= 1);
1128 assert!(cv.optimal.1 >= 1);
1129 assert!(cv.min_cv_mse.is_finite());
1130 }
1131
1132 #[test]
1133 fn test_fof_regression_residuals_consistent() {
1134 let (x, y, tx, ty) = make_fof_data(30, 40, 25, 42);
1135 let fit = fof_regression(&x, &y, &tx, &ty, 3, 3).unwrap();
1136
1137 let (n, my) = y.shape();
1138 for i in 0..n {
1139 for j in 0..my {
1140 let expected_resid = y[(i, j)] - fit.fitted[(i, j)];
1141 assert!(
1142 (fit.residuals[(i, j)] - expected_resid).abs() < 1e-10,
1143 "residual mismatch at ({i}, {j})"
1144 );
1145 }
1146 }
1147 }
1148
1149 // -----------------------------------------------------------------------
1150 // Helper: generate FoF data with subject IDs for RE tests
1151 // -----------------------------------------------------------------------
1152
1153 /// Generate test data with a grouped structure (repeated visits per subject).
1154 ///
1155 /// Each subject contributes `n_visits` curves. Subject-level random shifts
1156 /// are added to Y so that random effects should be detectable.
1157 fn make_fof_re_data(
1158 n_subjects: usize,
1159 n_visits: usize,
1160 mx: usize,
1161 my: usize,
1162 seed: u64,
1163 ) -> (FdMatrix, FdMatrix, Vec<usize>, Vec<f64>, Vec<f64>) {
1164 let n = n_subjects * n_visits;
1165 let tx: Vec<f64> = (0..mx).map(|j| j as f64 / (mx - 1).max(1) as f64).collect();
1166 let ty: Vec<f64> = (0..my).map(|j| j as f64 / (my - 1).max(1) as f64).collect();
1167
1168 let mut x = FdMatrix::zeros(n, mx);
1169 let mut y = FdMatrix::zeros(n, my);
1170 let mut subject_ids = Vec::with_capacity(n);
1171
1172 for s in 0..n_subjects {
1173 // Subject-level random shift to Y (so RE should be non-zero)
1174 let shift =
1175 ((seed.wrapping_mul(13).wrapping_add(s as u64 * 97) % 1000) as f64 / 500.0) - 1.0;
1176
1177 for v in 0..n_visits {
1178 let i = s * n_visits + v;
1179 subject_ids.push(s);
1180
1181 let a = ((seed.wrapping_mul(17).wrapping_add(i as u64 * 31) % 1000) as f64 / 500.0)
1182 - 1.0;
1183 let b = ((seed.wrapping_mul(7).wrapping_add(i as u64 * 53) % 1000) as f64 / 500.0)
1184 - 1.0;
1185
1186 for j in 0..mx {
1187 x[(i, j)] = a * (2.0 * PI * tx[j]).sin() + b * (4.0 * PI * tx[j]).cos();
1188 }
1189 for j in 0..my {
1190 // Fixed effect: depends on x-loadings
1191 // Random shift: subject-level
1192 y[(i, j)] = 1.5 * a * (2.0 * PI * ty[j]).cos()
1193 - 0.8 * b * (3.0 * PI * ty[j]).sin()
1194 + shift // subject-level random intercept in curve-space
1195 + 0.01 * (seed.wrapping_add(i as u64 + j as u64) % 10) as f64;
1196 }
1197 }
1198 }
1199
1200 (x, y, subject_ids, tx, ty)
1201 }
1202
1203 // -----------------------------------------------------------------------
1204 // Task 1: fof_re_regression tests
1205 // -----------------------------------------------------------------------
1206
1207 #[test]
1208 fn test_fof_re_regression_dims() {
1209 let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1210 let n = x.nrows();
1211 let mx = x.ncols();
1212 let my = y.ncols();
1213 let n_subjects = 5;
1214 let config = FofReConfig {
1215 ncomp_x: 3,
1216 ncomp_y: 3,
1217 ..FofReConfig::default()
1218 };
1219 let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1220
1221 assert_eq!(fit.beta_surface.shape(), (my, mx), "beta_surface shape");
1222 assert_eq!(fit.fitted.shape(), (n, my), "fitted shape");
1223 assert_eq!(fit.residuals.shape(), (n, my), "residuals shape");
1224 assert_eq!(
1225 fit.random_effects.nrows(),
1226 n_subjects,
1227 "random_effects rows"
1228 );
1229 assert_eq!(fit.random_effects.ncols(), my, "random_effects cols");
1230 assert_eq!(fit.coef_matrix.shape(), (3, 3), "coef_matrix shape");
1231 assert_eq!(fit.intercept.len(), my, "intercept length");
1232 assert_eq!(fit.sigma2_u.len(), 3, "sigma2_u length");
1233 assert_eq!(fit.n_subjects, n_subjects, "n_subjects");
1234 assert_eq!(fit.ncomp_x, 3, "ncomp_x");
1235 assert_eq!(fit.ncomp_y, 3, "ncomp_y");
1236 }
1237
1238 #[test]
1239 fn test_fof_re_regression_invariant() {
1240 let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1241 let config = FofReConfig::default();
1242 let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1243
1244 let (n, my) = y.shape();
1245 for i in 0..n {
1246 for j in 0..my {
1247 let reconstructed = fit.fitted[(i, j)] + fit.residuals[(i, j)];
1248 assert!(
1249 (reconstructed - y[(i, j)]).abs() < 1e-6,
1250 "fitted+residuals != y at ({i},{j}): reconstructed={reconstructed}, y={}",
1251 y[(i, j)]
1252 );
1253 }
1254 }
1255 }
1256
1257 #[test]
1258 fn test_fof_re_regression_re_nonzero() {
1259 // With distinct subject-level shifts, random_effects L2 norm must be > 0
1260 let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1261 let config = FofReConfig::default();
1262 let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1263
1264 let n_subjects = fit.random_effects.nrows();
1265 let my = fit.random_effects.ncols();
1266 let mut l2_sq = 0.0_f64;
1267 for s in 0..n_subjects {
1268 for j in 0..my {
1269 l2_sq += fit.random_effects[(s, j)].powi(2);
1270 }
1271 }
1272 let l2 = l2_sq.sqrt();
1273 assert!(
1274 l2 > 0.0,
1275 "random_effects L2 norm should be > 0 for grouped data, got {l2}"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_fof_re_regression_ids_mismatch() {
1281 let (x, y, _ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1282 let bad_ids: Vec<usize> = vec![0, 1, 2]; // wrong length
1283 let config = FofReConfig::default();
1284 let err = fof_re_regression(&x, &y, &bad_ids, &tx, &ty, &config).unwrap_err();
1285 match err {
1286 FdarError::InvalidDimension { parameter, .. } => {
1287 assert_eq!(parameter, "subject_ids");
1288 }
1289 other => panic!("Expected InvalidDimension for subject_ids, got {other:?}"),
1290 }
1291 }
1292
1293 #[test]
1294 fn test_fof_re_reexport() {
1295 // Smoke test: fof_re_regression is accessible in scope via super::*
1296 let _ = fof_re_regression;
1297 }
1298
1299 // -----------------------------------------------------------------------
1300 // Task 2: predict_fof_re tests
1301 // -----------------------------------------------------------------------
1302
1303 #[test]
1304 fn test_predict_fof_re_shape() {
1305 let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1306 let my = y.ncols();
1307 let config = FofReConfig::default();
1308 let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1309
1310 let n_new = 7;
1311 let mx = x.ncols();
1312 let new_x = FdMatrix::from_column_major(
1313 (0..n_new * mx)
1314 .map(|k| {
1315 let i = (k % n_new) as f64;
1316 let j = (k / n_new) as f64;
1317 (i * j * 0.1).sin()
1318 })
1319 .collect(),
1320 n_new,
1321 mx,
1322 )
1323 .unwrap();
1324
1325 let predicted = predict_fof_re(&fit, &new_x).unwrap();
1326 assert_eq!(predicted.shape(), (n_new, my), "shape mismatch");
1327
1328 // All entries must be finite
1329 for i in 0..n_new {
1330 for j in 0..my {
1331 assert!(
1332 predicted[(i, j)].is_finite(),
1333 "non-finite prediction at ({i},{j})"
1334 );
1335 }
1336 }
1337 }
1338
1339 #[test]
1340 fn test_predict_fof_re_training_matches_fixed() {
1341 // On training data, predict_fof_re returns fixed-effect-only fitted values.
1342 // These should equal: mean_y + sum_l (x_scores[i,l] * coef_matrix[:,l]) * phi_y^l
1343 // i.e., fitted minus the random-effect contribution.
1344 let (x, y, ids, tx, ty) = make_fof_re_data(5, 4, 30, 20, 42);
1345 let my = y.ncols();
1346 let config = FofReConfig::default();
1347 let fit = fof_re_regression(&x, &y, &ids, &tx, &ty, &config).unwrap();
1348
1349 let fixed_only = predict_fof_re(&fit, &x).unwrap();
1350 assert_eq!(fixed_only.shape(), (x.nrows(), my));
1351
1352 // Compute expected fixed-effect part manually: mean_y + X_scores * coef_matrix reconstructed
1353 let x_scores = fit.fpca_x.project(&x).unwrap();
1354 let ncomp_x = fit.ncomp_x;
1355 let ncomp_y = fit.ncomp_y;
1356 let n = x.nrows();
1357
1358 for i in 0..n {
1359 for j in 0..my {
1360 let mut expected = fit.fpca_y.mean[j];
1361 for l in 0..ncomp_y {
1362 let mut sc = 0.0;
1363 for k in 0..ncomp_x {
1364 sc += x_scores[(i, k)] * fit.coef_matrix[(k, l)];
1365 }
1366 expected += sc * fit.fpca_y.rotation[(j, l)];
1367 }
1368 assert!(
1369 (fixed_only[(i, j)] - expected).abs() < 1e-6,
1370 "fixed-only prediction mismatch at ({i},{j}): got {}, expected {expected}",
1371 fixed_only[(i, j)]
1372 );
1373 }
1374 }
1375 }
1376}