bootstrap_ht/bootstrap.rs
1// Copyright (c) 2022. Sebastien Soudan
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http:www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::fmt::Debug;
16
17use num_traits::Float;
18use rand::prelude::SliceRandom;
19use rand::Rng;
20
21use crate::Error;
22
23// FUTURE(ssoudan) parametric bootstrap
24
25// FUTURE(ssoudan) randomization test
26
27/// Part of the statistic distribution to use for the p-value
28/// https://en.wikipedia.org/wiki/P-value#Probability_of_obtaining_a_real-valued_test_statistic_at_least_as_extreme_as_the_one_actually_obtained
29pub enum PValueType {
30 /// Two-sided test - symmetric with respect to the mean of the statistic distribution
31 /// 2 * min (Pr(T >= t | H0), Pr(T <= t | H0))
32 TwoSided,
33 /// One-sided test (right tail)
34 /// Pr(T >= t | H0)
35 OneSidedRightTail,
36 /// One-sided test (left tail)
37 /// Pr(T <= t | H0)
38 OneSidedLeftTail,
39}
40
41/// Two-samples bootstrap test.
42///
43/// Perform a non-parametric bootstrap hypothesis test on samples `a` and `b` with the
44/// given `test_statistic_fn` function.
45///
46/// # Description
47///
48/// The null hypothesis is that the two samples are drawn from the same distribution.
49/// The p-value is the probability of obtaining a test statistic at least as extreme as
50/// the one actually obtained (extreme being defined by `pvalue_type` - see
51/// [`PValueType`]).
52///
53/// The test statistic is computed on the two samples and the p-value is computed by
54/// comparing the test statistic to the distribution of the test statistic.
55///
56/// The test statistic distribution is obtained by randomly sampling with replacement from
57/// the two samples `rep` times - seems that 10_000 is the norm.
58///
59/// Note `a` and `b` need not be of the same size.
60///
61/// # Example
62///
63/// Let's use the absolute difference of the max as the test statistic: `|max(a) -
64/// max(b)|`.
65///
66/// ```rust
67/// use bootstrap_ht::prelude::*;
68/// use itertools::Itertools;
69/// use rand::prelude::Distribution;
70/// use rand::SeedableRng;
71/// use rand_chacha::ChaCha8Rng;
72/// use rand_distr::StandardNormal;
73///
74/// let mut rng = ChaCha8Rng::seed_from_u64(42);
75///
76/// let a = StandardNormal
77/// .sample_iter(&mut rng)
78/// .take(100)
79/// .collect::<Vec<f64>>();
80/// let b = StandardNormal
81/// .sample_iter(&mut rng)
82/// .take(40)
83/// .map(|x: f64| x + 2.0)
84/// .collect::<Vec<f64>>();
85///
86/// /// absolute difference of the max
87/// let test_statistic_fn = |a: &[f64], b: &[f64]| {
88/// let a_max = a.iter().copied().fold(f64::NAN, f64::max);
89/// let b_max = b.iter().copied().fold(f64::NAN, f64::max);
90/// (a_max - b_max).abs()
91/// };
92///
93/// let p_value = bootstrap::two_samples_non_parametric_ht(
94/// &mut rng,
95/// &a,
96/// &b,
97/// test_statistic_fn,
98/// bootstrap::PValueType::OneSidedRightTail,
99/// 10_000,
100/// )
101/// .unwrap();
102/// assert_eq!(p_value, 0.0021);
103/// ```
104pub fn two_samples_non_parametric_ht<
105 R: Rng + ?Sized,
106 F: Float + std::iter::Sum,
107 S: Clone + Default,
108>(
109 mut rng: &mut R,
110 a: &[S],
111 b: &[S],
112 test_statistic_fn: impl Fn(&[S], &[S]) -> F,
113 pvalue_type: PValueType,
114 rep: usize,
115) -> Result<F, Error> {
116 let n_a = a.len();
117 if n_a == 0 {
118 return Err(Error::NotEnoughSamples);
119 }
120 let n_b = b.len();
121 if n_b == 0 {
122 return Err(Error::NotEnoughSamples);
123 }
124
125 // the test statistic for the observed data
126 let test_stat = test_statistic_fn(a, b);
127
128 // the test statistic distribution of the population under the null hypothesis (a and b
129 // are drawn from the same population).
130 let mut test_stat_dist = vec![F::from(0.).unwrap(); rep];
131
132 let reference = [a, b].concat();
133
134 let mut a_ = vec![S::default(); n_a];
135 let mut b_ = vec![S::default(); n_b];
136
137 for test_stat_dist_ in test_stat_dist.iter_mut() {
138 for a__ in a_.iter_mut() {
139 *a__ = reference.choose(&mut rng).unwrap().clone();
140 }
141
142 for b__ in b_.iter_mut() {
143 *b__ = reference.choose(&mut rng).unwrap().clone();
144 }
145
146 let test_stat_ = test_statistic_fn(&a_, &b_);
147 *test_stat_dist_ = test_stat_;
148 }
149
150 // the p-value
151 let p_value = compute_p_value(pvalue_type, test_stat, test_stat_dist);
152
153 Ok(p_value)
154}
155
156/// One-sample bootstrap test.
157///
158/// Perform a non-parametric one-sample bootstrap hypothesis test on the population
159/// generated from sample `a` with the given `test_statistic_fn` function and reference
160/// statistic `mu`.
161///
162/// # Description
163///
164/// The null hypothesis is that the test statistic of the population generated by the
165/// sample `a` is `test_stat`. The p-value is the probability of obtaining a test
166/// statistic at least as extreme as the one provided by `mu` (extreme being defined by
167/// `pvalue_type` - see [`PValueType`]).
168///
169/// The p-value is computed by comparing `test_stat` to the distribution of the
170/// test statistic.
171///
172/// The test statistic distribution is obtained by randomly sampling with replacement from
173/// the samples `rep` times - seems that 10_000 is the norm.
174///
175///
176/// # Example
177///
178/// Let's use the mean as the test statistic: `mean(a)`.
179/// ```rust
180/// use bootstrap_ht::prelude::*;
181/// use rand::SeedableRng;
182/// use rand_chacha::ChaCha8Rng;
183///
184/// // From p.224 - 'An introduction to the boostrap', Efron and Tibshirani
185///
186/// let a = [94., 197., 16., 38., 99., 141., 23.];
187/// let a_mean = a.iter().sum::<f32>() / a.len() as f32;
188///
189/// let mut rng = ChaCha8Rng::seed_from_u64(42);
190///
191/// let test_statistic_fn = |a: &[f32]| {
192/// let a_mean = a.iter().sum::<f32>() / a.len() as f32;
193/// let a_std =
194/// (a.iter().map(|x| (x - a_mean).powi(2)).sum::<f32>() / (a.len() - 1) as f32).sqrt();
195///
196/// (a_mean - 129.) / (a_std / (a.len() as f32).sqrt())
197/// };
198///
199/// let test_stat: f32 = test_statistic_fn(&a);
200///
201/// /// Got to shift the samples so their mean is the one assumed under H0
202/// let a = a
203/// .into_iter()
204/// .map(|x| x - a_mean + 129.)
205/// .collect::<Vec<f32>>();
206///
207/// let p_value = bootstrap::one_sample_non_parametric_ht(
208/// &mut rng,
209/// &a,
210/// test_stat,
211/// test_statistic_fn,
212/// bootstrap::PValueType::OneSidedLeftTail,
213/// 1_000,
214/// )
215/// .unwrap();
216/// assert_eq!(p_value, 0.092);
217/// ```
218pub fn one_sample_non_parametric_ht<
219 R: Rng + ?Sized,
220 F: Float + std::iter::Sum + Debug,
221 S: Clone + Default + Debug,
222>(
223 mut rng: &mut R,
224 a: &[S],
225 test_stat: F,
226 test_statistic_fn: impl Fn(&[S]) -> F,
227 pvalue_type: PValueType,
228 rep: usize,
229) -> Result<F, Error> {
230 let n_a = a.len();
231 if n_a == 0 {
232 return Err(Error::NotEnoughSamples);
233 }
234
235 // the test statistic distribution of the population under the null hypothesis.
236 let mut test_stat_dist = vec![F::from(0.).unwrap(); rep];
237
238 let mut a_ = vec![S::default(); n_a];
239 for test_stat_dist_ in test_stat_dist.iter_mut() {
240 for a__ in a_.iter_mut() {
241 *a__ = a.choose(&mut rng).unwrap().clone();
242 }
243
244 let test_stat_ = test_statistic_fn(&a_);
245 *test_stat_dist_ = test_stat_;
246 }
247
248 // the p-value
249 let p_value = compute_p_value(pvalue_type, test_stat, test_stat_dist);
250
251 Ok(p_value)
252}
253
254fn compute_p_value<F: Float + std::iter::Sum>(
255 pvalue_type: PValueType,
256 test_stat: F,
257 test_stat_dist: Vec<F>,
258) -> F {
259 let n = F::from(test_stat_dist.len()).unwrap();
260
261 match pvalue_type {
262 PValueType::TwoSided => {
263 let right_p_value =
264 F::from(test_stat_dist.iter().filter(|t| **t >= test_stat).count()).unwrap() / n;
265
266 let left_p_value =
267 F::from(test_stat_dist.iter().filter(|t| **t <= test_stat).count()).unwrap() / n;
268
269 let min = if right_p_value <= left_p_value {
270 right_p_value
271 } else {
272 left_p_value
273 };
274
275 F::from(2.).unwrap() * min
276 }
277 PValueType::OneSidedRightTail => {
278 F::from(test_stat_dist.iter().filter(|&t| t >= &test_stat).count()).unwrap() / n
279 }
280 PValueType::OneSidedLeftTail => {
281 F::from(test_stat_dist.iter().filter(|&t| t <= &test_stat).count()).unwrap() / n
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use itertools::Itertools;
289 use rand::SeedableRng;
290 use rand_chacha::ChaCha8Rng;
291 use rand_distr::{Distribution, StandardNormal};
292
293 use super::*;
294
295 #[test]
296 fn test_two_samples_ht() {
297 let a = vec![1.0, 2.0, 3.0];
298 let b = vec![4.0, 5.0, 6.0];
299
300 let mut rng = ChaCha8Rng::seed_from_u64(42);
301
302 // absolute difference of the means
303 let test_statistic_fn = |a: &[f64], b: &[f64]| {
304 let a_mean = a.iter().sum::<f64>() / a.len() as f64;
305 let b_mean = b.iter().sum::<f64>() / b.len() as f64;
306 (a_mean - b_mean).abs()
307 };
308
309 let p_value = two_samples_non_parametric_ht(
310 &mut rng,
311 &a,
312 &b,
313 test_statistic_fn,
314 PValueType::OneSidedRightTail,
315 10_000,
316 )
317 .unwrap();
318 assert_eq!(p_value, 0.0345);
319 // p_value is lower than 0.05, so we reject the null hypothesis that the means are
320 // equal
321 }
322
323 #[test]
324 fn test_two_samples_normal_distributions_ht() {
325 let mut rng = &mut ChaCha8Rng::seed_from_u64(42);
326 let a = StandardNormal
327 .sample_iter(&mut rng)
328 .take(100)
329 .collect::<Vec<f64>>();
330 let b = StandardNormal
331 .sample_iter(&mut rng)
332 .take(40)
333 .collect::<Vec<f64>>();
334
335 let mut rng = ChaCha8Rng::seed_from_u64(42);
336
337 // absolute difference of the means
338 let test_statistic_fn = |a: &[f64], b: &[f64]| {
339 let a_mean = a.iter().sum::<f64>() / a.len() as f64;
340 let b_mean = b.iter().sum::<f64>() / b.len() as f64;
341 (a_mean - b_mean).abs()
342 };
343
344 let p_value = two_samples_non_parametric_ht(
345 &mut rng,
346 &a,
347 &b,
348 test_statistic_fn,
349 PValueType::OneSidedRightTail,
350 10_000,
351 )
352 .unwrap();
353 assert_eq!(p_value, 0.8298);
354 // p_value is large enough (say, compared to 0.05) not to reject the null
355 // hypothesis that the means are equal
356 }
357
358 #[test]
359 fn test_two_normal_distributions_two_sided_ht() {
360 let mut rng = &mut ChaCha8Rng::seed_from_u64(42);
361 let a = StandardNormal
362 .sample_iter(&mut rng)
363 .take(100)
364 .collect::<Vec<f64>>();
365 let b = StandardNormal
366 .sample_iter(&mut rng)
367 .take(40)
368 .collect::<Vec<f64>>();
369
370 let mut rng = ChaCha8Rng::seed_from_u64(42);
371
372 // difference of the means
373 let test_statistic_fn = |a: &[f64], b: &[f64]| {
374 let a_mean = a.iter().sum::<f64>() / a.len() as f64;
375 let b_mean = b.iter().sum::<f64>() / b.len() as f64;
376 a_mean - b_mean
377 };
378
379 let p_value = two_samples_non_parametric_ht(
380 &mut rng,
381 &a,
382 &b,
383 test_statistic_fn,
384 PValueType::TwoSided,
385 10_000,
386 )
387 .unwrap();
388 assert_eq!(p_value, 0.8222);
389 // p_value is large enough not to reject the null hypothesis that the means are
390 // equal
391 }
392
393 #[test]
394 fn test_two_different_normal_distributions_two_sided_ht() {
395 let mut rng = &mut ChaCha8Rng::seed_from_u64(42);
396 let a = StandardNormal
397 .sample_iter(&mut rng)
398 .take(100)
399 .collect::<Vec<f64>>();
400 let b = StandardNormal
401 .sample_iter(&mut rng)
402 .take(100)
403 .map(|x: f64| x + 0.3)
404 .collect::<Vec<f64>>();
405
406 let mut rng = ChaCha8Rng::seed_from_u64(42);
407
408 // difference of the means
409 fn test_statistic_fn(a: &[f64], b: &[f64]) -> f64 {
410 let a_mean = a.iter().sum::<f64>() / a.len() as f64;
411 let b_mean = b.iter().sum::<f64>() / b.len() as f64;
412 a_mean - b_mean
413 }
414
415 let p_value = two_samples_non_parametric_ht(
416 &mut rng,
417 &a,
418 &b,
419 test_statistic_fn,
420 PValueType::TwoSided,
421 10_000,
422 )
423 .unwrap();
424 assert_eq!(p_value, 0.0418);
425 // p_value is small enough to reject the null hypothesis that the means are equal
426 }
427
428 #[test]
429 fn test_two_different_normal_distributions_one_sided_95percentile_ht() {
430 let mut rng = &mut ChaCha8Rng::seed_from_u64(42);
431 let a = StandardNormal
432 .sample_iter(&mut rng)
433 .take(100)
434 .collect::<Vec<f64>>();
435 let b = StandardNormal
436 .sample_iter(&mut rng)
437 .take(100)
438 .map(|x: f64| x + 0.7)
439 .collect::<Vec<f64>>();
440
441 let mut rng = ChaCha8Rng::seed_from_u64(42);
442
443 // difference of the means
444 let test_statistic_fn = |a: &[f64], b: &[f64]| {
445 let a_95percentile = a
446 .iter()
447 .sorted_by(|x, y| x.partial_cmp(y).unwrap())
448 .nth(95 * a.len() / 100)
449 .unwrap();
450 let b_95percentile = b
451 .iter()
452 .sorted_by(|x, y| x.partial_cmp(y).unwrap())
453 .nth(95 * b.len() / 100)
454 .unwrap();
455 (a_95percentile - b_95percentile).abs()
456 };
457
458 let p_value = two_samples_non_parametric_ht(
459 &mut rng,
460 &a,
461 &b,
462 test_statistic_fn,
463 PValueType::OneSidedRightTail,
464 10_000,
465 )
466 .unwrap();
467 assert_eq!(p_value, 0.0218);
468 // p_value is small enough to reject the null hypothesis that the 95-percentiles
469 // are equal
470 }
471
472 #[test]
473 fn test_one_sample_std_normal_mean_ht() {
474 let mut rng = &mut ChaCha8Rng::seed_from_u64(42);
475 let a = StandardNormal
476 .sample_iter(&mut rng)
477 .take(100)
478 .collect::<Vec<f64>>();
479
480 let mut rng = ChaCha8Rng::seed_from_u64(42);
481
482 // difference of the means
483 let test_statistic_fn = |a: &[f64]| {
484 let a_mean = a.iter().sum::<f64>() / a.len() as f64;
485 a_mean
486 };
487
488 let test_stat = test_statistic_fn(&a);
489
490 let p_value = one_sample_non_parametric_ht(
491 &mut rng,
492 &a,
493 test_stat,
494 test_statistic_fn,
495 PValueType::TwoSided,
496 10_000,
497 )
498 .unwrap();
499 assert_eq!(p_value, 0.999);
500 // let's not reject H0
501 }
502
503 #[test]
504 fn test_one_sample_mean_ht() {
505 // From p.224 - 'An introduction to the boostrap', Efron and Tibshirani
506
507 // H0: the mean of the population is 129.
508
509 let a = [94., 197., 16., 38., 99., 141., 23.];
510 let a_mean = a.iter().sum::<f32>() / a.len() as f32;
511
512 let mut rng = ChaCha8Rng::seed_from_u64(42);
513
514 // 'studentized' distance to the H0 mean statistic
515 let test_statistic_fn = |a: &[f32]| {
516 let a_mean = a.iter().sum::<f32>() / a.len() as f32;
517 let a_std =
518 (a.iter().map(|x| (x - a_mean).powi(2)).sum::<f32>() / (a.len() - 1) as f32).sqrt();
519
520 (a_mean - 129.) / (a_std / (a.len() as f32).sqrt())
521 };
522
523 let test_stat: f32 = test_statistic_fn(&a);
524
525 // Got to shift the samples so their mean is the one assumed under H0 - namely 129.
526 let a = a
527 .into_iter()
528 .map(|x| x - a_mean + 129.)
529 .collect::<Vec<f32>>();
530
531 // Alternatively, we could have not shifted the samples, and used a test statistic
532 // measuring the distance to the empirical mean of the original sample (86.9) while
533 // keeping 129 when calculating the reference test statistic.
534 //
535 // In the end, what we want to estimate is how 'surprising' it is for the original sample
536 // to be this far from a hypothetical population mean by comparing the distribution of
537 // distance to the mean for a collection of samples generated randomly from the
538 // same 'data'.
539
540 let p_value = one_sample_non_parametric_ht(
541 &mut rng,
542 &a,
543 test_stat,
544 test_statistic_fn,
545 PValueType::OneSidedLeftTail,
546 1_000,
547 )
548 .unwrap();
549 assert_eq!(p_value, 0.092);
550 }
551}