Skip to main content

fdars_core/inference/
scb.rs

1//! Simultaneous confidence bands (Degras) for the mean and mean difference.
2//!
3//! [`mean_scb`] is a thin inference-facing wrapper over
4//! [`crate::tolerance::scb_mean_degras`]. [`scb_two_sample_test`] builds a
5//! simultaneous confidence band around the *difference* of the two sample-mean
6//! functions using the same Degras multiplier-bootstrap machinery and rejects
7//! the null of equal means when that band excludes zero at any grid point
8//! (`SCBmeanfd`-style two-sample test).
9
10use super::TestResult;
11use crate::error::FdarError;
12use crate::matrix::FdMatrix;
13use crate::tolerance::{scb_mean_degras, MultiplierDistribution, ToleranceBand};
14
15/// Simultaneous confidence band for the mean function (Degras).
16///
17/// Thin inference-facing wrapper over [`crate::tolerance::scb_mean_degras`];
18/// the band math is not reimplemented here. Returns a [`ToleranceBand`] whose
19/// `[lower, upper]` covers the true mean at approximately `confidence`
20/// coverage.
21///
22/// # Arguments
23/// * `data` - Functional data matrix (`n x m`, `n >= 3` for Degras).
24/// * `argvals` - Evaluation points (length `m`).
25/// * `bandwidth` - Kernel bandwidth for local-polynomial smoothing (`> 0`).
26/// * `nb` - Number of bootstrap replicates (`>= 1`).
27/// * `confidence` - Confidence level in `(0, 1)`, e.g. `0.95`.
28/// * `multiplier` - Multiplier distribution (Gaussian or Rademacher).
29///
30/// # Errors
31///
32/// Forwards all validation errors from [`scb_mean_degras`]:
33/// [`FdarError::InvalidDimension`] for `< 3` rows, zero columns, or an
34/// `argvals` length mismatch; [`FdarError::InvalidParameter`] for a
35/// non-positive bandwidth, `nb == 0`, or `confidence` outside `(0, 1)`.
36pub fn mean_scb(
37    data: &FdMatrix,
38    argvals: &[f64],
39    bandwidth: f64,
40    nb: usize,
41    confidence: f64,
42    multiplier: MultiplierDistribution,
43) -> Result<ToleranceBand, FdarError> {
44    scb_mean_degras(data, argvals, bandwidth, nb, confidence, multiplier)
45}
46
47/// Two-sample mean-equality test via a simultaneous confidence band for the
48/// mean difference (`SCBmeanfd`-style).
49///
50/// Forms the paired difference matrix `d[i] = data_a[i] − data_b[i]` (over the
51/// first `min(n_a, n_b)` rows) and reuses the Degras multiplier bootstrap
52/// ([`scb_mean_degras`]) to produce a simultaneous band for the mean
53/// difference. The null of equal means is rejected when that band excludes
54/// zero at any grid point.
55///
56/// The returned [`TestResult`] encodes the decision:
57/// * `statistic` is the maximum standardized excursion of the difference band
58///   from zero, `max_t (|center(t)| / half_width(t))`; it exceeds `1.0` exactly
59///   when the band excludes zero somewhere (i.e. when the null is rejected).
60/// * `p_value` is `0.0` when the null is rejected (band excludes zero) and
61///   `1.0` otherwise — a conservative encoding of the simultaneous-band
62///   decision at the requested `confidence` (there is no finer p-value from a
63///   single band). `n_perm` is `0`.
64///
65/// # Arguments
66/// * `data_a` - First sample (`n_a x m`).
67/// * `data_b` - Second sample (`n_b x m`).
68/// * `argvals` - Evaluation points (length `m`).
69/// * `bandwidth` - Kernel bandwidth (`> 0`).
70/// * `nb` - Number of bootstrap replicates (`>= 1`).
71/// * `confidence` - Confidence level in `(0, 1)`.
72/// * `multiplier` - Multiplier distribution.
73///
74/// # Errors
75///
76/// Returns [`FdarError::InvalidDimension`] if the two samples have unequal or
77/// zero column counts or an `argvals` length mismatch. Forwards
78/// [`scb_mean_degras`] validation errors otherwise (including the `>= 3` rows
79/// requirement on the difference matrix, `bandwidth > 0`, `nb >= 1`,
80/// `confidence in (0, 1)`).
81pub fn scb_two_sample_test(
82    data_a: &FdMatrix,
83    data_b: &FdMatrix,
84    argvals: &[f64],
85    bandwidth: f64,
86    nb: usize,
87    confidence: f64,
88    multiplier: MultiplierDistribution,
89) -> Result<TestResult, FdarError> {
90    let (n_a, m_a) = data_a.shape();
91    let (n_b, m_b) = data_b.shape();
92    if m_a == 0 || m_b == 0 {
93        return Err(FdarError::InvalidDimension {
94            parameter: "data",
95            expected: "at least 1 column (grid points)".to_string(),
96            actual: format!("data_a has {m_a} columns, data_b has {m_b} columns"),
97        });
98    }
99    if m_a != m_b {
100        return Err(FdarError::InvalidDimension {
101            parameter: "data_b",
102            expected: format!("{m_a} columns (matching data_a)"),
103            actual: format!("{m_b} columns"),
104        });
105    }
106    if argvals.len() != m_a {
107        return Err(FdarError::InvalidDimension {
108            parameter: "argvals",
109            expected: format!("{m_a} elements (matching data columns)"),
110            actual: format!("{} elements", argvals.len()),
111        });
112    }
113
114    // Paired difference matrix over the first min(n_a, n_b) rows. The Degras
115    // band on this matrix is a simultaneous confidence band for the mean
116    // difference mean_a - mean_b.
117    let n = n_a.min(n_b);
118    let m = m_a;
119    let mut diff = FdMatrix::zeros(n, m);
120    for j in 0..m {
121        for i in 0..n {
122            diff[(i, j)] = data_a[(i, j)] - data_b[(i, j)];
123        }
124    }
125
126    // Delegates remaining validation (>= 3 rows, bandwidth, nb, confidence).
127    let band = scb_mean_degras(&diff, argvals, bandwidth, nb, confidence, multiplier)?;
128
129    // Reject when the band excludes zero at any grid point, i.e. when the
130    // maximum standardized excursion |center| / half_width exceeds 1.
131    let mut max_excursion = 0.0_f64;
132    let mut excludes_zero = false;
133    for j in 0..m {
134        let hw = band.half_width[j].max(1e-300);
135        let excursion = band.center[j].abs() / hw;
136        if excursion > max_excursion {
137            max_excursion = excursion;
138        }
139        if band.lower[j] > 0.0 || band.upper[j] < 0.0 {
140            excludes_zero = true;
141        }
142    }
143
144    let p_value = if excludes_zero { 0.0 } else { 1.0 };
145    Ok(TestResult {
146        statistic: max_excursion,
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    /// Curves around a known mean function `mean_fn(t) + shift`, with
158    /// deterministic bounded noise.
159    fn make_sample(
160        n: usize,
161        argvals: &[f64],
162        mean_fn: impl Fn(f64) -> f64,
163        shift: f64,
164        noise_amp: f64,
165        seed: u64,
166    ) -> FdMatrix {
167        let m = argvals.len();
168        let mut mat = FdMatrix::zeros(n, m);
169        let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
170        for i in 0..n {
171            for (j, &t) in argvals.iter().enumerate() {
172                state = state
173                    .wrapping_mul(6_364_136_223_846_793_005)
174                    .wrapping_add(1_442_695_040_888_963_407);
175                // Zero-mean noise in [-1, 1): 2*u - 1 with u in [0, 1).
176                let u = (state >> 33) as f64 / (1u64 << 31) as f64;
177                let noise = 2.0 * u - 1.0;
178                mat[(i, j)] = mean_fn(t) + shift + noise_amp * noise;
179            }
180        }
181        mat
182    }
183
184    #[test]
185    fn mean_scb_covers_true_mean() {
186        let argvals = uniform_grid(40);
187        // A gentle (near-linear) mean so that local-polynomial smoothing bias
188        // is negligible and the simultaneous band should cover the true mean at
189        // every grid point. (A high-curvature mean like sin(2πt) with a wide
190        // bandwidth would be dominated by smoothing bias, not coverage.)
191        let mean_fn = |t: f64| 0.5 + 0.3 * t;
192        let data = make_sample(80, &argvals, mean_fn, 0.0, 0.4, 42);
193        let band = mean_scb(
194            &data,
195            &argvals,
196            0.1,
197            400,
198            0.95,
199            MultiplierDistribution::Gaussian,
200        )
201        .unwrap();
202        // The true mean should lie within [lower, upper] at every grid point.
203        let mut covered = 0usize;
204        for (j, &t) in argvals.iter().enumerate() {
205            let truth = mean_fn(t);
206            if band.lower[j] <= truth && truth <= band.upper[j] {
207                covered += 1;
208            }
209        }
210        let n = argvals.len();
211        assert_eq!(
212            covered, n,
213            "true mean should be covered at every grid point ({covered}/{n})"
214        );
215    }
216
217    #[test]
218    fn scb_two_sample_detects_difference() {
219        let argvals = uniform_grid(40);
220        let mean_fn = |t: f64| (2.0 * std::f64::consts::PI * t).sin();
221        // Same mean shape, but sample b shifted by a clear constant.
222        let a = make_sample(50, &argvals, mean_fn, 0.0, 0.2, 11);
223        let b = make_sample(50, &argvals, mean_fn, 1.5, 0.2, 22);
224        let res = scb_two_sample_test(
225            &a,
226            &b,
227            &argvals,
228            0.15,
229            300,
230            0.95,
231            MultiplierDistribution::Gaussian,
232        )
233        .unwrap();
234        assert_eq!(res.p_value, 0.0, "clear difference should reject the null");
235        assert!(res.statistic > 1.0);
236    }
237
238    #[test]
239    fn scb_two_sample_no_difference() {
240        let argvals = uniform_grid(40);
241        let mean_fn = |t: f64| (2.0 * std::f64::consts::PI * t).sin();
242        // Same generator (up to seed) -> no genuine mean difference.
243        let a = make_sample(50, &argvals, mean_fn, 0.0, 0.3, 101);
244        let b = make_sample(50, &argvals, mean_fn, 0.0, 0.3, 202);
245        let res = scb_two_sample_test(
246            &a,
247            &b,
248            &argvals,
249            0.15,
250            300,
251            0.95,
252            MultiplierDistribution::Gaussian,
253        )
254        .unwrap();
255        assert_eq!(
256            res.p_value, 1.0,
257            "no genuine difference should fail to reject, got statistic={}",
258            res.statistic
259        );
260    }
261
262    #[test]
263    fn scb_invalid_input() {
264        let argvals = uniform_grid(20);
265        let mean_fn = |t: f64| t;
266        let a = make_sample(10, &argvals, mean_fn, 0.0, 0.1, 5);
267        // Mismatched columns.
268        let argvals_b = uniform_grid(15);
269        let b = make_sample(10, &argvals_b, mean_fn, 0.0, 0.1, 6);
270        assert!(matches!(
271            scb_two_sample_test(
272                &a,
273                &b,
274                &argvals,
275                0.15,
276                100,
277                0.95,
278                MultiplierDistribution::Gaussian
279            ),
280            Err(FdarError::InvalidDimension { .. })
281        ));
282        // Forwarded param validation: bandwidth <= 0.
283        let b2 = make_sample(10, &argvals, mean_fn, 0.0, 0.1, 7);
284        assert!(matches!(
285            mean_scb(
286                &a,
287                &argvals,
288                0.0,
289                100,
290                0.95,
291                MultiplierDistribution::Gaussian
292            ),
293            Err(FdarError::InvalidParameter { .. })
294        ));
295        // confidence out of range, forwarded.
296        assert!(matches!(
297            scb_two_sample_test(
298                &a,
299                &b2,
300                &argvals,
301                0.15,
302                100,
303                1.5,
304                MultiplierDistribution::Gaussian
305            ),
306            Err(FdarError::InvalidParameter { .. })
307        ));
308    }
309}