Skip to main content

fdars_core/inference/
hotelling.rs

1//! FPC-basis Hotelling-T² two-sample mean test.
2//!
3//! [`two_sample_mean_test`] projects both samples onto a shared FPC basis
4//! (fitted on the pooled data via [`crate::regression::fdata_to_pc_1d`]),
5//! forms the Hotelling-T² statistic on the difference of the two group
6//! score-means (reusing [`crate::spm::stats::hotelling_t2`]), and converts it
7//! to a p-value via the asymptotic chi-square(`ncomp`) upper tail.
8
9use super::dist::chi_square_sf;
10use super::TestResult;
11use crate::error::FdarError;
12use crate::matrix::FdMatrix;
13use crate::regression::fdata_to_pc_1d;
14use crate::spm::stats::hotelling_t2;
15
16/// Mean score vector (length ncomp) over the rows of a score matrix.
17fn mean_scores(scores: &FdMatrix) -> Vec<f64> {
18    let (n, ncomp) = scores.shape();
19    let mut mean = vec![0.0; ncomp];
20    for k in 0..ncomp {
21        let mut s = 0.0;
22        for i in 0..n {
23            s += scores[(i, k)];
24        }
25        mean[k] = s / n as f64;
26    }
27    mean
28}
29
30/// Functional two-sample mean-equality test via Hotelling-T² on a shared FPC
31/// basis (`fda.usc`-style mean equality).
32///
33/// Both samples are projected onto a common FPC basis fitted on the pooled
34/// data. The Hotelling-T² statistic is formed on the difference of the two
35/// group score-means, scaled by the effective sample size
36/// `sqrt(n_a · n_b / (n_a + n_b))` so that under the null the statistic is
37/// asymptotically χ²(`ncomp`). The p-value is the χ²(`ncomp`) upper-tail
38/// probability of the observed statistic.
39///
40/// The eigenvalues fed to [`hotelling_t2`] are derived from the pooled FPCA
41/// singular values via `eigenvalue = sv² / (n_pooled − 1)` (the mfpca
42/// convention).
43///
44/// # Arguments
45/// * `data_a` - First sample (`n_a x m`).
46/// * `data_b` - Second sample (`n_b x m`).
47/// * `argvals` - Evaluation points (length `m`).
48/// * `ncomp` - Number of FPC components for the shared basis.
49///
50/// Returns a [`TestResult`] with `n_perm = 0` (non-permutation path).
51///
52/// # Errors
53///
54/// Returns [`FdarError::InvalidDimension`] if the two samples have unequal or
55/// zero column counts, if `argvals.len()` does not match the column count, or
56/// if either sample has fewer than 2 rows. Returns
57/// [`FdarError::InvalidParameter`] if `ncomp < 1`. Propagates errors from
58/// [`fdata_to_pc_1d`] / [`hotelling_t2`].
59pub fn two_sample_mean_test(
60    data_a: &FdMatrix,
61    data_b: &FdMatrix,
62    argvals: &[f64],
63    ncomp: usize,
64) -> Result<TestResult, FdarError> {
65    let (n_a, m_a) = data_a.shape();
66    let (n_b, m_b) = data_b.shape();
67    if m_a == 0 || m_b == 0 {
68        return Err(FdarError::InvalidDimension {
69            parameter: "data",
70            expected: "at least 1 column (grid points)".to_string(),
71            actual: format!("data_a has {m_a} columns, data_b has {m_b} columns"),
72        });
73    }
74    if m_a != m_b {
75        return Err(FdarError::InvalidDimension {
76            parameter: "data_b",
77            expected: format!("{m_a} columns (matching data_a)"),
78            actual: format!("{m_b} columns"),
79        });
80    }
81    if argvals.len() != m_a {
82        return Err(FdarError::InvalidDimension {
83            parameter: "argvals",
84            expected: format!("{m_a} elements (matching data columns)"),
85            actual: format!("{} elements", argvals.len()),
86        });
87    }
88    if n_a < 2 || n_b < 2 {
89        return Err(FdarError::InvalidDimension {
90            parameter: "data",
91            expected: "at least 2 rows per sample".to_string(),
92            actual: format!("data_a has {n_a} rows, data_b has {n_b} rows"),
93        });
94    }
95    if ncomp < 1 {
96        return Err(FdarError::InvalidParameter {
97            parameter: "ncomp",
98            message: format!("ncomp must be >= 1, got {ncomp}"),
99        });
100    }
101
102    // Pool the two samples (data_a rows first, then data_b rows).
103    let n_pooled = n_a + n_b;
104    let m = m_a;
105    let mut pooled = FdMatrix::zeros(n_pooled, m);
106    for j in 0..m {
107        for i in 0..n_a {
108            pooled[(i, j)] = data_a[(i, j)];
109        }
110        for i in 0..n_b {
111            pooled[(n_a + i, j)] = data_b[(i, j)];
112        }
113    }
114
115    // Fit a shared FPC basis on the pooled data.
116    let fpca = fdata_to_pc_1d(&pooled, ncomp, argvals)?;
117    // fdata_to_pc_1d clamps ncomp to min(n, m); use the realized component count.
118    let eff_ncomp = fpca.singular_values.len();
119
120    // Express both samples in the shared coordinate system.
121    let scores_a = fpca.project(data_a)?;
122    let scores_b = fpca.project(data_b)?;
123    let mean_a = mean_scores(&scores_a);
124    let mean_b = mean_scores(&scores_b);
125
126    // Effective sample-size scaling: under H0 the scaled mean-difference has
127    // unit-variance components (in eigenvalue units), giving an asymptotic
128    // χ²(ncomp) statistic.
129    let scale = ((n_a as f64) * (n_b as f64) / (n_pooled as f64)).sqrt();
130    let diff: Vec<f64> = (0..eff_ncomp)
131        .map(|k| scale * (mean_a[k] - mean_b[k]))
132        .collect();
133    let diff_row = FdMatrix::from_column_major(diff, 1, eff_ncomp)?;
134
135    // Eigenvalues from pooled singular values (mfpca convention sv^2/(n-1)).
136    let eigenvalues: Vec<f64> = fpca
137        .singular_values
138        .iter()
139        .map(|&sv| (sv * sv / (n_pooled as f64 - 1.0)).max(1e-15))
140        .collect();
141
142    let t2 = hotelling_t2(&diff_row, &eigenvalues)?[0];
143    let p_value = chi_square_sf(t2, eff_ncomp);
144
145    Ok(TestResult {
146        statistic: t2,
147        p_value,
148        n_perm: 0,
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::test_helpers::uniform_grid;
156
157    fn make_sample(n: usize, argvals: &[f64], shift: f64, seed: u64) -> FdMatrix {
158        let m = argvals.len();
159        let mut mat = FdMatrix::zeros(n, m);
160        let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
161        for i in 0..n {
162            for (j, &t) in argvals.iter().enumerate() {
163                state = state
164                    .wrapping_mul(6_364_136_223_846_793_005)
165                    .wrapping_add(1_442_695_040_888_963_407);
166                let noise = ((state >> 33) as f64 / (1u64 << 31) as f64) - 1.0;
167                mat[(i, j)] = (2.0 * std::f64::consts::PI * t).sin() + 0.2 * noise + shift;
168            }
169        }
170        mat
171    }
172
173    #[test]
174    fn chi_square_sf_sane() {
175        // χ²(1): P(X > 3.841) ≈ 0.05; P(X > 0) = 1.
176        assert!((chi_square_sf(3.8415, 1) - 0.05).abs() < 1e-3);
177        assert!((chi_square_sf(0.0, 3) - 1.0).abs() < 1e-12);
178        // χ²(2): P(X > 5.991) ≈ 0.05.
179        assert!((chi_square_sf(5.9915, 2) - 0.05).abs() < 1e-3);
180        // Monotone decreasing.
181        assert!(chi_square_sf(1.0, 3) > chi_square_sf(5.0, 3));
182    }
183
184    #[test]
185    fn mean_test_differ_rejects() {
186        let argvals = uniform_grid(30);
187        let a = make_sample(30, &argvals, 0.0, 101);
188        let b = make_sample(30, &argvals, 2.0, 102); // clearly different mean
189        let res = two_sample_mean_test(&a, &b, &argvals, 3).unwrap();
190        assert!(
191            res.p_value < 0.05,
192            "differing means should reject, got p={}",
193            res.p_value
194        );
195    }
196
197    #[test]
198    fn mean_test_coincide_fails_to_reject() {
199        let argvals = uniform_grid(30);
200        let a = make_sample(30, &argvals, 0.0, 201);
201        let b = make_sample(30, &argvals, 0.0, 202); // same generator
202        let res = two_sample_mean_test(&a, &b, &argvals, 3).unwrap();
203        assert!(
204            res.p_value > 0.05,
205            "coinciding means should not reject, got p={}",
206            res.p_value
207        );
208    }
209
210    #[test]
211    fn mean_test_invalid_input() {
212        let argvals = uniform_grid(20);
213        let a = make_sample(10, &argvals, 0.0, 5);
214        // ncomp = 0 rejected.
215        let b = make_sample(10, &argvals, 0.0, 6);
216        assert!(matches!(
217            two_sample_mean_test(&a, &b, &argvals, 0),
218            Err(FdarError::InvalidParameter { .. })
219        ));
220        // Mismatched columns.
221        let argvals_b = uniform_grid(15);
222        let b2 = make_sample(10, &argvals_b, 0.0, 7);
223        assert!(matches!(
224            two_sample_mean_test(&a, &b2, &argvals, 3),
225            Err(FdarError::InvalidDimension { .. })
226        ));
227        // Too few rows.
228        let a_small = make_sample(1, &argvals, 0.0, 8);
229        assert!(matches!(
230            two_sample_mean_test(&a_small, &b, &argvals, 3),
231            Err(FdarError::InvalidDimension { .. })
232        ));
233    }
234}