Skip to main content

fdars_core/inference/
permutation.rs

1//! Permutation-based two-sample functional tests.
2//!
3//! Provides [`t_perm_test`] (integrated L2-of-difference statistic) and
4//! [`f_perm_test`] (integrated F-statistic, the k = 2 case of functional
5//! ANOVA). Both build a permutation null by pooling the two samples and
6//! relabelling group membership via a Fisher–Yates shuffle seeded
7//! deterministically with `StdRng::seed_from_u64(seed)`.
8
9use super::TestResult;
10use crate::error::FdarError;
11use crate::function_on_scalar::integrated_f_statistic;
12use crate::helpers::simpsons_weights;
13use crate::matrix::FdMatrix;
14use rand::rngs::StdRng;
15use rand::SeedableRng;
16
17/// Default number of permutations for the permutation tests.
18pub const DEFAULT_N_PERM: usize = 999;
19
20/// Validate two functional samples share equal, non-zero column counts, that
21/// `argvals` matches that width, and that each sample has at least 2 rows.
22///
23/// Returns `(n_a, n_b, m)` on success.
24fn validate_two_samples(
25    data_a: &FdMatrix,
26    data_b: &FdMatrix,
27    argvals: &[f64],
28) -> Result<(usize, usize, usize), FdarError> {
29    let (n_a, m_a) = data_a.shape();
30    let (n_b, m_b) = data_b.shape();
31    if m_a == 0 || m_b == 0 {
32        return Err(FdarError::InvalidDimension {
33            parameter: "data",
34            expected: "at least 1 column (grid points)".to_string(),
35            actual: format!("data_a has {m_a} columns, data_b has {m_b} columns"),
36        });
37    }
38    if m_a != m_b {
39        return Err(FdarError::InvalidDimension {
40            parameter: "data_b",
41            expected: format!("{m_a} columns (matching data_a)"),
42            actual: format!("{m_b} columns"),
43        });
44    }
45    if argvals.len() != m_a {
46        return Err(FdarError::InvalidDimension {
47            parameter: "argvals",
48            expected: format!("{m_a} elements (matching data columns)"),
49            actual: format!("{} elements", argvals.len()),
50        });
51    }
52    if n_a < 2 || n_b < 2 {
53        return Err(FdarError::InvalidDimension {
54            parameter: "data",
55            expected: "at least 2 rows per sample".to_string(),
56            actual: format!("data_a has {n_a} rows, data_b has {n_b} rows"),
57        });
58    }
59    Ok((n_a, n_b, m_a))
60}
61
62/// Pool two column-major samples into a single `(n_a + n_b) x m` matrix, with
63/// the `n_a` rows of `data_a` first, followed by the `n_b` rows of `data_b`.
64fn pool_two_samples(
65    data_a: &FdMatrix,
66    data_b: &FdMatrix,
67    n_a: usize,
68    n_b: usize,
69    m: usize,
70) -> FdMatrix {
71    let mut pooled = FdMatrix::zeros(n_a + n_b, m);
72    for j in 0..m {
73        for i in 0..n_a {
74            pooled[(i, j)] = data_a[(i, j)];
75        }
76        for i in 0..n_b {
77            pooled[(n_a + i, j)] = data_b[(i, j)];
78        }
79    }
80    pooled
81}
82
83/// Integrated L2 distance between two sample-mean curves.
84///
85/// `sqrt( ∫ (mean_a(t) - mean_b(t))^2 dt )`, integrated with Simpson's weights.
86fn integrated_l2_mean_diff(
87    pooled: &FdMatrix,
88    labels: &[usize],
89    n_a: usize,
90    m: usize,
91    weights: &[f64],
92) -> f64 {
93    let mut mean_a = vec![0.0; m];
94    let mut mean_b = vec![0.0; m];
95    let n_b = labels.len() - n_a;
96    for (i, &lab) in labels.iter().enumerate() {
97        if lab == 0 {
98            for j in 0..m {
99                mean_a[j] += pooled[(i, j)];
100            }
101        } else {
102            for j in 0..m {
103                mean_b[j] += pooled[(i, j)];
104            }
105        }
106    }
107    for j in 0..m {
108        mean_a[j] /= n_a as f64;
109        mean_b[j] /= n_b as f64;
110    }
111    let mut acc = 0.0;
112    for j in 0..m {
113        let d = mean_a[j] - mean_b[j];
114        acc += d * d * weights[j];
115    }
116    acc.sqrt()
117}
118
119/// Fisher–Yates in-place shuffle of `v` using `rng`.
120fn shuffle_labels(v: &mut [usize], rng: &mut StdRng) {
121    use rand::Rng;
122    let n = v.len();
123    for i in (1..n).rev() {
124        let j = rng.gen_range(0..=i);
125        v.swap(i, j);
126    }
127}
128
129/// Functional two-sample permutation *t*-test (`fda::tperm.fd`).
130///
131/// Tests the null hypothesis that `data_a` and `data_b` are drawn from
132/// populations with the same mean curve. The test statistic is the integrated
133/// L2 distance between the two sample-mean curves,
134/// `sqrt( ∫ (mean_a - mean_b)^2 dt )`, integrated with Simpson's weights over
135/// `argvals`. The permutation null pools all `n_a + n_b` curves, relabels group
136/// membership via a Fisher–Yates shuffle, and recomputes the statistic; the
137/// p-value is `(#{perm >= observed} + 1) / (n_perm + 1)`.
138///
139/// # Arguments
140/// * `data_a` - First sample (`n_a x m`).
141/// * `data_b` - Second sample (`n_b x m`).
142/// * `argvals` - Evaluation points (length `m`).
143/// * `n_perm` - Number of permutations (typical default: [`DEFAULT_N_PERM`] = 999).
144/// * `seed` - Deterministic RNG seed (`StdRng::seed_from_u64(seed)`).
145///
146/// # Errors
147///
148/// Returns [`FdarError::InvalidDimension`] if the two samples have unequal or
149/// zero column counts, if `argvals.len()` does not match the column count, or
150/// if either sample has fewer than 2 rows. Returns
151/// [`FdarError::InvalidParameter`] if `n_perm == 0`.
152pub fn t_perm_test(
153    data_a: &FdMatrix,
154    data_b: &FdMatrix,
155    argvals: &[f64],
156    n_perm: usize,
157    seed: u64,
158) -> Result<TestResult, FdarError> {
159    let (n_a, n_b, m) = validate_two_samples(data_a, data_b, argvals)?;
160    if n_perm == 0 {
161        return Err(FdarError::InvalidParameter {
162            parameter: "n_perm",
163            message: "must be >= 1".to_string(),
164        });
165    }
166
167    let weights = simpsons_weights(argvals);
168    let pooled = pool_two_samples(data_a, data_b, n_a, n_b, m);
169
170    let mut labels: Vec<usize> = (0..(n_a + n_b)).map(|i| usize::from(i >= n_a)).collect();
171    let observed = integrated_l2_mean_diff(&pooled, &labels, n_a, m, &weights);
172
173    let mut rng = StdRng::seed_from_u64(seed);
174    let mut n_ge = 0usize;
175    for _ in 0..n_perm {
176        shuffle_labels(&mut labels, &mut rng);
177        let perm_stat = integrated_l2_mean_diff(&pooled, &labels, n_a, m, &weights);
178        if perm_stat >= observed {
179            n_ge += 1;
180        }
181    }
182
183    let p_value = (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0);
184    Ok(TestResult {
185        statistic: observed,
186        p_value,
187        n_perm,
188    })
189}
190
191/// Functional two-sample permutation *F*-test (`fda::Fperm.fd`).
192///
193/// The k = 2 case of functional ANOVA: assembles a two-group problem from
194/// `data_a` (label 0) and `data_b` (label 1) and computes the integrated
195/// F-statistic via the shared `integrated_f_statistic` core (the same core used
196/// by [`crate::function_on_scalar::fanova`]). The permutation null relabels the
197/// pooled group membership via a seeded Fisher–Yates shuffle; the p-value is
198/// `(#{perm >= observed} + 1) / (n_perm + 1)`.
199///
200/// # Arguments
201/// * `data_a` - First sample (`n_a x m`).
202/// * `data_b` - Second sample (`n_b x m`).
203/// * `argvals` - Evaluation points (length `m`), used only for input validation
204///   (the integrated F-statistic is a mean over grid points).
205/// * `n_perm` - Number of permutations (typical default: [`DEFAULT_N_PERM`] = 999).
206/// * `seed` - Deterministic RNG seed (`StdRng::seed_from_u64(seed)`).
207///
208/// # Errors
209///
210/// Returns [`FdarError::InvalidDimension`] if the two samples have unequal or
211/// zero column counts, if `argvals.len()` does not match the column count, or
212/// if either sample has fewer than 2 rows. Returns
213/// [`FdarError::InvalidParameter`] if `n_perm == 0`.
214pub fn f_perm_test(
215    data_a: &FdMatrix,
216    data_b: &FdMatrix,
217    argvals: &[f64],
218    n_perm: usize,
219    seed: u64,
220) -> Result<TestResult, FdarError> {
221    let (n_a, n_b, m) = validate_two_samples(data_a, data_b, argvals)?;
222    if n_perm == 0 {
223        return Err(FdarError::InvalidParameter {
224            parameter: "n_perm",
225            message: "must be >= 1".to_string(),
226        });
227    }
228
229    let pooled = pool_two_samples(data_a, data_b, n_a, n_b, m);
230    let labels_dedup = [0usize, 1usize];
231
232    // Group vector: 0 for the first n_a rows, 1 for the next n_b.
233    let mut groups: Vec<usize> = (0..(n_a + n_b)).map(|i| usize::from(i >= n_a)).collect();
234    let observed = integrated_f_statistic(&pooled, &groups, &labels_dedup);
235
236    let mut rng = StdRng::seed_from_u64(seed);
237    let mut n_ge = 0usize;
238    for _ in 0..n_perm {
239        shuffle_labels(&mut groups, &mut rng);
240        let perm_stat = integrated_f_statistic(&pooled, &groups, &labels_dedup);
241        if perm_stat >= observed {
242            n_ge += 1;
243        }
244    }
245
246    let p_value = (n_ge as f64 + 1.0) / (n_perm as f64 + 1.0);
247    Ok(TestResult {
248        statistic: observed,
249        p_value,
250        n_perm,
251    })
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::test_helpers::uniform_grid;
258
259    /// Deterministic sample: `n` curves of width `m` = `argvals.len()`, each a
260    /// smooth base curve plus a per-row/per-column perturbation, shifted by
261    /// `shift`.
262    fn make_sample(n: usize, argvals: &[f64], shift: f64, seed: u64) -> FdMatrix {
263        let m = argvals.len();
264        let mut mat = FdMatrix::zeros(n, m);
265        // Simple deterministic pseudo-random generator (LCG) for reproducible noise.
266        let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
267        for i in 0..n {
268            for (j, &t) in argvals.iter().enumerate() {
269                state = state
270                    .wrapping_mul(6_364_136_223_846_793_005)
271                    .wrapping_add(1_442_695_040_888_963_407);
272                let noise = ((state >> 33) as f64 / (1u64 << 31) as f64) - 1.0; // ~[-1, 1)
273                mat[(i, j)] = (2.0 * std::f64::consts::PI * t).sin() + 0.1 * noise + shift;
274            }
275        }
276        mat
277    }
278
279    #[test]
280    fn t_perm_separated_small_p() {
281        let argvals = uniform_grid(25);
282        let a = make_sample(15, &argvals, 0.0, 1);
283        let b = make_sample(15, &argvals, 5.0, 2); // large constant shift
284        let res = t_perm_test(&a, &b, &argvals, 199, 42).unwrap();
285        assert!(
286            res.p_value < 0.05,
287            "separated samples should give small p, got {}",
288            res.p_value
289        );
290    }
291
292    #[test]
293    fn t_perm_null_large_p() {
294        let argvals = uniform_grid(25);
295        let a = make_sample(15, &argvals, 0.0, 10);
296        let b = make_sample(15, &argvals, 0.0, 20); // same generator, no shift
297        let res = t_perm_test(&a, &b, &argvals, 199, 7).unwrap();
298        assert!(
299            res.p_value > 0.1,
300            "null samples should give large p, got {}",
301            res.p_value
302        );
303    }
304
305    #[test]
306    fn t_perm_deterministic() {
307        let argvals = uniform_grid(20);
308        let a = make_sample(10, &argvals, 0.0, 3);
309        let b = make_sample(12, &argvals, 1.0, 4);
310        let r1 = t_perm_test(&a, &b, &argvals, 99, 123).unwrap();
311        let r2 = t_perm_test(&a, &b, &argvals, 99, 123).unwrap();
312        assert_eq!(r1, r2, "same seed must give bit-identical result");
313    }
314
315    #[test]
316    fn t_perm_invalid_input() {
317        let argvals = uniform_grid(20);
318        let a = make_sample(10, &argvals, 0.0, 5);
319        // Mismatched column counts.
320        let argvals_b = uniform_grid(15);
321        let b = make_sample(10, &argvals_b, 0.0, 6);
322        assert!(matches!(
323            t_perm_test(&a, &b, &argvals, 99, 1),
324            Err(FdarError::InvalidDimension { .. })
325        ));
326        // n_perm = 0 rejected.
327        let b2 = make_sample(10, &argvals, 0.0, 7);
328        assert!(matches!(
329            t_perm_test(&a, &b2, &argvals, 0, 1),
330            Err(FdarError::InvalidParameter { .. })
331        ));
332        // Too few rows.
333        let a_small = make_sample(1, &argvals, 0.0, 8);
334        assert!(matches!(
335            t_perm_test(&a_small, &b2, &argvals, 99, 1),
336            Err(FdarError::InvalidDimension { .. })
337        ));
338    }
339
340    #[test]
341    fn f_perm_separated_small_p() {
342        let argvals = uniform_grid(25);
343        let a = make_sample(15, &argvals, 0.0, 11);
344        let b = make_sample(15, &argvals, 5.0, 12);
345        let res = f_perm_test(&a, &b, &argvals, 199, 42).unwrap();
346        assert!(
347            res.p_value < 0.05,
348            "separated samples should give small p, got {}",
349            res.p_value
350        );
351    }
352
353    #[test]
354    fn f_perm_null_large_p() {
355        let argvals = uniform_grid(25);
356        let a = make_sample(15, &argvals, 0.0, 30);
357        let b = make_sample(15, &argvals, 0.0, 40);
358        let res = f_perm_test(&a, &b, &argvals, 199, 7).unwrap();
359        assert!(
360            res.p_value > 0.1,
361            "null samples should give large p, got {}",
362            res.p_value
363        );
364    }
365
366    #[test]
367    fn f_perm_deterministic() {
368        let argvals = uniform_grid(20);
369        let a = make_sample(10, &argvals, 0.0, 3);
370        let b = make_sample(12, &argvals, 1.0, 4);
371        let r1 = f_perm_test(&a, &b, &argvals, 99, 555).unwrap();
372        let r2 = f_perm_test(&a, &b, &argvals, 99, 555).unwrap();
373        assert_eq!(r1, r2);
374    }
375
376    #[test]
377    fn f_perm_agrees_with_fanova_decision() {
378        use crate::function_on_scalar::fanova;
379        let argvals = uniform_grid(25);
380        let a = make_sample(15, &argvals, 0.0, 111);
381        let b = make_sample(15, &argvals, 5.0, 112);
382        // Stack as a 2-group fanova problem.
383        let n_a = 15usize;
384        let n_b = 15usize;
385        let m = argvals.len();
386        let mut pooled = FdMatrix::zeros(n_a + n_b, m);
387        for j in 0..m {
388            for i in 0..n_a {
389                pooled[(i, j)] = a[(i, j)];
390            }
391            for i in 0..n_b {
392                pooled[(n_a + i, j)] = b[(i, j)];
393            }
394        }
395        let groups: Vec<usize> = (0..(n_a + n_b)).map(|i| usize::from(i >= n_a)).collect();
396        let fa = fanova(&pooled, &groups, 199).unwrap();
397        let fp = f_perm_test(&a, &b, &argvals, 199, 42).unwrap();
398        // Both should reject at 0.05.
399        assert!(fa.p_value < 0.05);
400        assert!(fp.p_value < 0.05);
401    }
402
403    #[test]
404    fn f_perm_invalid_input() {
405        let argvals = uniform_grid(20);
406        let a = make_sample(10, &argvals, 0.0, 5);
407        let b2 = make_sample(10, &argvals, 0.0, 7);
408        assert!(matches!(
409            f_perm_test(&a, &b2, &argvals, 0, 1),
410            Err(FdarError::InvalidParameter { .. })
411        ));
412        let a_small = make_sample(1, &argvals, 0.0, 8);
413        assert!(matches!(
414            f_perm_test(&a_small, &b2, &argvals, 99, 1),
415            Err(FdarError::InvalidDimension { .. })
416        ));
417    }
418}