Skip to main content

fdars_core/scalar_on_function/
mod.rs

1//! Scalar-on-function regression with mixed scalar/functional covariates.
2//!
3//! Implements models of the form:
4//! ```text
5//! y = α + ∫β(t)X(t)dt + γᵀz + ε
6//! ```
7//! where X(t) is a functional predictor, z is a vector of scalar covariates,
8//! β(t) is the functional coefficient, and γ is the vector of scalar coefficients.
9//!
10//! # Methods
11//!
12//! - [`fregre_lm`]: FPC-based functional linear model with optional scalar covariates
13//! - [`fregre_l1`]: L1 (median) robust functional regression via IRLS
14//! - [`fregre_huber`]: Huber M-estimation robust functional regression via IRLS
15//! - [`fregre_np_mixed`]: Nonparametric kernel regression with product kernels
16//! - [`functional_logistic`]: Logistic regression for binary outcomes
17//! - [`fregre_cv`]: Cross-validation for number of FPC components
18
19use crate::error::FdarError;
20use crate::linalg::cholesky_solve as linalg_cholesky_solve;
21use crate::matrix::FdMatrix;
22use crate::regression::{FpcaResult, PlsResult};
23
24mod additive;
25mod bootstrap;
26mod cv;
27mod fregre_lm;
28mod glm;
29mod logistic;
30mod multi;
31mod nonparametric;
32mod pls;
33mod robust;
34#[cfg(test)]
35mod tests;
36
37// Re-export all public items from submodules
38pub use additive::{
39    fam, fregre_gkam, fregre_gsam, history_index, permutation_test_fam, variable_selection,
40    FamConfig, FamResult, GkamConfig, GkamResult, GsamConfig, GsamResult, HistoryIndexConfig,
41    HistoryIndexResult, PermTestConfig, PermTestResult, PermTestStatistic, VarSelectConfig,
42    VarSelectPenalty, VarSelectResult,
43};
44pub use bootstrap::{bootstrap_ci_fregre_lm, bootstrap_ci_functional_logistic};
45pub use cv::{fregre_basis_cv, fregre_np_cv};
46pub use fregre_lm::{fregre_cv, fregre_lm, model_selection_ncomp, predict_fregre_lm};
47pub use glm::{functional_glm, predict_functional_glm};
48// GlmFamily and FunctionalGlmResult are defined in this module (mod.rs) and
49// exported from here directly — no glm:: re-export needed for those types.
50pub use logistic::{functional_logistic, predict_functional_logistic};
51pub use multi::{fregre_lm_multi, fregre_lm_multi_cv, predict_fregre_lm_multi, MultiCvResult};
52pub use nonparametric::{
53    fregre_np_from_distances, fregre_np_mixed, predict_fregre_np, predict_fregre_np_from_distances,
54};
55pub use pls::{fregre_pls, predict_fregre_pls};
56pub use robust::{fregre_huber, fregre_l1, predict_fregre_robust};
57
58// ---------------------------------------------------------------------------
59// Result types
60// ---------------------------------------------------------------------------
61
62/// Result of functional linear regression.
63#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65pub struct FregreLmResult {
66    /// Intercept α
67    pub intercept: f64,
68    /// Functional coefficient β(t), evaluated on the original grid (length m)
69    pub beta_t: Vec<f64>,
70    /// Pointwise standard errors of β(t) (length m)
71    pub beta_se: Vec<f64>,
72    /// Scalar coefficients γ (one per scalar covariate)
73    pub gamma: Vec<f64>,
74    /// Fitted values ŷ (length n)
75    pub fitted_values: Vec<f64>,
76    /// Residuals y - ŷ (length n)
77    pub residuals: Vec<f64>,
78    /// R² statistic
79    pub r_squared: f64,
80    /// Adjusted R²
81    pub r_squared_adj: f64,
82    /// Standard errors of all coefficients (intercept, FPC scores, scalar covariates)
83    pub std_errors: Vec<f64>,
84    /// Number of FPC components used
85    pub ncomp: usize,
86    /// FPCA result (for projecting new data)
87    pub fpca: FpcaResult,
88    /// Regression coefficients on (FPC scores, scalar covariates) — internal
89    pub coefficients: Vec<f64>,
90    /// Residual standard error
91    pub residual_se: f64,
92    /// GCV criterion value (if computed)
93    pub gcv: f64,
94    /// Akaike Information Criterion
95    pub aic: f64,
96    /// Bayesian Information Criterion
97    pub bic: f64,
98}
99
100/// Result of nonparametric functional regression with mixed predictors.
101#[derive(Debug, Clone, PartialEq)]
102#[non_exhaustive]
103pub struct FregreNpResult {
104    /// Fitted values ŷ (length n)
105    pub fitted_values: Vec<f64>,
106    /// Residuals y - ŷ (length n)
107    pub residuals: Vec<f64>,
108    /// R² statistic
109    pub r_squared: f64,
110    /// Bandwidth for functional distance kernel
111    pub h_func: f64,
112    /// Bandwidth for scalar covariates kernel
113    pub h_scalar: f64,
114    /// Leave-one-out CV error
115    pub cv_error: f64,
116}
117
118/// Result of robust (L1 or Huber) functional regression.
119#[derive(Debug, Clone, PartialEq)]
120#[non_exhaustive]
121pub struct FregreRobustResult {
122    /// Intercept
123    pub intercept: f64,
124    /// Functional coefficient β(t), evaluated on the original grid (length m)
125    pub beta_t: Vec<f64>,
126    /// Fitted values ŷ (length n)
127    pub fitted_values: Vec<f64>,
128    /// Residuals y - ŷ (length n)
129    pub residuals: Vec<f64>,
130    /// Regression coefficients (intercept, FPC scores, scalar covariates)
131    pub coefficients: Vec<f64>,
132    /// Number of FPC components used
133    pub ncomp: usize,
134    /// FPCA result (for projecting new data)
135    pub fpca: FpcaResult,
136    /// Number of IRLS iterations performed
137    pub iterations: usize,
138    /// Whether the IRLS algorithm converged
139    pub converged: bool,
140    /// Final IRLS weights (length n)
141    pub weights: Vec<f64>,
142    /// R² statistic
143    pub r_squared: f64,
144}
145
146/// Result of functional logistic regression.
147#[derive(Debug, Clone, PartialEq)]
148#[non_exhaustive]
149pub struct FunctionalLogisticResult {
150    /// Intercept α
151    pub intercept: f64,
152    /// Functional coefficient β(t), evaluated on the original grid (length m)
153    pub beta_t: Vec<f64>,
154    /// Pointwise standard errors of β(t) (length m)
155    pub beta_se: Vec<f64>,
156    /// Scalar coefficients γ (one per scalar covariate)
157    pub gamma: Vec<f64>,
158    /// Predicted probabilities P(Y=1) (length n)
159    pub probabilities: Vec<f64>,
160    /// Predicted class labels (0 or 1)
161    pub predicted_classes: Vec<usize>,
162    /// Number of FPC components used
163    pub ncomp: usize,
164    /// Classification accuracy on training data
165    pub accuracy: f64,
166    /// Standard errors of all coefficients (intercept, FPC scores, scalar covariates)
167    pub std_errors: Vec<f64>,
168    /// Regression coefficients on (FPC scores, scalar covariates) — internal
169    pub coefficients: Vec<f64>,
170    /// Log-likelihood at convergence
171    pub log_likelihood: f64,
172    /// Number of IRLS iterations
173    pub iterations: usize,
174    /// FPCA result (for projecting new data)
175    pub fpca: FpcaResult,
176    /// Akaike Information Criterion
177    pub aic: f64,
178    /// Bayesian Information Criterion
179    pub bic: f64,
180}
181
182/// Result of cross-validation for K selection.
183#[derive(Debug, Clone, PartialEq)]
184#[non_exhaustive]
185pub struct FregreCvResult {
186    /// Candidate K values tested
187    pub k_values: Vec<usize>,
188    /// CV error for each K
189    pub cv_errors: Vec<f64>,
190    /// Optimal K (minimizing CV error)
191    pub optimal_k: usize,
192    /// Minimum CV error
193    pub min_cv_error: f64,
194    /// Out-of-fold predictions at optimal K (length n, each predicted when held out)
195    pub oof_predictions: Vec<f64>,
196    /// Fold assignment for each observation (0..n_folds)
197    pub fold_assignments: Vec<usize>,
198    /// Per-fold MSE at optimal K
199    pub fold_errors: Vec<f64>,
200}
201
202/// Result of PLS-based scalar-on-function regression.
203#[derive(Debug, Clone, PartialEq)]
204#[non_exhaustive]
205pub struct PlsRegressionResult {
206    /// Intercept α
207    pub intercept: f64,
208    /// Functional coefficient β(t), evaluated on the original grid (length m)
209    pub beta_t: Vec<f64>,
210    /// Scalar coefficients γ (one per scalar covariate)
211    pub gamma: Vec<f64>,
212    /// Fitted values ŷ (length n)
213    pub fitted_values: Vec<f64>,
214    /// Residuals y - ŷ (length n)
215    pub residuals: Vec<f64>,
216    /// R² statistic
217    pub r_squared: f64,
218    /// Adjusted R²
219    pub r_squared_adj: f64,
220    /// Number of PLS components used
221    pub ncomp: usize,
222    /// PLS result (for projecting new data)
223    pub pls: PlsResult,
224    /// Regression coefficients on (intercept, PLS scores, scalar covariates)
225    pub coefficients: Vec<f64>,
226    /// Residual standard error
227    pub residual_se: f64,
228    /// Akaike Information Criterion
229    pub aic: f64,
230    /// Bayesian Information Criterion
231    pub bic: f64,
232}
233
234/// Result of multi-predictor functional linear regression.
235#[derive(Debug, Clone, PartialEq)]
236#[non_exhaustive]
237pub struct MultiFregreLmResult {
238    /// Intercept α
239    pub intercept: f64,
240    /// Functional coefficients beta_k(t) for each predictor, each length m_k.
241    pub beta_t: Vec<Vec<f64>>,
242    /// Scalar coefficients γ (one per scalar covariate)
243    pub gamma: Vec<f64>,
244    /// Fitted values ŷ (length n)
245    pub fitted_values: Vec<f64>,
246    /// Residuals y - ŷ (length n)
247    pub residuals: Vec<f64>,
248    /// R²
249    pub r_squared: f64,
250    /// Adjusted R²
251    pub r_squared_adj: f64,
252    /// Number of FPC components used per functional predictor
253    pub ncomp: Vec<usize>,
254    /// FPCA results for each functional predictor (for projection)
255    pub fpcas: Vec<FpcaResult>,
256    /// Regression coefficients [intercept, scores_1..., scores_2..., ..., scalars...]
257    pub coefficients: Vec<f64>,
258    /// Residual standard error
259    pub residual_se: f64,
260    /// AIC
261    pub aic: f64,
262    /// BIC
263    pub bic: f64,
264}
265
266/// Criterion used for model selection.
267#[non_exhaustive]
268#[derive(Debug, Clone, Copy, PartialEq)]
269pub enum SelectionCriterion {
270    /// Akaike Information Criterion
271    Aic,
272    /// Bayesian Information Criterion
273    Bic,
274    /// Generalized Cross-Validation
275    Gcv,
276}
277
278/// Result of ncomp model selection.
279#[derive(Debug, Clone, PartialEq)]
280#[non_exhaustive]
281pub struct ModelSelectionResult {
282    /// Best number of FPC components by the chosen criterion
283    pub best_ncomp: usize,
284    /// (ncomp, AIC, BIC, GCV) for each candidate
285    pub criteria: Vec<(usize, f64, f64, f64)>,
286}
287
288/// Result of bootstrap confidence intervals for β(t).
289#[derive(Debug, Clone, PartialEq)]
290#[non_exhaustive]
291pub struct BootstrapCiResult {
292    /// Pointwise lower bound (length m).
293    pub lower: Vec<f64>,
294    /// Pointwise upper bound (length m).
295    pub upper: Vec<f64>,
296    /// Original β(t) estimate (length m).
297    pub center: Vec<f64>,
298    /// Simultaneous lower bound (sup-norm adjusted, length m).
299    pub sim_lower: Vec<f64>,
300    /// Simultaneous upper bound (sup-norm adjusted, length m).
301    pub sim_upper: Vec<f64>,
302    /// Number of bootstrap replicates that converged.
303    pub n_boot_success: usize,
304}
305
306/// Result of lambda selection for basis regression via cross-validation.
307#[derive(Debug, Clone, PartialEq)]
308#[non_exhaustive]
309pub struct FregreBasisCvResult {
310    /// Optimal smoothing parameter lambda.
311    pub optimal_lambda: f64,
312    /// Mean CV error for each lambda.
313    pub cv_errors: Vec<f64>,
314    /// SE of CV error across folds for each lambda.
315    pub cv_se: Vec<f64>,
316    /// Lambda values tested.
317    pub lambda_values: Vec<f64>,
318    /// Minimum mean CV error.
319    pub min_cv_error: f64,
320}
321
322/// Result of bandwidth selection for nonparametric regression via CV.
323#[derive(Debug, Clone, PartialEq)]
324#[non_exhaustive]
325pub struct FregreNpCvResult {
326    /// Optimal bandwidth.
327    pub optimal_h: f64,
328    /// Mean CV error for each bandwidth.
329    pub cv_errors: Vec<f64>,
330    /// SE of CV error across folds for each bandwidth.
331    pub cv_se: Vec<f64>,
332    /// Bandwidth values tested.
333    pub h_values: Vec<f64>,
334    /// Minimum mean CV error.
335    pub min_cv_error: f64,
336}
337
338/// Exponential-family distribution for [`functional_glm`].
339///
340/// Each variant specifies the canonical link function and variance function
341/// for one member of the exponential family.
342///
343/// | Variant   | Link g(μ)  | Inverse link g⁻¹(η) | Variance V(μ) |
344/// |-----------|------------|---------------------|--------------|
345/// | Binomial  | logit      | sigmoid             | μ(1−μ)       |
346/// | Poisson   | log        | exp                 | μ            |
347/// | Gamma     | inverse    | 1/η                 | μ²           |
348/// | Gaussian  | identity   | η                   | 1            |
349#[derive(Debug, Clone, Copy, PartialEq)]
350#[non_exhaustive]
351#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
352pub enum GlmFamily {
353    /// Logit link; binary outcomes (y ∈ {0, 1}).
354    Binomial,
355    /// Log link; non-negative integer counts (y ∈ {0, 1, 2, …}).
356    Poisson,
357    /// Inverse link (canonical); strictly positive continuous responses (y > 0).
358    Gamma,
359    /// Identity link; continuous unbounded responses.
360    Gaussian,
361}
362
363/// Result of [`functional_glm`] for a scalar response over functional predictors.
364///
365/// Contains the fitted model parameters, diagnostic statistics, and the embedded
366/// [`crate::regression::FpcaResult`] for projecting new data.
367#[derive(Debug, Clone, PartialEq)]
368#[non_exhaustive]
369#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
370pub struct FunctionalGlmResult {
371    /// Intercept α
372    pub intercept: f64,
373    /// Functional coefficient β(t), evaluated on the original grid (length m)
374    pub beta_t: Vec<f64>,
375    /// Pointwise standard errors of β(t) (length m)
376    pub beta_se: Vec<f64>,
377    /// Scalar coefficients γ (one per scalar covariate)
378    pub gamma: Vec<f64>,
379    /// Fitted mean values μ = g⁻¹(η) (length n)
380    pub fitted_values: Vec<f64>,
381    /// Linear predictors η = Xβ (length n)
382    pub linear_predictors: Vec<f64>,
383    /// Number of FPC components used
384    pub ncomp: usize,
385    /// All regression coefficients [intercept, γ₁…γ_K, z₁…z_P]
386    pub coefficients: Vec<f64>,
387    /// Standard errors of all coefficients (intercept, FPC scores, scalar covariates)
388    pub std_errors: Vec<f64>,
389    /// Log-likelihood at convergence (kernel; see module doc for AIC comparability note)
390    pub log_likelihood: f64,
391    /// GLM deviance D = 2(LL_saturated − LL_fitted)
392    pub deviance: f64,
393    /// Number of IRLS iterations performed
394    pub iterations: usize,
395    /// FPCA result (embedded for projecting new data)
396    pub fpca: crate::regression::FpcaResult,
397    /// Akaike Information Criterion: −2·log_likelihood + 2·p
398    pub aic: f64,
399    /// Bayesian Information Criterion: −2·log_likelihood + p·ln(n)
400    pub bic: f64,
401    /// Exponential-family distribution used for this fit
402    pub family: GlmFamily,
403}
404
405impl FunctionalGlmResult {
406    /// Predict response for new functional data. Delegates to [`predict_functional_glm`].
407    ///
408    /// # Errors
409    ///
410    /// Propagates [`FdarError::InvalidDimension`] from [`predict_functional_glm`]
411    /// when `new_data` / `new_scalar` shapes do not match the fitted model.
412    pub fn predict(
413        &self,
414        new_data: &FdMatrix,
415        new_scalar: Option<&FdMatrix>,
416    ) -> Result<Vec<f64>, FdarError> {
417        predict_functional_glm(self, new_data, new_scalar)
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Shared linear algebra helpers (delegated to crate::linalg)
423// ---------------------------------------------------------------------------
424
425// Re-export for use by submodules and explain/ modules that import from
426// `crate::scalar_on_function::{cholesky_factor, cholesky_forward_back, compute_xtx}`.
427pub(crate) use crate::linalg::cholesky_factor;
428pub(crate) use crate::linalg::cholesky_forward_back;
429pub(crate) use crate::linalg::compute_xtx;
430
431/// Compute X'y (length p).
432fn compute_xty(x: &FdMatrix, y: &[f64]) -> Vec<f64> {
433    let (n, p) = x.shape();
434    (0..p)
435        .map(|k| {
436            let mut s = 0.0;
437            for i in 0..n {
438                s += x[(i, k)] * y[i];
439            }
440            s
441        })
442        .collect()
443}
444
445/// Solve Ax = b via Cholesky decomposition (A must be symmetric positive definite).
446pub(super) fn cholesky_solve(a: &[f64], b: &[f64], p: usize) -> Result<Vec<f64>, FdarError> {
447    linalg_cholesky_solve(a, b, p)
448}
449
450/// Compute hat matrix diagonal: H_ii = x_i' (X'X)^{-1} x_i, given Cholesky factor L of X'X.
451pub(crate) fn compute_hat_diagonal(x: &FdMatrix, l: &[f64]) -> Vec<f64> {
452    let (n, p) = x.shape();
453    let mut hat_diag = vec![0.0; n];
454    for i in 0..n {
455        let mut v = vec![0.0; p];
456        for j in 0..p {
457            v[j] = x[(i, j)];
458            for k in 0..j {
459                v[j] -= l[j * p + k] * v[k];
460            }
461            v[j] /= l[j * p + j];
462        }
463        hat_diag[i] = v.iter().map(|vi| vi * vi).sum();
464    }
465    hat_diag
466}
467
468/// Compute diagonal of (X'X)^{-1} given Cholesky factor L, then SE = sqrt(sigma² * diag).
469fn compute_ols_std_errors(l: &[f64], p: usize, sigma2: f64) -> Vec<f64> {
470    let mut se = vec![0.0; p];
471    for j in 0..p {
472        let mut v = vec![0.0; p];
473        v[j] = 1.0;
474        for k in 0..p {
475            for kk in 0..k {
476                v[k] -= l[k * p + kk] * v[kk];
477            }
478            v[k] /= l[k * p + k];
479        }
480        se[j] = (sigma2 * v.iter().map(|vi| vi * vi).sum::<f64>()).sqrt();
481    }
482    se
483}
484
485// ---------------------------------------------------------------------------
486// Design matrix and coefficient recovery
487// ---------------------------------------------------------------------------
488
489/// Build design matrix: \[1, ξ_1, ..., ξ_K, z_1, ..., z_p\].
490/// Validate inputs for fregre_lm / functional_logistic.
491fn validate_fregre_inputs(
492    n: usize,
493    m: usize,
494    y: &[f64],
495    scalar_covariates: Option<&FdMatrix>,
496) -> Result<(), FdarError> {
497    if n < 3 {
498        return Err(FdarError::InvalidDimension {
499            parameter: "data",
500            expected: "at least 3 rows".to_string(),
501            actual: format!("{n}"),
502        });
503    }
504    if m == 0 {
505        return Err(FdarError::InvalidDimension {
506            parameter: "data",
507            expected: "at least 1 column".to_string(),
508            actual: "0".to_string(),
509        });
510    }
511    if y.len() != n {
512        return Err(FdarError::InvalidDimension {
513            parameter: "y",
514            expected: format!("{n}"),
515            actual: format!("{}", y.len()),
516        });
517    }
518    if let Some(sc) = scalar_covariates {
519        if sc.nrows() != n {
520            return Err(FdarError::InvalidDimension {
521                parameter: "scalar_covariates",
522                expected: format!("{n} rows"),
523                actual: format!("{} rows", sc.nrows()),
524            });
525        }
526    }
527    Ok(())
528}
529
530/// Resolve ncomp: auto-select via CV if 0, otherwise clamp.
531fn resolve_ncomp(
532    ncomp: usize,
533    data: &FdMatrix,
534    y: &[f64],
535    scalar_covariates: Option<&FdMatrix>,
536    n: usize,
537    m: usize,
538) -> Result<usize, FdarError> {
539    if ncomp == 0 {
540        let cv = fregre_cv(data, y, scalar_covariates, 1, m.min(n - 1).min(20), 5)?;
541        Ok(cv.optimal_k)
542    } else {
543        Ok(ncomp.min(n - 1).min(m))
544    }
545}
546
547pub(crate) fn build_design_matrix(
548    fpca_scores: &FdMatrix,
549    ncomp: usize,
550    scalar_covariates: Option<&FdMatrix>,
551    n: usize,
552) -> FdMatrix {
553    let p_scalar = scalar_covariates.map_or(0, super::matrix::FdMatrix::ncols);
554    let p_total = 1 + ncomp + p_scalar;
555    let mut design = FdMatrix::zeros(n, p_total);
556    for i in 0..n {
557        design[(i, 0)] = 1.0;
558        for k in 0..ncomp {
559            design[(i, 1 + k)] = fpca_scores[(i, k)];
560        }
561        if let Some(sc) = scalar_covariates {
562            for j in 0..p_scalar {
563                design[(i, 1 + ncomp + j)] = sc[(i, j)];
564            }
565        }
566    }
567    design
568}
569
570/// Recover functional coefficient β(t) = Σ_k γ_k φ_k(t).
571fn recover_beta_t(fpc_coeffs: &[f64], rotation: &FdMatrix, m: usize) -> Vec<f64> {
572    let ncomp = fpc_coeffs.len();
573    let mut beta_t = vec![0.0; m];
574    for k in 0..ncomp {
575        for j in 0..m {
576            beta_t[j] += fpc_coeffs[k] * rotation[(j, k)];
577        }
578    }
579    beta_t
580}
581
582/// Pointwise standard error of β(t) via error propagation through FPCA rotation.
583///
584/// SE[β(t_j)]² = Σ_k φ_k(t_j)² · SE[γ_k]²
585fn compute_beta_se(gamma_se: &[f64], rotation: &FdMatrix, m: usize) -> Vec<f64> {
586    let ncomp = gamma_se.len();
587    let mut beta_se = vec![0.0; m];
588    for j in 0..m {
589        let mut var_j = 0.0;
590        for k in 0..ncomp {
591            var_j += rotation[(j, k)].powi(2) * gamma_se[k].powi(2);
592        }
593        beta_se[j] = var_j.sqrt();
594    }
595    beta_se
596}
597
598/// Compute fitted values ŷ = X β.
599fn compute_fitted(design: &FdMatrix, coeffs: &[f64]) -> Vec<f64> {
600    let (n, p) = design.shape();
601    (0..n)
602        .map(|i| {
603            let mut yhat = 0.0;
604            for j in 0..p {
605                yhat += design[(i, j)] * coeffs[j];
606            }
607            yhat
608        })
609        .collect()
610}
611
612/// Compute R² and adjusted R².
613fn compute_r_squared(y: &[f64], residuals: &[f64], p_total: usize) -> (f64, f64) {
614    let n = y.len();
615    let y_mean = y.iter().sum::<f64>() / n as f64;
616    let ss_tot: f64 = y.iter().map(|&yi| (yi - y_mean).powi(2)).sum();
617    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
618    let r_squared = if ss_tot > 0.0 {
619        1.0 - ss_res / ss_tot
620    } else {
621        0.0
622    };
623    let df_model = (p_total - 1) as f64;
624    let r_squared_adj = if n as f64 - df_model - 1.0 > 0.0 {
625        1.0 - (1.0 - r_squared) * (n as f64 - 1.0) / (n as f64 - df_model - 1.0)
626    } else {
627        r_squared
628    };
629    (r_squared, r_squared_adj)
630}
631
632// ---------------------------------------------------------------------------
633// OLS solver
634// ---------------------------------------------------------------------------
635
636/// Solve ordinary least squares: min ||Xb - y||² via normal equations with Cholesky.
637/// Returns (coefficients, hat_matrix_diagonal) or error if singular.
638fn ols_solve(x: &FdMatrix, y: &[f64]) -> Result<(Vec<f64>, Vec<f64>), FdarError> {
639    let (n, p) = x.shape();
640    if n < p || p == 0 {
641        return Err(FdarError::InvalidDimension {
642            parameter: "design matrix",
643            expected: format!("n >= p and p > 0 (p={p})"),
644            actual: format!("n={n}, p={p}"),
645        });
646    }
647    let xtx = compute_xtx(x);
648    let xty = compute_xty(x, y);
649    let l = cholesky_factor(&xtx, p)?;
650    let b = cholesky_forward_back(&l, &xty, p);
651    let hat_diag = compute_hat_diagonal(x, &l);
652    Ok((b, hat_diag))
653}
654
655/// Sigmoid function: 1 / (1 + exp(-x))
656pub(crate) fn sigmoid(x: f64) -> f64 {
657    if x >= 0.0 {
658        1.0 / (1.0 + (-x).exp())
659    } else {
660        let ex = x.exp();
661        ex / (1.0 + ex)
662    }
663}
664
665// ---------------------------------------------------------------------------
666// Predict methods on result structs
667// ---------------------------------------------------------------------------
668
669impl FregreLmResult {
670    /// Predict new responses. Delegates to [`predict_fregre_lm`].
671    pub fn predict(&self, new_data: &FdMatrix, new_scalar: Option<&FdMatrix>) -> Vec<f64> {
672        predict_fregre_lm(self, new_data, new_scalar)
673    }
674}
675
676impl FregreRobustResult {
677    /// Predict new responses. Delegates to [`predict_fregre_robust`].
678    pub fn predict(&self, new_data: &FdMatrix, new_scalar: Option<&FdMatrix>) -> Vec<f64> {
679        predict_fregre_robust(self, new_data, new_scalar)
680    }
681}
682
683impl FunctionalLogisticResult {
684    /// Predict P(Y=1) for new data. Delegates to [`predict_functional_logistic`].
685    pub fn predict(&self, new_data: &FdMatrix, new_scalar: Option<&FdMatrix>) -> Vec<f64> {
686        predict_functional_logistic(self, new_data, new_scalar)
687    }
688}
689
690impl MultiFregreLmResult {
691    /// Predict new responses. Delegates to [`predict_fregre_lm_multi`].
692    ///
693    /// # Errors
694    ///
695    /// Returns [`FdarError`] if prediction fails due to dimension mismatches.
696    pub fn predict(
697        &self,
698        new_predictors: &[&FdMatrix],
699        new_scalar: Option<&FdMatrix>,
700    ) -> Result<Vec<f64>, FdarError> {
701        predict_fregre_lm_multi(self, new_predictors, new_scalar)
702    }
703}