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}