Skip to main content

gam_terms/inference/
smooth_test.rs

1//! Wood-style smooth-component Wald tests.
2//!
3//! The test follows the rank-truncated covariance inverse used by Wood (2013):
4//! the penalized part of the coefficient block is tested with a pseudo-inverse
5//! of rank approximately equal to the term EDF, while unpenalized null-space
6//! coefficients are kept full-rank. The reference degrees of freedom use the
7//! coefficient-space influence block `F_jj = (H⁻¹ X'WX)_jj`.
8//!
9//! Bartlett and Lawley mean corrections are likelihood-ratio corrections, so
10//! they are not applied here. In the ordinary unpenalized Gaussian model the
11//! Wald statistic satisfies `T / q ~ F(q, ν)` exactly, while under a ridge
12//! penalty even the one-parameter statistic becomes `(n / (n + λ))χ²₁` rather
13//! than a central χ²/F reference target.
14
15use gam_linalg::faer_ndarray::FaerEigh;
16use ndarray::{Array1, Array2, ArrayView1, s};
17use statrs::distribution::{ChiSquared, ContinuousCDF, FisherSnedecor};
18use std::ops::Range;
19
20/// Whether the residual dispersion `φ` is known or estimated from the
21/// fit.  Selects the reference distribution for the Wald p-value: `Known`
22/// → `χ²_{ref_df}` (e.g. binomial/Poisson), `Estimated` → `F_{ref_df,
23/// residual_df}` (e.g. Gaussian where `φ̂` carries its own sampling
24/// variability).
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SmoothTestScale {
27    Known,
28    Estimated,
29}
30
31/// Inputs to `wood_smooth_test`. `beta` is the full coefficient vector;
32/// the term block being tested is `beta[coeff_range]`. `covariance` is the
33/// matching posterior covariance Σ̂ (full p×p; the diagonal block is sliced
34/// out). **`covariance` must be the scale-included posterior covariance**
35/// (mgcv `Vb`/`Vp`, i.e. `H⁻¹` already multiplied by the dispersion `φ̂`),
36/// so the Wald statistic `T = β̂'·Σ̂⁻·β̂` is dimensionless — the residual
37/// dispersion has already been divided out and the F-statistic is `T/ref_df`
38/// with *no* further `φ̂` factor. `influence_matrix` is the optional
39/// coefficient-space influence `F = H⁻¹ X'WX`; when present
40/// `tr(F_jj)² / tr(F_jj²)` is used as the Wood-corrected reference d.f.
41/// `edf` is the smooth's effective d.f. (rank truncation for the penalized
42/// subblock); `nullspace_dim` is the fixed-effect (unpenalized) leading
43/// dimension within the block. `residual_df` is the denominator d.f. for the
44/// `Estimated`-scale F branch.
45#[derive(Debug, Clone)]
46pub struct SmoothTestInput<'a> {
47    pub beta: ArrayView1<'a, f64>,
48    pub covariance: &'a Array2<f64>,
49    pub influence_matrix: Option<&'a Array2<f64>>,
50    pub coeff_range: Range<usize>,
51    pub edf: f64,
52    pub nullspace_dim: usize,
53    pub residual_df: f64,
54    pub scale: SmoothTestScale,
55}
56
57/// Output of `wood_smooth_test`: the Wald statistic
58/// `T = β̂'·Σ̂⁻ᵣ·β̂` (rank-`r` pseudo-inverse on the penalized subblock,
59/// full-rank on the nullspace subblock), the reference d.f. used to compute
60/// the tail probability, and the resulting `p_value` (clamped to `[0,1]`).
61#[derive(Debug, Clone)]
62pub struct SmoothTestResult {
63    pub statistic: f64,
64    pub ref_df: f64,
65    pub p_value: f64,
66}
67
68/// Wood (2013) rank-truncated Wald smooth-component test.
69///
70/// Splits `beta[coeff_range]` into a leading `nullspace_dim` unpenalized
71/// block (tested at full rank) and a trailing penalized block (tested at
72/// rank `round(edf − nullspace_dim)` via the spectral truncated
73/// pseudo-inverse of the matching Σ̂ subblock). The combined statistic
74/// `T` is compared against `χ²_{ref_df}` when the scale is `Known`, or
75/// `F = T/ref_df` against `F_{ref_df, residual_df}` when `Estimated`.
76///
77/// Because `covariance` is the scale-included posterior covariance, `T`
78/// already has the dispersion `φ̂` divided out (it is a proper Wald χ²);
79/// the estimated-scale F-statistic is therefore `T/ref_df` with no extra
80/// `φ̂` factor. Dividing by `φ̂` a second time — the historical defect
81/// fixed in issue #675 — makes the p-value scale as `1/φ̂` and so depend on
82/// the units of the response. Returns `None` on degenerate inputs (empty
83/// block, non-finite EDF, non-finite stat, or non-positive residual d.f.
84/// in the F branch).
85pub fn wood_smooth_test(input: SmoothTestInput<'_>) -> Option<SmoothTestResult> {
86    let start = input.coeff_range.start;
87    let end = input.coeff_range.end;
88    if start >= end
89        || end > input.beta.len()
90        || end > input.covariance.nrows()
91        || end > input.covariance.ncols()
92        || !input.edf.is_finite()
93        || input.edf <= 0.0
94    {
95        return None;
96    }
97    let k = end - start;
98    let beta = input.beta.slice(s![start..end]).to_owned();
99    let cov = block(input.covariance, start, end)?;
100    let null_dim = input.nullspace_dim.min(k);
101    let pen_dim = k.saturating_sub(null_dim);
102
103    let mut statistic = 0.0;
104    // Effective rank of the statistic: the number of covariance directions
105    // actually summed across the null and penalized sub-blocks. The χ²/F
106    // reference d.f. is floored at this count so a boundary-shrunk term (whose
107    // Wood influence-trace d.f. collapses toward 0) is never judged against a
108    // degenerate ~0-d.f. reference — the mechanism that turned a *zero* Wald
109    // statistic into p≈0 for a term the fit removed (#1360).
110    let mut rank_used = 0usize;
111    if null_dim > 0 {
112        let beta_null = beta.slice(s![0..null_dim]).to_owned();
113        let cov_null = cov.slice(s![0..null_dim, 0..null_dim]).to_owned();
114        let (q, used) = full_rank_quadratic(&beta_null, &cov_null)?;
115        statistic += q;
116        rank_used += used;
117    }
118    if pen_dim > 0 {
119        let beta_pen = beta.slice(s![null_dim..k]).to_owned();
120        let cov_pen = cov.slice(s![null_dim..k, null_dim..k]).to_owned();
121        let rank = truncated_rank(input.edf - null_dim as f64, pen_dim);
122        if rank > 0 {
123            let (q, used) = truncated_quadratic(&beta_pen, &cov_pen, rank)?;
124            statistic += q;
125            rank_used += used;
126        }
127    }
128
129    if rank_used == 0 {
130        // No estimable direction in the block (every covariance eigenmode is
131        // numerically null): the term carries no testable signal.
132        return None;
133    }
134    // Wood (2013) influence-trace participation d.f. when available, but never
135    // below `rank_used`. The historical fallback to `edf` collapsed to ~0 for a
136    // shrunk term, making `χ²_{ref_df→0}` degenerate.
137    let ref_df = match reference_df(input.influence_matrix, start, end) {
138        Some(rd) if rd.is_finite() && rd > 0.0 => rd.max(rank_used as f64),
139        _ => rank_used as f64,
140    };
141    if !statistic.is_finite() || statistic < 0.0 || !ref_df.is_finite() || ref_df <= 0.0 {
142        return None;
143    }
144    let p_value = match input.scale {
145        SmoothTestScale::Known => {
146            let dist = ChiSquared::new(ref_df).ok()?;
147            1.0 - dist.cdf(statistic)
148        }
149        SmoothTestScale::Estimated => {
150            if !input.residual_df.is_finite() || input.residual_df <= 0.0 {
151                return None;
152            }
153            // `statistic` is already a dispersion-free Wald χ² (the covariance
154            // is scale-included), so the estimated-scale F-statistic is the
155            // χ² divided by its reference d.f. only — mgcv's `Tr/rank`. Dividing
156            // by `φ̂` again would re-introduce a response-unit dependence (#675).
157            let f_stat = statistic / ref_df;
158            let dist = FisherSnedecor::new(ref_df, input.residual_df).ok()?;
159            1.0 - dist.cdf(f_stat)
160        }
161    };
162    if !p_value.is_finite() {
163        return None;
164    }
165    Some(SmoothTestResult {
166        statistic,
167        ref_df,
168        p_value: p_value.clamp(0.0, 1.0),
169    })
170}
171
172fn truncated_rank(edf_pen: f64, pen_dim: usize) -> usize {
173    if pen_dim == 0 || !edf_pen.is_finite() || edf_pen <= 0.0 {
174        return 0;
175    }
176    (edf_pen.round() as usize).clamp(1, pen_dim)
177}
178
179fn block(matrix: &Array2<f64>, start: usize, end: usize) -> Option<Array2<f64>> {
180    if start >= end || end > matrix.nrows() || end > matrix.ncols() {
181        return None;
182    }
183    Some(matrix.slice(s![start..end, start..end]).to_owned())
184}
185
186fn full_rank_quadratic(beta: &Array1<f64>, cov: &Array2<f64>) -> Option<(f64, usize)> {
187    truncated_quadratic(beta, cov, beta.len())
188}
189
190/// Returns the rank-`rank` truncated Wald quadratic together with the number of
191/// covariance directions (eigenmodes above the relative tolerance) that were
192/// actually summed into it. The `used` count is the *effective rank of the
193/// statistic*: it can fall below `rank` when the covariance subblock is itself
194/// rank-deficient. Callers fold it into the χ² reference degrees of freedom so
195/// the tail probability is never evaluated against a degenerate ~0 d.f.
196fn truncated_quadratic(beta: &Array1<f64>, cov: &Array2<f64>, rank: usize) -> Option<(f64, usize)> {
197    if beta.is_empty() || cov.nrows() != beta.len() || cov.ncols() != beta.len() || rank == 0 {
198        return None;
199    }
200    let (evals, evecs) = cov.to_owned().eigh(faer::Side::Lower).ok()?;
201    let mut order: Vec<usize> = (0..evals.len()).collect();
202    order.sort_by(|&a, &b| evals[b].total_cmp(&evals[a]));
203    let tol = evals
204        .iter()
205        .copied()
206        .fold(0.0_f64, |acc, v| acc.max(v.abs()))
207        * 1e-10;
208    let mut q = 0.0;
209    let mut used = 0usize;
210    for idx in order {
211        let lambda = evals[idx];
212        if lambda <= tol {
213            continue;
214        }
215        let v = evecs.column(idx);
216        let proj = beta.dot(&v);
217        q += proj * proj / lambda;
218        used += 1;
219        if used >= rank {
220            break;
221        }
222    }
223    (used > 0 && q.is_finite()).then_some((q.max(0.0), used))
224}
225
226fn reference_df(influence: Option<&Array2<f64>>, start: usize, end: usize) -> Option<f64> {
227    let f = influence?;
228    let f_block = block(f, start, end)?;
229    let tr = (0..f_block.nrows()).map(|i| f_block[[i, i]]).sum::<f64>();
230    let tr2 = f_block.dot(&f_block).diag().sum();
231    if tr.is_finite() && tr2.is_finite() && tr > 0.0 && tr2 > 0.0 {
232        Some((tr * tr / tr2).max(1e-12))
233    } else {
234        None
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use ndarray::array;
242    use statrs::distribution::{ChiSquared, ContinuousCDF};
243
244    #[test]
245    fn reference_df_uses_trace_correction() {
246        let beta = array![1.0, 2.0];
247        let cov = array![[2.0, 0.0], [0.0, 3.0]];
248        let f = array![[0.5, 0.0], [0.0, 0.25]];
249        let out = wood_smooth_test(SmoothTestInput {
250            beta: beta.view(),
251            covariance: &cov,
252            influence_matrix: Some(&f),
253            coeff_range: 0..2,
254            edf: 1.0,
255            nullspace_dim: 0,
256            residual_df: 20.0,
257            scale: SmoothTestScale::Known,
258        })
259        .expect("smooth test");
260        assert!((out.ref_df - 1.8).abs() < 1e-12);
261        assert!(out.statistic > 0.0);
262        assert!((0.0..=1.0).contains(&out.p_value));
263    }
264
265    #[test]
266    fn known_scale_branch_reports_plain_wald_chi_square() {
267        let beta = array![1.0, 2.0];
268        let cov = array![[2.0, 0.0], [0.0, 3.0]];
269        let f = array![[0.5, 0.0], [0.0, 0.25]];
270        let out = wood_smooth_test(SmoothTestInput {
271            beta: beta.view(),
272            covariance: &cov,
273            influence_matrix: Some(&f),
274            coeff_range: 0..2,
275            edf: 1.0,
276            nullspace_dim: 0,
277            residual_df: 20.0,
278            scale: SmoothTestScale::Known,
279        })
280        .expect("smooth test");
281
282        let dist = ChiSquared::new(out.ref_df).expect("chi-square");
283        let expected = 1.0 - dist.cdf(out.statistic);
284        assert!((out.p_value - expected).abs() < 1e-15);
285    }
286
287    /// Rescaling the response by `c` is `β → c·β`, `Σ → c²·Σ` (the covariance
288    /// is scale-included). The Wald statistic `T = β'Σ⁻β` is then invariant,
289    /// and — because the estimated-scale F-statistic is `T/ref_df` with no
290    /// further `φ̂` factor — so is the p-value. This is the unit-level guard
291    /// for issue #675: the historical `T/(ref_df·φ̂)` made the p-value scale
292    /// as `1/c²` even though `T` did not move.
293    #[test]
294    fn estimated_scale_pvalue_is_response_unit_invariant() {
295        let beta = array![2.5, -3.5, 1.8];
296        let cov = array![[2.0, 0.3, 0.0], [0.3, 1.5, 0.1], [0.0, 0.1, 0.9]];
297        let f = array![[0.7, 0.0, 0.0], [0.0, 0.6, 0.0], [0.0, 0.0, 0.4]];
298
299        let run = |c: f64| {
300            let beta_c = &beta * c;
301            let cov_c = &cov * (c * c);
302            wood_smooth_test(SmoothTestInput {
303                beta: beta_c.view(),
304                covariance: &cov_c,
305                influence_matrix: Some(&f),
306                coeff_range: 0..3,
307                edf: 2.0,
308                nullspace_dim: 0,
309                residual_df: 50.0,
310                scale: SmoothTestScale::Estimated,
311            })
312            .expect("smooth test")
313        };
314
315        let base = run(1.0);
316        assert!(base.statistic > 0.0);
317        // A non-trivial, clearly-significant p-value so the invariance check is
318        // not vacuously comparing two values pinned at a boundary.
319        assert!(base.p_value > 0.0 && base.p_value < 0.05);
320        for c in [1e-3, 0.1, 10.0, 1e3, 1e6] {
321            let scaled = run(c);
322            let rel_stat = (scaled.statistic - base.statistic).abs() / base.statistic;
323            assert!(
324                rel_stat < 1e-9,
325                "Wald statistic not scale-invariant at c={c}: {} vs {}",
326                scaled.statistic,
327                base.statistic
328            );
329            let rel_p = (scaled.p_value - base.p_value).abs() / base.p_value;
330            assert!(
331                rel_p < 1e-9,
332                "estimated-scale p-value not scale-invariant at c={c}: {} vs {}",
333                scaled.p_value,
334                base.p_value
335            );
336        }
337    }
338
339    /// A term the fit drove to the penalty boundary (coefficients ≈ 0, EDF → 0)
340    /// must read as *not* significant. The defect (#1360): the reference d.f.
341    /// fell back to `edf` and collapsed toward 0, so the χ² tail of a *zero*
342    /// statistic evaluated at ~0 d.f. degenerated to p ≈ 0 — an overwhelming
343    /// false positive for a term that was removed. The reference d.f. is now
344    /// floored at the rank actually summed (≥ 1), so a zero statistic returns
345    /// p ≈ 1.
346    #[test]
347    fn boundary_shrunk_term_is_not_significant() {
348        // Near-zero coefficients with a well-conditioned (non-degenerate)
349        // covariance: the Wald statistic is ~0 regardless of how the reference
350        // d.f. is formed.
351        let beta = array![1e-9, -2e-9, 5e-10];
352        let cov = array![[0.04, 0.0, 0.0], [0.0, 0.05, 0.0], [0.0, 0.0, 0.06]];
353        // A degenerate influence block (sign-flipped near-zero leverages) so the
354        // Wood trace correction is unavailable and the fallback is exercised.
355        let f = array![[1e-9, 0.0, 0.0], [0.0, -1e-9, 0.0], [0.0, 0.0, 1e-12]];
356        for scale in [SmoothTestScale::Known, SmoothTestScale::Estimated] {
357            let out = wood_smooth_test(SmoothTestInput {
358                beta: beta.view(),
359                covariance: &cov,
360                influence_matrix: Some(&f),
361                coeff_range: 0..3,
362                edf: 1e-6,
363                nullspace_dim: 0,
364                residual_df: 500.0,
365                scale,
366            })
367            .expect("boundary term still produces a result");
368            assert!(
369                out.ref_df >= 1.0,
370                "reference d.f. must not collapse below the tested rank: {}",
371                out.ref_df
372            );
373            assert!(
374                out.statistic < 1e-6,
375                "boundary statistic should be ~0: {}",
376                out.statistic
377            );
378            assert!(
379                out.p_value > 0.5,
380                "shrunk boundary term must not be significant (p={}, scale={:?})",
381                out.p_value,
382                scale
383            );
384        }
385    }
386
387    /// Flooring the reference d.f. at the tested rank must not weaken a genuinely
388    /// significant term: a large statistic with a healthy influence block keeps
389    /// its small p-value (the floor only raises a *degenerate* sub-1 d.f.).
390    #[test]
391    fn floor_does_not_blunt_a_real_signal() {
392        let beta = array![6.0, -5.0];
393        let cov = array![[1.0, 0.0], [0.0, 1.0]];
394        let f = array![[0.9, 0.0], [0.0, 0.9]];
395        let out = wood_smooth_test(SmoothTestInput {
396            beta: beta.view(),
397            covariance: &cov,
398            influence_matrix: Some(&f),
399            coeff_range: 0..2,
400            edf: 2.0,
401            nullspace_dim: 2,
402            residual_df: 500.0,
403            scale: SmoothTestScale::Known,
404        })
405        .expect("smooth test");
406        assert!(out.statistic > 40.0, "statistic={}", out.statistic);
407        assert!(
408            out.p_value < 1e-6,
409            "a strong term must stay significant: p={}",
410            out.p_value
411        );
412    }
413}