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}