Skip to main content

fdars_core/scalar_on_function/
glm.rs

1//! Functional Generalized Linear Model (GLM) over FPC scores.
2//!
3//! Implements `functional_glm` — a scalar-on-function GLM that covers the four
4//! mainstream exponential-family distributions through a [`GlmFamily`] enum
5//! (canonical link + variance function per family).  The IRLS loop runs over
6//! Functional Principal Component (FPC) scores produced by [`fdata_to_pc_1d`],
7//! reusing the same weighted-normal-equations solver as [`functional_logistic`].
8//!
9//! # Supported families and canonical links
10//!
11//! | Family | Link g(μ) | Variance V(μ) |
12//! |--------|-----------|--------------|
13//! | [`GlmFamily::Binomial`]  | logit     | μ(1−μ)       |
14//! | [`GlmFamily::Poisson`]   | log       | μ             |
15//! | [`GlmFamily::Gamma`]     | inverse   | μ²            |
16//! | [`GlmFamily::Gaussian`]  | identity  | 1             |
17//!
18//! # IRLS convergence
19//!
20//! The loop converges when the absolute change in deviance between consecutive
21//! iterations is below `tol`, or when `max_iter` is reached.  Gaussian with
22//! identity link converges in a single IRLS step (weights ≡ 1 → OLS).
23//!
24//! # Convention divergences from R `glm()`
25//!
26//! - **Convergence criterion:** deviance-change `< tol`, not coefficient-change
27//!   (more scale-invariant; recommended for multi-family code).
28//! - **Canonical links only:** Gamma uses inverse link (g(μ)=1/μ), NOT log-link.
29//! - **AIC/BIC:** computed as `−2·log_likelihood + 2p` and `−2·log_likelihood + p·ln(n)`
30//!   using the log-likelihood kernel per family. The dispersion φ is **not** folded into
31//!   the Gamma/Gaussian AIC/BIC log-likelihood kernel, so Gamma and Gaussian AIC magnitudes
32//!   are **not** directly comparable to R's `glm()` / `lm()` output.
33//! - **Standard errors:** the dispersion φ (φ = 1 for Binomial/Poisson; Pearson χ²/dof for
34//!   Gaussian/Gamma) IS applied to the reported coefficient standard errors:
35//!   `Var(β̂) = φ·(XᵀWX)⁻¹`.
36//! - **μ/η clamping:** Poisson clamps η ≤ 500 before `exp`; Gamma clamps η ≥ 1e-10 so
37//!   μ = 1/η remains finite; all families clamp μ ≥ 1e-10 in weight/deviance computations.
38//! - **Gamma intercept initialisation:** β₀ = 1/mean(y) so η₀ > 0 (μ₀ = mean(y)), preventing
39//!   a divide-by-zero on the very first IRLS step.
40//!
41//! [`functional_logistic`]: crate::scalar_on_function::functional_logistic
42
43use super::{
44    build_design_matrix, cholesky_factor, cholesky_solve, compute_beta_se, compute_fitted,
45    compute_ols_std_errors, recover_beta_t, sigmoid, FunctionalGlmResult, GlmFamily,
46};
47use crate::error::FdarError;
48use crate::matrix::FdMatrix;
49use crate::regression::{fdata_to_pc_1d, FpcaResult};
50
51// ---------------------------------------------------------------------------
52// GlmFamily methods — per-family link / variance / deviance / log-likelihood
53// ---------------------------------------------------------------------------
54
55impl GlmFamily {
56    /// Inverse link function: η → μ = g⁻¹(η), clamped to a valid range.
57    pub(crate) fn inv_link(self, eta: f64) -> f64 {
58        match self {
59            GlmFamily::Binomial => sigmoid(eta),
60            GlmFamily::Poisson => eta.min(500.0_f64).exp().max(1e-10),
61            GlmFamily::Gamma => (1.0 / eta.max(1e-10)).max(1e-10),
62            GlmFamily::Gaussian => eta,
63        }
64    }
65
66    /// Link derivative: dη/dμ = g′(μ).
67    ///
68    /// **Stored separately from [`irls_weight`].**  The working response
69    /// `z = η + (y − μ) · g′(μ)` must use this value directly — never derive
70    /// the working response from `1/weight`.  For Gamma, g′(μ) = −1/μ² is
71    /// **negative**, which is required for IRLS to converge.
72    pub(crate) fn link_deriv(self, mu: f64) -> f64 {
73        match self {
74            GlmFamily::Binomial => 1.0 / (mu * (1.0 - mu)).max(1e-10),
75            GlmFamily::Poisson => 1.0 / mu.max(1e-10),
76            GlmFamily::Gamma => -1.0 / mu.max(1e-10).powi(2), // NEGATIVE — do not confuse with irls_weight
77            GlmFamily::Gaussian => 1.0,
78        }
79    }
80
81    /// IRLS weight: w_i = (dμ/dη)² / V(μ) = 1 / (V(μ) · g′(μ)²).
82    ///
83    /// For canonical links this simplifies to:
84    /// Binomial = μ(1−μ), Poisson = μ, Gamma = μ², Gaussian = 1.
85    ///
86    /// **Gamma derivation (inverse link):**
87    /// - dμ/dη = −μ² (from μ = 1/η → dμ/dη = −1/η² = −μ²)
88    /// - V(μ) = μ²
89    /// - w = (dμ/dη)² / V(μ) = μ⁴ / μ² = μ²
90    pub(crate) fn irls_weight(self, mu: f64) -> f64 {
91        match self {
92            GlmFamily::Binomial => (mu * (1.0 - mu)).max(1e-10),
93            GlmFamily::Poisson => mu.max(1e-10),
94            // w = μ² (NOT 1/μ²) — see derivation in doc comment above
95            GlmFamily::Gamma => mu.max(1e-10).powi(2),
96            GlmFamily::Gaussian => 1.0,
97        }
98    }
99
100    /// Total deviance D = 2 Σ d(y_i, μ_i).
101    ///
102    /// Uses the `0·log(0) = 0` convention (Pitfall 4 in RESEARCH.md) via the
103    /// private `xlogy` helper.
104    pub(crate) fn deviance(self, y: &[f64], mu: &[f64]) -> f64 {
105        fn xlogy(x: f64, y: f64) -> f64 {
106            if x == 0.0 {
107                0.0
108            } else {
109                x * y.ln()
110            }
111        }
112        y.iter()
113            .zip(mu)
114            .map(|(&yi, &mi)| match self {
115                GlmFamily::Binomial => {
116                    2.0 * (xlogy(yi, yi / mi.max(1e-15))
117                        + xlogy(1.0 - yi, (1.0 - yi) / (1.0 - mi).max(1e-15)))
118                }
119                GlmFamily::Poisson => 2.0 * (xlogy(yi, yi / mi.max(1e-15)) - (yi - mi)),
120                GlmFamily::Gamma => 2.0 * ((yi - mi) / mi.max(1e-15) - (yi / mi.max(1e-15)).ln()),
121                GlmFamily::Gaussian => (yi - mi).powi(2),
122            })
123            .sum()
124    }
125
126    /// Log-likelihood kernel sufficient for AIC/BIC (excludes normalising constants).
127    ///
128    /// Note: for Gamma and Gaussian the dispersion parameter φ is not estimated
129    /// separately.  See module-level documentation for the resulting AIC
130    /// comparability caveat.
131    ///
132    /// For the Poisson family, `log(y!) = ln Γ(y+1)` is computed via the private
133    /// [`ln_gamma`] Lanczos helper — O(1) and overflow-free (the earlier
134    /// `Σ_{k=1}^{y} ln(k)` form was O(y) and, on a saturating `y as u64`, unbounded).
135    pub(crate) fn log_likelihood(self, y: &[f64], mu: &[f64]) -> f64 {
136        y.iter()
137            .zip(mu)
138            .map(|(&yi, &mi)| match self {
139                GlmFamily::Binomial => {
140                    let mi = mi.clamp(1e-15, 1.0 - 1e-15);
141                    yi * mi.ln() + (1.0 - yi) * (1.0 - mi).ln()
142                }
143                GlmFamily::Poisson => {
144                    let mi = mi.max(1e-300);
145                    // log(y!) = ln Γ(y+1) — O(1), overflow-free (yi is a validated
146                    // finite non-negative integer, so yi + 1.0 >= 1.0).
147                    let ln_y_fact = ln_gamma(yi + 1.0);
148                    yi * mi.ln() - mi - ln_y_fact
149                }
150                GlmFamily::Gamma => {
151                    let mi = mi.max(1e-300);
152                    -yi / mi - mi.ln()
153                }
154                GlmFamily::Gaussian => {
155                    // Kernel only: −(y−μ)² (scale by −1/(2σ²) for absolute LL)
156                    -(yi - mi).powi(2)
157                }
158            })
159            .sum()
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Response-domain validation
165// ---------------------------------------------------------------------------
166
167fn validate_response(y: &[f64], family: GlmFamily) -> Result<(), FdarError> {
168    // Reject non-finite responses for ALL families FIRST. IEEE 754 makes
169    // `NaN <= 0.0` false and `f64::INFINITY.floor() == f64::INFINITY`, so a
170    // non-finite value would otherwise slip past the per-family guards below —
171    // producing an all-NaN result (Gamma NaN) or, for Poisson, a `yi as u64`
172    // saturation to u64::MAX driving an unbounded log-factorial loop.
173    if let Some(&bad) = y.iter().find(|v| !v.is_finite()) {
174        return Err(FdarError::InvalidParameter {
175            parameter: "y",
176            message: format!("response contains a non-finite value ({bad})"),
177        });
178    }
179    match family {
180        GlmFamily::Binomial => {
181            if y.iter().any(|&yi| yi != 0.0 && yi != 1.0) {
182                return Err(FdarError::InvalidParameter {
183                    parameter: "y",
184                    message: "all values must be 0.0 or 1.0 for Binomial family".to_string(),
185                });
186            }
187        }
188        GlmFamily::Poisson => {
189            if y.iter().any(|&yi| yi < 0.0 || yi != yi.floor()) {
190                return Err(FdarError::InvalidParameter {
191                    parameter: "y",
192                    message: "all values must be non-negative integers for Poisson family"
193                        .to_string(),
194                });
195            }
196        }
197        GlmFamily::Gamma => {
198            if y.iter().any(|&yi| yi <= 0.0) {
199                return Err(FdarError::InvalidParameter {
200                    parameter: "y",
201                    message: "all values must be strictly positive for Gamma family".to_string(),
202                });
203            }
204        }
205        GlmFamily::Gaussian => {} // unrestricted
206    }
207    Ok(())
208}
209
210/// Natural log of the Gamma function via the Lanczos approximation (g = 7, n = 9).
211///
212/// Used for the Poisson `log(y!) = ln Γ(y+1)` term. O(1) and overflow-free — it
213/// replaces an O(y) running `Σ ln(k)` sum, and adds no crate dependency (statrs
214/// is not vendored). Accurate to ~15 significant digits for the arguments used
215/// here (`x = y + 1 >= 1`); the reflection branch covers `x < 0.5` for completeness.
216fn ln_gamma(x: f64) -> f64 {
217    const G: f64 = 7.0;
218    const C: [f64; 9] = [
219        0.999_999_999_999_809_9,
220        676.520_368_121_885_1,
221        -1_259.139_216_722_402_8,
222        771.323_428_777_653_1,
223        -176.615_029_162_140_6,
224        12.507_343_278_686_905,
225        -0.138_571_095_265_720_12,
226        9.984_369_578_019_572e-6,
227        1.505_632_735_149_311_6e-7,
228    ];
229    if x < 0.5 {
230        // Reflection: ln Γ(x) = ln(π / sin(πx)) − ln Γ(1 − x)
231        std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().abs().ln() - ln_gamma(1.0 - x)
232    } else {
233        let x = x - 1.0;
234        let mut a = C[0];
235        let t = x + G + 0.5;
236        for (i, &c) in C.iter().enumerate().skip(1) {
237            a += c / (x + i as f64);
238        }
239        0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Generic IRLS step
245// ---------------------------------------------------------------------------
246
247/// One IRLS step: compute working response and solve weighted normal equations.
248/// Returns updated beta or `None` if the system is singular.
249fn irls_step_glm(
250    design: &FdMatrix,
251    y: &[f64],
252    beta: &[f64],
253    family: GlmFamily,
254) -> Option<Vec<f64>> {
255    let (n, p) = design.shape();
256
257    // Linear predictor η = Xβ
258    let eta: Vec<f64> = (0..n)
259        .map(|i| (0..p).map(|j| design[(i, j)] * beta[j]).sum())
260        .collect();
261
262    // μ = g⁻¹(η), w = IRLS weight, z = working response
263    let mu: Vec<f64> = eta.iter().map(|&e| family.inv_link(e)).collect();
264    let w: Vec<f64> = mu.iter().map(|&m| family.irls_weight(m)).collect();
265    // z_i = η_i + (y_i − μ_i) · g′(μ_i)   [MUST use link_deriv, NOT 1/weight]
266    let z: Vec<f64> = (0..n)
267        .map(|i| eta[i] + (y[i] - mu[i]) * family.link_deriv(mu[i]))
268        .collect();
269
270    // Weighted normal equations: (X′WX)β = X′Wz
271    let mut xtwx = vec![0.0; p * p];
272    for k in 0..p {
273        for j in k..p {
274            let s: f64 = (0..n).map(|i| design[(i, k)] * w[i] * design[(i, j)]).sum();
275            xtwx[k * p + j] = s;
276            xtwx[j * p + k] = s;
277        }
278    }
279    let xtwz: Vec<f64> = (0..p)
280        .map(|k| (0..n).map(|i| design[(i, k)] * w[i] * z[i]).sum())
281        .collect();
282
283    cholesky_solve(&xtwx, &xtwz, p).ok()
284}
285
286// ---------------------------------------------------------------------------
287// IRLS loop
288// ---------------------------------------------------------------------------
289
290/// Run IRLS until deviance-change < tol or max_iter is reached.
291/// Returns (beta, iterations).
292fn irls_loop_glm(
293    design: &FdMatrix,
294    y: &[f64],
295    family: GlmFamily,
296    max_iter: usize,
297    tol: f64,
298) -> (Vec<f64>, usize) {
299    let p_total = design.ncols();
300    let mut beta = init_beta(p_total, y, family);
301    let mut iterations = 0;
302
303    // Initial deviance
304    let mu_init: Vec<f64> = {
305        let (n, p) = design.shape();
306        (0..n)
307            .map(|i| {
308                let eta: f64 = (0..p).map(|j| design[(i, j)] * beta[j]).sum();
309                family.inv_link(eta)
310            })
311            .collect()
312    };
313    let mut dev_old = family.deviance(y, &mu_init);
314
315    for iter in 0..max_iter {
316        iterations = iter + 1;
317        let Some(beta_new) = irls_step_glm(design, y, &beta, family) else {
318            break;
319        };
320        // Compute new deviance
321        let (n, p) = design.shape();
322        let mu_new: Vec<f64> = (0..n)
323            .map(|i| {
324                let eta: f64 = (0..p).map(|j| design[(i, j)] * beta_new[j]).sum();
325                family.inv_link(eta)
326            })
327            .collect();
328        let dev_new = family.deviance(y, &mu_new);
329        beta = beta_new;
330        if (dev_new - dev_old).abs() < tol {
331            break;
332        }
333        dev_old = dev_new;
334    }
335    (beta, iterations)
336}
337
338/// Initialise β for the IRLS loop.
339///
340/// Zero-initialisation works for Binomial (η=0 → μ=0.5), Poisson (η=0 → μ=1),
341/// and Gaussian.  For Gamma, zero β → η=0 → μ=1/0 = ∞ on the first step, so
342/// the intercept is initialised to 1/mean(y) so that η₀ > 0 and μ₀ = mean(y).
343fn init_beta(p: usize, y: &[f64], family: GlmFamily) -> Vec<f64> {
344    let mut beta = vec![0.0_f64; p];
345    if let GlmFamily::Gamma = family {
346        let mean_y = y.iter().sum::<f64>() / y.len() as f64;
347        beta[0] = 1.0 / mean_y.max(1e-10);
348    }
349    beta
350}
351
352// ---------------------------------------------------------------------------
353// Result assembly
354// ---------------------------------------------------------------------------
355
356fn build_glm_result(
357    design: &FdMatrix,
358    beta: Vec<f64>,
359    y: &[f64],
360    fpca: FpcaResult,
361    ncomp: usize,
362    m: usize,
363    iterations: usize,
364    family: GlmFamily,
365) -> FunctionalGlmResult {
366    let (n, p) = design.shape();
367    let linear_predictors = compute_fitted(design, &beta);
368    let fitted_values: Vec<f64> = linear_predictors
369        .iter()
370        .map(|&e| family.inv_link(e))
371        .collect();
372
373    let beta_t = recover_beta_t(&beta[1..=ncomp], &fpca.rotation, m);
374    let gamma: Vec<f64> = beta[1 + ncomp..].to_vec();
375
376    // SE from Fisher information matrix (X′WX)⁻¹ evaluated at converged β
377    let w_final: Vec<f64> = fitted_values
378        .iter()
379        .map(|&mu| family.irls_weight(mu))
380        .collect();
381    let mut xtwx = vec![0.0; p * p];
382    for k in 0..p {
383        for j in k..p {
384            let s: f64 = (0..n)
385                .map(|i| design[(i, k)] * w_final[i] * design[(i, j)])
386                .sum();
387            xtwx[k * p + j] = s;
388            xtwx[j * p + k] = s;
389        }
390    }
391    // Dispersion φ scales the coefficient covariance: Var(β̂) = φ·(XᵀWX)⁻¹.
392    // φ = 1 for Binomial/Poisson (fixed by the family); for Gaussian/Gamma it is
393    // estimated by the Pearson χ² statistic over residual dof, so the reported
394    // standard errors are not systematically too small.
395    let dispersion = match family {
396        GlmFamily::Binomial | GlmFamily::Poisson => 1.0,
397        GlmFamily::Gaussian => {
398            let dof = n.saturating_sub(p).max(1) as f64;
399            let rss: f64 = y
400                .iter()
401                .zip(&fitted_values)
402                .map(|(&yi, &mi)| (yi - mi).powi(2))
403                .sum();
404            rss / dof
405        }
406        GlmFamily::Gamma => {
407            let dof = n.saturating_sub(p).max(1) as f64;
408            // Pearson χ² with V(μ) = μ²: Σ ((yᵢ − μᵢ)/μᵢ)²
409            let chi2: f64 = y
410                .iter()
411                .zip(&fitted_values)
412                .map(|(&yi, &mi)| ((yi - mi) / mi.max(1e-10)).powi(2))
413                .sum();
414            chi2 / dof
415        }
416    };
417    let std_errors = cholesky_factor(&xtwx, p).map_or_else(
418        |_| vec![f64::NAN; p],
419        |l| compute_ols_std_errors(&l, p, dispersion),
420    );
421    let beta_se = compute_beta_se(&std_errors[1..=ncomp], &fpca.rotation, m);
422
423    let ll = family.log_likelihood(y, &fitted_values);
424    let deviance = family.deviance(y, &fitted_values);
425    let nf = n as f64;
426    let pf = p as f64;
427    let aic = -2.0 * ll + 2.0 * pf;
428    let bic = -2.0 * ll + nf.ln() * pf;
429
430    FunctionalGlmResult {
431        intercept: beta[0],
432        beta_t,
433        beta_se,
434        gamma,
435        fitted_values,
436        linear_predictors,
437        ncomp,
438        coefficients: beta,
439        std_errors,
440        log_likelihood: ll,
441        deviance,
442        iterations,
443        fpca,
444        aic,
445        bic,
446        family,
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Public API
452// ---------------------------------------------------------------------------
453
454/// Fit a functional GLM for a scalar response over a functional predictor.
455///
456/// Models: g(E[Y | X]) = α + ∫β(t)X(t)dt + γᵀz
457///
458/// via IRLS (iteratively reweighted least squares) on FPC scores, where g is
459/// the canonical link function for the chosen [`GlmFamily`].
460///
461/// # Arguments
462///
463/// * `data` — Functional predictor matrix (n × m, column-major `FdMatrix`)
464/// * `y` — Scalar response vector (length n); must satisfy the family's domain
465///   constraint (Binomial: {0,1}; Poisson: non-negative integers; Gamma: > 0)
466/// * `family` — Exponential-family distribution; determines link and variance
467/// * `scalar_covariates` — Optional scalar covariate matrix (n × p)
468/// * `ncomp` — Number of FPC components (clamped to min(n−1, m))
469/// * `max_iter` — Maximum IRLS iterations (pass 0 for default of 25)
470/// * `tol` — Deviance-change convergence tolerance (pass ≤ 0.0 for default 1e-6)
471///
472/// # Returns
473///
474/// A [`FunctionalGlmResult`] containing: intercept, functional coefficient
475/// β(t), FPC score coefficients γ, fitted values μ = g⁻¹(η), linear
476/// predictors η, deviance, log-likelihood, AIC, BIC, standard errors, and
477/// the embedded [`FpcaResult`] for projecting new data.
478///
479/// # Errors
480///
481/// Returns [`FdarError::InvalidDimension`] if:
482/// - `data` has fewer than 3 rows or zero columns
483/// - `y.len() != n`
484/// - `scalar_covariates` is provided but its row count differs from `n`
485///
486/// Returns [`FdarError::InvalidParameter`] if any response value violates the
487/// family's domain constraint (Binomial y ∉ {0,1}; Poisson y < 0 or non-integer;
488/// Gamma y ≤ 0).
489///
490/// Returns [`FdarError::ComputationFailed`] if the SVD inside FPCA fails.
491///
492/// # Examples
493///
494/// ```
495/// use fdars_core::matrix::FdMatrix;
496/// use fdars_core::scalar_on_function::{functional_glm, GlmFamily};
497///
498/// let data = FdMatrix::from_column_major(
499///     (0..600).map(|i| (i as f64 * 0.05).sin()).collect(),
500///     20, 30,
501/// ).unwrap();
502/// let y: Vec<f64> = (0..20).map(|i| (i as f64) * 0.4).collect();
503/// let fit = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6).unwrap();
504/// assert_eq!(fit.fitted_values.len(), 20);
505/// assert_eq!(fit.beta_t.len(), 30);
506/// assert!(fit.iterations >= 1);
507/// ```
508#[must_use = "expensive computation whose result should not be discarded"]
509pub fn functional_glm(
510    data: &FdMatrix,
511    y: &[f64],
512    family: GlmFamily,
513    scalar_covariates: Option<&FdMatrix>,
514    ncomp: usize,
515    max_iter: usize,
516    tol: f64,
517) -> Result<FunctionalGlmResult, FdarError> {
518    let (n, m) = data.shape();
519
520    // --- Dimension checks (fire before FPCA) ---
521    if n < 3 {
522        return Err(FdarError::InvalidDimension {
523            parameter: "data",
524            expected: "at least 3 rows".to_string(),
525            actual: format!("{n}"),
526        });
527    }
528    if m == 0 {
529        return Err(FdarError::InvalidDimension {
530            parameter: "data",
531            expected: "at least 1 column".to_string(),
532            actual: "0".to_string(),
533        });
534    }
535    if y.len() != n {
536        return Err(FdarError::InvalidDimension {
537            parameter: "y",
538            expected: format!("{n}"),
539            actual: format!("{}", y.len()),
540        });
541    }
542    if let Some(sc) = scalar_covariates {
543        let sc_rows = sc.shape().0;
544        if sc_rows != n {
545            return Err(FdarError::InvalidDimension {
546                parameter: "scalar_covariates",
547                expected: format!("{n} rows (matching data)"),
548                actual: format!("{sc_rows}"),
549            });
550        }
551    }
552
553    // --- Response-domain guard (fires before FPCA) ---
554    validate_response(y, family)?;
555
556    let ncomp = ncomp.min(n - 1).min(m);
557    let argvals: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1).max(1) as f64).collect();
558    let fpca = fdata_to_pc_1d(data, ncomp, &argvals)?;
559    let design = build_design_matrix(&fpca.scores, ncomp, scalar_covariates, n);
560
561    let max_iter = if max_iter == 0 { 25 } else { max_iter };
562    let tol = if tol <= 0.0 { 1e-6 } else { tol };
563
564    let (beta, iterations) = irls_loop_glm(&design, y, family, max_iter, tol);
565    Ok(build_glm_result(
566        &design, beta, y, fpca, ncomp, m, iterations, family,
567    ))
568}
569
570/// Predict response for new functional data using a fitted GLM.
571///
572/// Projects new curves through the stored FPCA, computes the linear predictor,
573/// and applies the inverse link: μ = g⁻¹(η).
574///
575/// # Arguments
576///
577/// * `fit` — A fitted [`FunctionalGlmResult`]
578/// * `new_data` — New functional predictor matrix (n_new × m), where `m` MUST
579///   equal the training grid length
580/// * `new_scalar` — Optional new scalar covariates (n_new × p)
581///
582/// # Errors
583///
584/// Returns [`FdarError::InvalidDimension`] if `new_data`'s column count differs
585/// from the training grid length, or if `new_scalar`'s shape does not match the
586/// fitted model's scalar-covariate count (or is missing when the model has
587/// scalar covariates). This prevents out-of-bounds indexing / silent truncation.
588pub fn predict_functional_glm(
589    fit: &FunctionalGlmResult,
590    new_data: &FdMatrix,
591    new_scalar: Option<&FdMatrix>,
592) -> Result<Vec<f64>, FdarError> {
593    let (n_new, m) = new_data.shape();
594    let ncomp = fit.ncomp;
595    let p_scalar = fit.gamma.len();
596    let m_train = fit.fpca.mean.len();
597
598    if m != m_train {
599        return Err(FdarError::InvalidDimension {
600            parameter: "new_data",
601            expected: format!("{m_train} columns (training grid length)"),
602            actual: format!("{m}"),
603        });
604    }
605    match new_scalar {
606        Some(sc) => {
607            let (sc_rows, sc_cols) = sc.shape();
608            if sc_rows != n_new {
609                return Err(FdarError::InvalidDimension {
610                    parameter: "new_scalar",
611                    expected: format!("{n_new} rows (matching new_data)"),
612                    actual: format!("{sc_rows}"),
613                });
614            }
615            if sc_cols != p_scalar {
616                return Err(FdarError::InvalidDimension {
617                    parameter: "new_scalar",
618                    expected: format!("{p_scalar} columns (model scalar covariates)"),
619                    actual: format!("{sc_cols}"),
620                });
621            }
622        }
623        None if p_scalar > 0 => {
624            return Err(FdarError::InvalidDimension {
625                parameter: "new_scalar",
626                expected: format!("{p_scalar} columns (model was fit with scalar covariates)"),
627                actual: "None".to_string(),
628            });
629        }
630        None => {}
631    }
632
633    Ok((0..n_new)
634        .map(|i| {
635            let mut eta = fit.coefficients[0]; // intercept
636            for k in 0..ncomp {
637                let mut s = 0.0;
638                for j in 0..m {
639                    s += (new_data[(i, j)] - fit.fpca.mean[j])
640                        * fit.fpca.rotation[(j, k)]
641                        * fit.fpca.weights[j];
642                }
643                eta += fit.coefficients[1 + k] * s;
644            }
645            if let Some(sc) = new_scalar {
646                for j in 0..p_scalar {
647                    eta += fit.gamma[j] * sc[(i, j)];
648                }
649            }
650            fit.family.inv_link(eta)
651        })
652        .collect())
653}
654
655// ---------------------------------------------------------------------------
656// Inline tests
657// ---------------------------------------------------------------------------
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use crate::scalar_on_function::functional_logistic;
663
664    fn make_data(n: usize, m: usize) -> FdMatrix {
665        FdMatrix::from_column_major(
666            (0..n * m)
667                .map(|i| ((i as f64) * 0.07).sin() + 0.01 * (i as f64))
668                .collect(),
669            n,
670            m,
671        )
672        .unwrap()
673    }
674
675    // ------------------------------------------------------------------
676    // Task 1: Gaussian tracer smoke test
677    // ------------------------------------------------------------------
678
679    #[test]
680    fn test_gaussian_smoke() {
681        let n = 30;
682        let m = 40;
683        let data = make_data(n, m);
684        let y: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
685
686        let fit = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6).unwrap();
687
688        assert_eq!(fit.fitted_values.len(), n, "fitted_values len");
689        assert_eq!(fit.beta_t.len(), m, "beta_t len");
690        assert!(fit.iterations >= 1, "at least one iteration");
691        assert!(
692            fit.fitted_values.iter().all(|v| v.is_finite()),
693            "all fitted_values finite"
694        );
695    }
696
697    // ------------------------------------------------------------------
698    // Task 2: Binomial parity with functional_logistic
699    // ------------------------------------------------------------------
700
701    #[test]
702    fn test_binomial_parity_with_logistic() {
703        let n = 30;
704        let m = 50;
705        let data = make_data(n, m);
706        // Binary labels: first half 0, second half 1
707        let y_bin: Vec<f64> = (0..n).map(|i| if i < n / 2 { 0.0 } else { 1.0 }).collect();
708
709        // functional_logistic stops on max-coefficient-change while functional_glm
710        // stops on deviance-change. The per-step IRLS update is identical, so with a
711        // tight tol and ample iterations BOTH fully converge to the same fixed point
712        // — making the parity comparison deterministic (not criterion-timing dependent).
713        let fit_logistic = functional_logistic(&data, &y_bin, None, 3, 100, 1e-12).unwrap();
714        let fit_glm =
715            functional_glm(&data, &y_bin, GlmFamily::Binomial, None, 3, 100, 1e-12).unwrap();
716
717        // Coefficient parity
718        for (i, (a, b)) in fit_logistic
719            .coefficients
720            .iter()
721            .zip(&fit_glm.coefficients)
722            .enumerate()
723        {
724            assert!(
725                (a - b).abs() < 1e-6,
726                "coefficient[{i}] mismatch: logistic={a}, glm={b}"
727            );
728        }
729        // Fitted value (probability) parity
730        for (i, (a, b)) in fit_logistic
731            .probabilities
732            .iter()
733            .zip(&fit_glm.fitted_values)
734            .enumerate()
735        {
736            assert!(
737                (a - b).abs() < 1e-6,
738                "fitted_value[{i}] mismatch: logistic={a}, glm={b}"
739            );
740        }
741    }
742
743    #[test]
744    fn test_binomial_out_of_range_guard() {
745        let n = 10;
746        let m = 20;
747        let data = make_data(n, m);
748        let mut y = vec![0.0f64; n];
749        y[3] = 0.5; // invalid
750
751        let result = functional_glm(&data, &y, GlmFamily::Binomial, None, 3, 25, 1e-6);
752        assert!(
753            matches!(result, Err(FdarError::InvalidParameter { .. })),
754            "expected InvalidParameter for out-of-range Binomial y"
755        );
756    }
757
758    // ------------------------------------------------------------------
759    // Task 3: Poisson recovery
760    // ------------------------------------------------------------------
761
762    /// Build a rich functional dataset with K orthogonal components, each with varying amplitude.
763    ///
764    /// Curve i = Σ_k scores_k[i] * basis_k(t) where basis_k = sin(k*π*t).
765    /// This ensures FPCA picks up K independent components and the design matrix is full-rank.
766    fn make_rich_data(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
767        // Generate 3 components with decorrelated, linearly-spaced scores.
768        // Curve i = s0*sin(πt) + 0.5*s1*sin(2πt) + 0.25*s2*sin(3πt), where
769        // s0, s1, s2 are index-based quasi-random permutations so the 3 FPCA
770        // components are well-separated and X'WX is non-singular.
771        let mut vals = vec![0.0f64; n * m];
772        let mut first_scores = vec![0.0f64; n];
773        for i in 0..n {
774            let s0 = (i as f64 / (n - 1) as f64) * 2.0 - 1.0; // in [-1, 1]
775            let s1 = ((i * 3 % n) as f64 / (n - 1) as f64) * 1.6 - 0.8;
776            let s2 = ((i * 7 % n) as f64 / (n - 1) as f64) * 1.4 - 0.7;
777            first_scores[i] = s0;
778            for j in 0..m {
779                let t = j as f64 / (m - 1) as f64;
780                let b0 = (std::f64::consts::PI * t).sin();
781                let b1 = (2.0 * std::f64::consts::PI * t).sin();
782                let b2 = (3.0 * std::f64::consts::PI * t).sin();
783                vals[i + j * n] = s0 * b0 + 0.5 * s1 * b1 + 0.25 * s2 * b2;
784            }
785        }
786        let data = FdMatrix::from_column_major(vals, n, m).unwrap();
787        (data, first_scores)
788    }
789
790    #[test]
791    fn test_poisson_recovery() {
792        // Deterministic: true Poisson log(mu_i) = 1.0 + 1.5 * first_score_i.
793        // y_i = round(mu_i) (integer counts).
794        let n = 100;
795        let m = 30;
796
797        let (data, first_scores) = make_rich_data(n, m);
798        let true_mu: Vec<f64> = first_scores
799            .iter()
800            .map(|&s| (1.0 + 1.5 * s).exp())
801            .collect();
802        let y: Vec<f64> = true_mu.iter().map(|&mu| mu.round().max(0.0)).collect();
803
804        let fit = functional_glm(&data, &y, GlmFamily::Poisson, None, 3, 100, 1e-6).unwrap();
805
806        assert!(
807            fit.fitted_values.iter().all(|&v| v.is_finite() && v > 0.0),
808            "all fitted_values finite and positive"
809        );
810
811        // Pearson correlation between fit.fitted_values and true_mu
812        let corr = pearson_corr(&fit.fitted_values, &true_mu);
813        assert!(corr > 0.9, "Pearson corr={corr} should be > 0.9");
814    }
815
816    #[test]
817    fn test_gamma_recovery() {
818        // True model: 1/μ_i = 2.0 + 1.0 * first_score_i (Gamma inverse link).
819        // first_scores from make_rich_data are in [-1, 1], so η_i ∈ [1.0, 3.0] > 0,
820        // and μ_i = 1/η_i ∈ [0.33, 1.0] — strictly positive throughout.
821        //
822        // This test verifies that the Gamma GLM with CORRECT IRLS weight (w = μ²)
823        // recovers the true generating mean with Pearson correlation > 0.9.
824        // The 3-component functional data from make_rich_data ensures the FPCA
825        // scores are not trivially aligned with the true score, providing a
826        // meaningful regression test.
827        //
828        // Note: for this noiseless multi-component fixture the IRLS weight choice
829        // (μ² vs 1/μ²) both converge to the same point estimates; the primary
830        // value of the corr > 0.9 assertion is to confirm the overall GLM algorithm
831        // is producing a sensible Gamma fit, not just finite values.
832        let n = 100;
833        let m = 30;
834
835        let (data, first_scores) = make_rich_data(n, m);
836        // η_i = 2.0 + 1.0 * s_i, s_i ∈ [-1, 1] → η_i ∈ [1.0, 3.0] > 0
837        let true_mu: Vec<f64> = first_scores
838            .iter()
839            .map(|&s| 1.0 / (2.0 + 1.0 * s))
840            .collect();
841        let y = true_mu.clone();
842
843        let fit = functional_glm(&data, &y, GlmFamily::Gamma, None, 3, 100, 1e-6).unwrap();
844
845        assert!(
846            fit.fitted_values.iter().all(|&v| v.is_finite() && v > 0.0),
847            "all Gamma fitted_values finite and positive"
848        );
849
850        // Sanity: fitted means correlate with true means
851        let corr = pearson_corr(&fit.fitted_values, &true_mu);
852        assert!(
853            corr > 0.9,
854            "Gamma recovery: Pearson corr={corr:.4} should be > 0.9"
855        );
856    }
857
858    // ------------------------------------------------------------------
859    // Task 3: domain guard tests
860    // ------------------------------------------------------------------
861
862    #[test]
863    fn test_poisson_negative_guard() {
864        let n = 10;
865        let m = 20;
866        let data = make_data(n, m);
867        let mut y = vec![1.0f64; n];
868        y[2] = -1.0;
869
870        let result = functional_glm(&data, &y, GlmFamily::Poisson, None, 3, 25, 1e-6);
871        assert!(
872            matches!(result, Err(FdarError::InvalidParameter { .. })),
873            "expected InvalidParameter for negative Poisson y"
874        );
875    }
876
877    #[test]
878    fn test_poisson_noninteger_guard() {
879        let n = 10;
880        let m = 20;
881        let data = make_data(n, m);
882        let mut y = vec![1.0f64; n];
883        y[5] = 1.5;
884
885        let result = functional_glm(&data, &y, GlmFamily::Poisson, None, 3, 25, 1e-6);
886        assert!(
887            matches!(result, Err(FdarError::InvalidParameter { .. })),
888            "expected InvalidParameter for non-integer Poisson y"
889        );
890    }
891
892    #[test]
893    fn test_gamma_nonpositive_guard() {
894        let n = 10;
895        let m = 20;
896        let data = make_data(n, m);
897        let mut y = vec![1.0f64; n];
898        y[4] = 0.0;
899
900        let result = functional_glm(&data, &y, GlmFamily::Gamma, None, 3, 25, 1e-6);
901        assert!(
902            matches!(result, Err(FdarError::InvalidParameter { .. })),
903            "expected InvalidParameter for non-positive Gamma y"
904        );
905    }
906
907    #[test]
908    fn test_dimension_mismatch_guard() {
909        let n = 10;
910        let m = 20;
911        let data = make_data(n, m);
912        let y = vec![1.0f64; n + 1]; // wrong length
913
914        let result = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6);
915        assert!(
916            matches!(result, Err(FdarError::InvalidDimension { .. })),
917            "expected InvalidDimension for y.len() mismatch"
918        );
919    }
920
921    #[test]
922    fn test_nonfinite_response_guard() {
923        // IN-01 / CR-02a: NaN (Gamma) and +Inf (Poisson) must be rejected rather
924        // than slipping past the per-family guards into an all-NaN result / an
925        // unbounded log-factorial loop.
926        let n = 10;
927        let m = 20;
928        let data = make_data(n, m);
929
930        let mut y_nan = vec![1.0f64; n];
931        y_nan[3] = f64::NAN;
932        assert!(
933            matches!(
934                functional_glm(&data, &y_nan, GlmFamily::Gamma, None, 3, 25, 1e-6),
935                Err(FdarError::InvalidParameter { .. })
936            ),
937            "expected InvalidParameter for NaN Gamma response"
938        );
939
940        let mut y_inf = vec![1.0f64; n];
941        y_inf[5] = f64::INFINITY;
942        assert!(
943            matches!(
944                functional_glm(&data, &y_inf, GlmFamily::Poisson, None, 3, 25, 1e-6),
945                Err(FdarError::InvalidParameter { .. })
946            ),
947            "expected InvalidParameter for +Inf Poisson response"
948        );
949    }
950
951    #[test]
952    fn test_predict_dimension_guard() {
953        // CR-03: predict must reject a new_data grid length that differs from the
954        // training grid instead of panicking / silently truncating.
955        let n = 30;
956        let m = 40;
957        let data = make_data(n, m);
958        let y: Vec<f64> = (0..n).map(|i| (i as f64) * 0.5 + 1.0).collect();
959        let fit = functional_glm(&data, &y, GlmFamily::Gaussian, None, 3, 25, 1e-6).unwrap();
960
961        // Correct grid length succeeds.
962        assert!(predict_functional_glm(&fit, &data, None).is_ok());
963
964        // Wrong grid length → InvalidDimension (no panic).
965        let wrong = make_data(5, m + 3);
966        assert!(
967            matches!(
968                predict_functional_glm(&fit, &wrong, None),
969                Err(FdarError::InvalidDimension { .. })
970            ),
971            "expected InvalidDimension for mismatched predict grid length"
972        );
973    }
974
975    // ------------------------------------------------------------------
976    // Helper: Pearson correlation
977    // ------------------------------------------------------------------
978
979    fn pearson_corr(x: &[f64], y: &[f64]) -> f64 {
980        let n = x.len() as f64;
981        let mx = x.iter().sum::<f64>() / n;
982        let my = y.iter().sum::<f64>() / n;
983        let num: f64 = x
984            .iter()
985            .zip(y)
986            .map(|(&xi, &yi)| (xi - mx) * (yi - my))
987            .sum();
988        let dx: f64 = x.iter().map(|&xi| (xi - mx).powi(2)).sum::<f64>().sqrt();
989        let dy: f64 = y.iter().map(|&yi| (yi - my).powi(2)).sum::<f64>().sqrt();
990        if dx == 0.0 || dy == 0.0 {
991            0.0
992        } else {
993            num / (dx * dy)
994        }
995    }
996}