Skip to main content

fdars_core/inference/
flm.rs

1//! Functional-linear-model inference: overall-significance F-test and a
2//! residual-based goodness-of-fit test on a fitted [`FregreLmResult`].
3//!
4//! Both tests read the public fields of a fitted FLM
5//! ([`FregreLmResult::residuals`], [`FregreLmResult::fitted_values`],
6//! [`FregreLmResult::r_squared`], [`FregreLmResult::ncomp`]) and convert the
7//! observed statistic to a p-value via the self-contained F-distribution
8//! survival function in [`super::dist`]. They are additive, `Result`-returning,
9//! and validate their inputs at entry.
10
11use super::dist::f_sf;
12use super::TestResult;
13use crate::error::FdarError;
14use crate::scalar_on_function::FregreLmResult;
15
16/// Overall-significance F-test for a fitted functional linear model.
17///
18/// Tests the null hypothesis H0 that the functional coefficient has no effect —
19/// i.e. the FLM reduces to an intercept-only model. The statistic is the
20/// classical regression F built from the model R²:
21///
22/// ```text
23/// F = (R² / p) / ((1 − R²) / (n − p − 1))
24/// ```
25///
26/// where `p = fit.ncomp` is the number of effective FPC parameters and
27/// `n = fit.residuals.len()` is the sample size. Under H0, F follows an
28/// F(p, n − p − 1) distribution, so the p-value is the F upper-tail
29/// (survival) probability of the observed statistic. A small p-value rejects
30/// H0 in favour of a genuine functional effect.
31///
32/// Returns a [`TestResult`] with `n_perm = 0` (this is an asymptotic /
33/// closed-form test, not a permutation test).
34///
35/// # Errors
36///
37/// Returns [`FdarError::InvalidParameter`] when the fit is degenerate: `ncomp`
38/// is zero, the denominator degrees of freedom `n − p − 1` are non-positive,
39/// or `r_squared` is not finite or is `>= 1.0` (a perfect fit makes the F
40/// statistic ill-defined).
41pub fn flm_f_test(fit: &FregreLmResult) -> Result<TestResult, FdarError> {
42    let p = fit.ncomp;
43    let n = fit.residuals.len();
44    let r2 = fit.r_squared;
45
46    if p == 0 {
47        return Err(FdarError::InvalidParameter {
48            parameter: "fit.ncomp",
49            message: "flm_f_test requires ncomp >= 1 (at least one FPC parameter)".to_string(),
50        });
51    }
52    // Denominator degrees of freedom: n - p - 1 must be positive.
53    if n <= p + 1 {
54        return Err(FdarError::InvalidParameter {
55            parameter: "fit",
56            message: format!(
57                "degenerate degrees of freedom: n - p - 1 = {} - {} - 1 <= 0",
58                n, p
59            ),
60        });
61    }
62    if !r2.is_finite() || r2 >= 1.0 {
63        return Err(FdarError::InvalidParameter {
64            parameter: "fit.r_squared",
65            message: format!(
66                "r_squared must be finite and < 1.0 for a well-defined F statistic, got {r2}"
67            ),
68        });
69    }
70
71    let d1 = p as f64;
72    let d2 = (n - p - 1) as f64;
73    let f_stat = (r2 / d1) / ((1.0 - r2) / d2);
74    let p_value = f_sf(f_stat, d1, d2);
75
76    Ok(TestResult {
77        statistic: f_stat,
78        p_value,
79        n_perm: 0,
80    })
81}
82
83/// Residual-based lack-of-fit (goodness-of-fit) test for a fitted functional
84/// linear model.
85///
86/// # Chosen null method (F-form lack-of-fit)
87///
88/// This test targets the null hypothesis H0 that the linear FLM is
89/// **well specified** — that is, the conditional mean of the response is
90/// captured by the fitted linear-functional relationship, so the residuals
91/// carry no remaining structure associated with the fitted values. Rejection
92/// (small p) is evidence of **lack of fit / mis-specification**.
93///
94/// The statistic is an F-form lack-of-fit test that regresses the fitted
95/// model's residuals on a low-order polynomial expansion of the fitted values
96/// (a Ramsey-RESET-style specification test). If the linear FLM is adequate,
97/// the fitted values explain none of the residual variation and the extra
98/// regressors are jointly insignificant; a strong nonlinearity the linear model
99/// cannot capture leaves curvature in the residual-vs-fitted relationship that
100/// the polynomial terms pick up.
101///
102/// Concretely, with residuals `e_i` and fitted values `ŷ_i`, we fit the
103/// auxiliary regression
104///
105/// ```text
106/// e_i = a0 + a1·ŷ_i + a2·ŷ_i² + a3·ŷ_i³ + u_i
107/// ```
108///
109/// and report the F-statistic for H0: `a1 = a2 = a3 = 0` (the intercept is a
110/// nuisance term, so `q = 3` restrictions). Under H0 the statistic is
111/// F(q, n − q − 1); the p-value is its upper-tail probability, and a small
112/// p-value rejects adequacy of the linear FLM.
113///
114/// Returns a [`TestResult`] with `statistic = F`, the F-tail `p_value`, and
115/// `n_perm = 0`.
116///
117/// # Errors
118///
119/// Returns [`FdarError::InvalidParameter`] when the sample is too small for the
120/// auxiliary regression (`n − q − 1 <= 0` with `q = 3`), when the residual and
121/// fitted-value vectors have mismatched lengths, or when the inputs contain
122/// non-finite values / the fitted values are (numerically) constant so the
123/// polynomial design is rank-deficient.
124pub fn flm_gof_test(fit: &FregreLmResult) -> Result<TestResult, FdarError> {
125    // Number of auxiliary (polynomial) restrictions being tested.
126    const Q: usize = 3;
127
128    let e = &fit.residuals;
129    let yhat = &fit.fitted_values;
130    let n = e.len();
131
132    if yhat.len() != n {
133        return Err(FdarError::InvalidParameter {
134            parameter: "fit",
135            message: format!(
136                "residuals ({}) and fitted_values ({}) must have equal length",
137                n,
138                yhat.len()
139            ),
140        });
141    }
142    // Auxiliary regression has Q + 1 coefficients (intercept + Q powers);
143    // need n - (Q + 1) > 0 residual df, i.e. n > Q + 1.
144    if n <= Q + 1 {
145        return Err(FdarError::InvalidParameter {
146            parameter: "fit",
147            message: format!(
148                "degenerate degrees of freedom for GoF: n = {n} <= {}",
149                Q + 1
150            ),
151        });
152    }
153    if e.iter().chain(yhat.iter()).any(|v| !v.is_finite()) {
154        return Err(FdarError::InvalidParameter {
155            parameter: "fit",
156            message: "residuals / fitted_values contain non-finite values".to_string(),
157        });
158    }
159
160    // Standardize fitted values to a stable scale before forming powers, so the
161    // polynomial design is well-conditioned regardless of the response scale.
162    let mean_yhat = yhat.iter().sum::<f64>() / n as f64;
163    let var_yhat = yhat.iter().map(|&v| (v - mean_yhat).powi(2)).sum::<f64>() / n as f64;
164    if var_yhat <= 1e-30 {
165        return Err(FdarError::InvalidParameter {
166            parameter: "fit.fitted_values",
167            message: "fitted values are (numerically) constant; GoF design is rank-deficient"
168                .to_string(),
169        });
170    }
171    let sd_yhat = var_yhat.sqrt();
172
173    // Design matrix columns: [1, z, z^2, z^3] with z the standardized fitted value.
174    let ncoef = Q + 1;
175    let mut x: Vec<[f64; 4]> = Vec::with_capacity(n);
176    for &yh in yhat {
177        let z = (yh - mean_yhat) / sd_yhat;
178        x.push([1.0, z, z * z, z * z * z]);
179    }
180
181    // Normal equations X'X (ncoef x ncoef) and X'e (ncoef).
182    let mut xtx = [[0.0f64; 4]; 4];
183    let mut xte = [0.0f64; 4];
184    for (row, &ei) in x.iter().zip(e.iter()) {
185        for a in 0..ncoef {
186            xte[a] += row[a] * ei;
187            for b in 0..ncoef {
188                xtx[a][b] += row[a] * row[b];
189            }
190        }
191    }
192
193    // Solve (X'X) coef = X'e via Gaussian elimination with partial pivoting.
194    let coef = match solve_linear(xtx, xte, ncoef) {
195        Some(c) => c,
196        None => {
197            return Err(FdarError::InvalidParameter {
198                parameter: "fit.fitted_values",
199                message: "GoF auxiliary design is singular (rank-deficient fitted values)"
200                    .to_string(),
201            });
202        }
203    };
204
205    // Residual sum of squares of the FULL auxiliary model.
206    let mut rss_full = 0.0;
207    for (row, &ei) in x.iter().zip(e.iter()) {
208        let pred = (0..ncoef).map(|a| coef[a] * row[a]).sum::<f64>();
209        rss_full += (ei - pred).powi(2);
210    }
211
212    // Restricted model: intercept only (a1 = a2 = a3 = 0). Its fitted value is
213    // the mean of e; RSS_restricted = Σ (e_i − ē)².
214    let mean_e = e.iter().sum::<f64>() / n as f64;
215    let rss_restricted = e.iter().map(|&ei| (ei - mean_e).powi(2)).sum::<f64>();
216
217    let df_num = Q as f64; // restrictions
218    let df_den = (n - ncoef) as f64; // n - (Q + 1)
219
220    // Guard against an essentially-zero residual model (perfect auxiliary fit).
221    if rss_full <= 1e-30 || rss_restricted <= 1e-30 {
222        // No residual variation to explain → no evidence of lack of fit.
223        return Ok(TestResult {
224            statistic: 0.0,
225            p_value: 1.0,
226            n_perm: 0,
227        });
228    }
229
230    let f_stat = ((rss_restricted - rss_full) / df_num) / (rss_full / df_den);
231    let f_stat = f_stat.max(0.0);
232    let p_value = f_sf(f_stat, df_num, df_den);
233
234    Ok(TestResult {
235        statistic: f_stat,
236        p_value,
237        n_perm: 0,
238    })
239}
240
241/// Solve an `n x n` linear system `A x = b` (with `n <= 4`) via Gaussian
242/// elimination with partial pivoting. Returns `None` if the matrix is
243/// (numerically) singular.
244fn solve_linear(a: [[f64; 4]; 4], b: [f64; 4], n: usize) -> Option<[f64; 4]> {
245    let mut m = a;
246    let mut rhs = b;
247    for col in 0..n {
248        // Partial pivot.
249        let mut pivot = col;
250        let mut best = m[col][col].abs();
251        for r in (col + 1)..n {
252            if m[r][col].abs() > best {
253                best = m[r][col].abs();
254                pivot = r;
255            }
256        }
257        if best < 1e-12 {
258            return None;
259        }
260        if pivot != col {
261            m.swap(pivot, col);
262            rhs.swap(pivot, col);
263        }
264        let diag = m[col][col];
265        for r in (col + 1)..n {
266            let factor = m[r][col] / diag;
267            for c in col..n {
268                m[r][c] -= factor * m[col][c];
269            }
270            rhs[r] -= factor * rhs[col];
271        }
272    }
273    // Back-substitution.
274    let mut sol = [0.0f64; 4];
275    for i in (0..n).rev() {
276        let mut s = rhs[i];
277        for c in (i + 1)..n {
278            s -= m[i][c] * sol[c];
279        }
280        sol[i] = s / m[i][i];
281    }
282    Some(sol)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::matrix::FdMatrix;
289    use crate::scalar_on_function::fregre_lm;
290    use crate::test_helpers::uniform_grid;
291
292    /// Deterministic pseudo-noise in [-1, 1] from a splitmix-style counter.
293    fn noise(seed: &mut u64) -> f64 {
294        *seed = seed
295            .wrapping_mul(6_364_136_223_846_793_005)
296            .wrapping_add(1_442_695_040_888_963_407);
297        let z = (*seed >> 33) as f64 / (1u64 << 31) as f64; // in [0, 2)
298        z - 1.0
299    }
300
301    /// Build a functional predictor: curve i is a sine of amplitude a_i.
302    fn make_curves(n: usize, argvals: &[f64], amps: &[f64], seed: u64) -> FdMatrix {
303        let m = argvals.len();
304        let mut s = seed;
305        let mut mat = FdMatrix::zeros(n, m);
306        for i in 0..n {
307            for (j, &t) in argvals.iter().enumerate() {
308                let base = amps[i] * (2.0 * std::f64::consts::PI * t).sin();
309                mat[(i, j)] = base + 0.05 * noise(&mut s);
310            }
311        }
312        mat
313    }
314
315    #[test]
316    fn f_test_rejects_genuine_effect() {
317        let argvals = uniform_grid(40);
318        let n = 40;
319        // Amplitudes vary across curves; response is a strong linear function
320        // of the amplitude (the dominant functional signal) + small noise.
321        let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
322        let data = make_curves(n, &argvals, &amps, 7);
323        let mut s = 1234u64;
324        let y: Vec<f64> = (0..n)
325            .map(|i| 3.0 * amps[i] + 0.1 * noise(&mut s))
326            .collect();
327
328        let fit = fregre_lm(&data, &y, None, 3).unwrap();
329        let res = flm_f_test(&fit).unwrap();
330        assert!(
331            res.p_value < 0.05,
332            "genuine functional effect should reject H0, got p={} (F={})",
333            res.p_value,
334            res.statistic
335        );
336    }
337
338    #[test]
339    fn f_test_fails_to_reject_null_effect() {
340        let argvals = uniform_grid(40);
341        let n = 40;
342        // Amplitudes vary, but the response is constructed INDEPENDENTLY of the
343        // functional predictor (deterministic pseudo-noise with a separate
344        // stream), so there is no genuine effect to detect.
345        let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
346        let data = make_curves(n, &argvals, &amps, 71);
347        let mut s = 98765u64;
348        let y: Vec<f64> = (0..n).map(|_| 5.0 + noise(&mut s)).collect();
349
350        let fit = fregre_lm(&data, &y, None, 3).unwrap();
351        let res = flm_f_test(&fit).unwrap();
352        assert!(
353            res.p_value > 0.20,
354            "null-effect fit should fail to reject H0, got p={} (F={}, R²={})",
355            res.p_value,
356            res.statistic,
357            fit.r_squared
358        );
359    }
360
361    #[test]
362    fn f_test_guards_degenerate_df() {
363        // n - p - 1 <= 0: with n = 5 curves and ncomp = 3 -> df_den = 1 ok,
364        // but ncomp = 4 forces n - p - 1 = 0 -> Err.
365        let argvals = uniform_grid(20);
366        let n = 5;
367        let amps: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
368        let data = make_curves(n, &argvals, &amps, 3);
369        let mut s = 5u64;
370        let y: Vec<f64> = (0..n)
371            .map(|i| 2.0 * amps[i] + 0.1 * noise(&mut s))
372            .collect();
373        let fit = fregre_lm(&data, &y, None, 4).unwrap();
374        assert!(
375            matches!(flm_f_test(&fit), Err(FdarError::InvalidParameter { .. })),
376            "degenerate df must return Err"
377        );
378    }
379
380    #[test]
381    fn gof_fails_to_reject_well_specified() {
382        let argvals = uniform_grid(50);
383        let n = 60;
384        let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
385        let data = make_curves(n, &argvals, &amps, 21);
386        // Truly linear relationship + small noise.
387        let mut s = 55u64;
388        let y: Vec<f64> = (0..n)
389            .map(|i| 2.0 * amps[i] + 0.05 * noise(&mut s))
390            .collect();
391        let fit = fregre_lm(&data, &y, None, 3).unwrap();
392        let res = flm_gof_test(&fit).unwrap();
393        assert!(
394            res.p_value > 0.10,
395            "well-specified linear FLM should not be flagged, got p={} (F={})",
396            res.p_value,
397            res.statistic
398        );
399    }
400
401    #[test]
402    fn gof_rejects_mis_specified() {
403        let argvals = uniform_grid(50);
404        let n = 60;
405        let amps: Vec<f64> = (0..n).map(|i| 0.5 + 2.0 * (i as f64) / n as f64).collect();
406        let data = make_curves(n, &argvals, &amps, 22);
407        // Strongly nonlinear (quadratic) relationship the linear FLM cannot
408        // capture -> residual-vs-fitted curvature -> lack of fit.
409        let mut s = 66u64;
410        let y: Vec<f64> = (0..n)
411            .map(|i| 4.0 * amps[i] * amps[i] + 0.05 * noise(&mut s))
412            .collect();
413        let fit = fregre_lm(&data, &y, None, 3).unwrap();
414        let res = flm_gof_test(&fit).unwrap();
415        assert!(
416            res.p_value < 0.05,
417            "mis-specified (nonlinear) FLM should be flagged, got p={} (F={})",
418            res.p_value,
419            res.statistic
420        );
421    }
422
423    #[test]
424    fn gof_guards_degenerate_df() {
425        // n = 4 <= Q + 1 = 4 -> Err.
426        let argvals = uniform_grid(20);
427        let n = 4;
428        let amps: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
429        let data = make_curves(n, &argvals, &amps, 9);
430        let mut s = 4u64;
431        let y: Vec<f64> = (0..n).map(|i| amps[i] + 0.1 * noise(&mut s)).collect();
432        let fit = fregre_lm(&data, &y, None, 2).unwrap();
433        assert!(matches!(
434            flm_gof_test(&fit),
435            Err(FdarError::InvalidParameter { .. })
436        ));
437    }
438}