Skip to main content

qualia_core_db/solvers/learning/resampling/
bootstrap.rs

1//! The bootstrap (ISL ch 5.2) — resample-with-replacement to estimate the
2//! sampling distribution (standard error / spread) of an arbitrary statistic.
3
4use crate::solvers::statistics::descriptive::{mean, std_dev};
5
6/// Deterministic LCG for reproducible resamples.
7struct Lcg(u64);
8impl Lcg {
9    fn next_below(&mut self, bound: usize) -> usize {
10        self.0 = self
11            .0
12            .wrapping_mul(6364136223846793005)
13            .wrapping_add(1442695040888963407);
14        ((self.0 >> 33) as usize) % bound.max(1)
15    }
16}
17
18/// One bootstrap resample of `n` row indices, drawn with replacement.
19pub fn bootstrap_indices(n: usize, seed: u64) -> Vec<usize> {
20    let mut rng = Lcg(seed ^ 0xD1B54A32D192ED03);
21    (0..n).map(|_| rng.next_below(n)).collect()
22}
23
24/// Bootstrap estimate of a scalar statistic's sampling distribution.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct BootstrapResult {
27    /// The statistic evaluated on the original full sample.
28    pub estimate: f64,
29    /// Mean of the statistic across the `b` resamples.
30    pub boot_mean: f64,
31    /// Bootstrap standard error (std-dev of the resample statistics).
32    pub std_error: f64,
33    /// Bias estimate `boot_mean − estimate`.
34    pub bias: f64,
35}
36
37/// Estimate the standard error (and bias) of `statistic` applied to `data`, over
38/// `b` bootstrap resamples. `statistic` maps a sample slice to a scalar. `None`
39/// if `data` is empty or `b < 2`.
40pub fn bootstrap_estimate(
41    data: &[f64],
42    b: usize,
43    seed: u64,
44    statistic: impl Fn(&[f64]) -> f64,
45) -> Option<BootstrapResult> {
46    let n = data.len();
47    if n == 0 || b < 2 {
48        return None;
49    }
50    let estimate = statistic(data);
51    let mut stats = Vec::with_capacity(b);
52    let mut sample = vec![0.0; n];
53    for r in 0..b {
54        let idx = bootstrap_indices(n, seed.wrapping_add(r as u64));
55        for (s, &i) in sample.iter_mut().zip(idx.iter()) {
56            *s = data[i];
57        }
58        stats.push(statistic(&sample));
59    }
60    let boot_mean = mean(&stats)?;
61    let std_error = std_dev(&stats, true).unwrap_or(0.0);
62    Some(BootstrapResult {
63        estimate,
64        boot_mean,
65        std_error,
66        bias: boot_mean - estimate,
67    })
68}
69
70/// Which bootstrap confidence-interval to compute.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum CiMethod {
73    /// The α/2 and 1−α/2 percentiles of the bootstrap distribution.
74    Percentile,
75    /// Bias-corrected and accelerated (BCa) — corrects for bias and skew via a
76    /// jackknife acceleration; the gold-standard nonparametric interval.
77    Bca,
78}
79
80/// An earned confidence interval — derived by resampling the data, not assumed
81/// from a Gaussian.
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub struct BootstrapCi {
84    pub estimate: f64,
85    pub lower: f64,
86    pub upper: f64,
87    pub confidence: f64,
88    pub method: CiMethod,
89}
90
91/// Bootstrap confidence interval for `statistic` at confidence `1 − alpha`
92/// (`alpha` in `(0,1)`, e.g. `0.05` → 95%). `None` if `data` is empty, `b < 2`,
93/// or `alpha` is out of range.
94pub fn bootstrap_ci(
95    data: &[f64],
96    b: usize,
97    alpha: f64,
98    seed: u64,
99    method: CiMethod,
100    statistic: impl Fn(&[f64]) -> f64,
101) -> Option<BootstrapCi> {
102    use crate::solvers::statistics::descriptive::quantile_sorted;
103    use crate::solvers::statistics::distributions::normal;
104
105    let n = data.len();
106    if n < 2 || b < 2 || !(0.0..1.0).contains(&alpha) || alpha <= 0.0 {
107        return None;
108    }
109    let estimate = statistic(data);
110
111    // Bootstrap replicates.
112    let mut boots = Vec::with_capacity(b);
113    let mut sample = vec![0.0; n];
114    for r in 0..b {
115        let idx = bootstrap_indices(n, seed.wrapping_add(r as u64));
116        for (s, &i) in sample.iter_mut().zip(idx.iter()) {
117            *s = data[i];
118        }
119        boots.push(statistic(&sample));
120    }
121    boots.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
122
123    let (lo_q, hi_q) = match method {
124        CiMethod::Percentile => (alpha / 2.0, 1.0 - alpha / 2.0),
125        CiMethod::Bca => {
126            // Bias correction z0 from the fraction of replicates below the estimate.
127            let n_below = boots.iter().filter(|&&v| v < estimate).count();
128            let frac = (n_below as f64 / b as f64).clamp(1e-9, 1.0 - 1e-9);
129            let z0 = normal::standard_quantile(frac);
130            // Acceleration from the jackknife distribution.
131            let mut jack = vec![0.0; n];
132            let mut loo = vec![0.0; n - 1];
133            for i in 0..n {
134                let mut k = 0;
135                for (j, &v) in data.iter().enumerate() {
136                    if j != i {
137                        loo[k] = v;
138                        k += 1;
139                    }
140                }
141                jack[i] = statistic(&loo);
142            }
143            let jbar = jack.iter().sum::<f64>() / n as f64;
144            let mut num = 0.0;
145            let mut den = 0.0;
146            for &j in &jack {
147                let d = jbar - j;
148                num += d * d * d;
149                den += d * d;
150            }
151            let a = if den > 0.0 {
152                num / (6.0 * den.powf(1.5))
153            } else {
154                0.0
155            };
156            // Adjusted percentiles.
157            let adj = |z_alpha: f64| {
158                let num = z0 + z_alpha;
159                normal::standard_cdf(z0 + num / (1.0 - a * num))
160            };
161            let zlo = normal::standard_quantile(alpha / 2.0);
162            let zhi = normal::standard_quantile(1.0 - alpha / 2.0);
163            (adj(zlo).clamp(0.0, 1.0), adj(zhi).clamp(0.0, 1.0))
164        }
165    };
166
167    Some(BootstrapCi {
168        estimate,
169        lower: quantile_sorted(&boots, lo_q)?,
170        upper: quantile_sorted(&boots, hi_q)?,
171        confidence: 1.0 - alpha,
172        method,
173    })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::solvers::statistics::descriptive::{mean, std_dev};
180
181    #[test]
182    fn resample_indices_in_range_and_reproducible() {
183        let a = bootstrap_indices(20, 123);
184        let b = bootstrap_indices(20, 123);
185        assert_eq!(a, b, "same seed → same resample");
186        assert!(a.iter().all(|&i| i < 20));
187        assert_eq!(a.len(), 20);
188    }
189
190    #[test]
191    fn bootstrap_se_of_mean_matches_clt() {
192        // For the sample mean, the bootstrap SE ≈ sample_std/√n.
193        let data: Vec<f64> = (1..=50).map(|i| i as f64).collect();
194        let r = bootstrap_estimate(&data, 2000, 7, |s| mean(s).unwrap()).unwrap();
195        let analytic_se = std_dev(&data, true).unwrap() / (data.len() as f64).sqrt();
196        assert!((r.estimate - mean(&data).unwrap()).abs() < 1e-12);
197        // Within ~10% of the analytic SE (Monte-Carlo tolerance).
198        assert!(
199            (r.std_error - analytic_se).abs() / analytic_se < 0.1,
200            "boot SE {} vs analytic {}",
201            r.std_error,
202            analytic_se
203        );
204        // Bias of the mean is ~0.
205        assert!(r.bias.abs() < 0.5);
206    }
207
208    #[test]
209    fn percentile_ci_brackets_the_true_mean() {
210        // Data centered at 10; the 95% bootstrap CI for the mean should bracket 10
211        // and be ordered lower < estimate < upper.
212        let data: Vec<f64> = (0..60)
213            .map(|i| 10.0 + ((i * 17 % 40) as f64 - 20.0) / 7.0)
214            .collect();
215        let ci = bootstrap_ci(&data, 2000, 0.05, 1, CiMethod::Percentile, |s| {
216            mean(s).unwrap()
217        })
218        .unwrap();
219        assert!(ci.lower < ci.estimate && ci.estimate < ci.upper);
220        assert!(
221            ci.lower < 10.0 && ci.upper > 10.0,
222            "CI [{}, {}] should bracket 10",
223            ci.lower,
224            ci.upper
225        );
226        assert!((ci.confidence - 0.95).abs() < 1e-12);
227    }
228
229    #[test]
230    fn bca_runs_and_is_a_valid_interval() {
231        let data: Vec<f64> = (1..=40).map(|i| i as f64).collect();
232        let ci = bootstrap_ci(&data, 2000, 0.1, 3, CiMethod::Bca, |s| mean(s).unwrap()).unwrap();
233        assert!(ci.lower < ci.upper);
234        // Mean of 1..=40 is 20.5; the 90% CI brackets it.
235        assert!(ci.lower < 20.5 && ci.upper > 20.5);
236        assert_eq!(ci.method, CiMethod::Bca);
237    }
238
239    #[test]
240    fn ci_works_for_a_nonlinear_statistic() {
241        // The bootstrap earns a CI for the median too — no Gaussian assumption.
242        use crate::solvers::statistics::descriptive::median_in_place;
243        let data: Vec<f64> = (0..51).map(|i| i as f64).collect();
244        let ci = bootstrap_ci(&data, 1500, 0.05, 5, CiMethod::Percentile, |s| {
245            let mut v = s.to_vec();
246            median_in_place(&mut v).unwrap()
247        })
248        .unwrap();
249        assert!(ci.lower <= 25.0 && ci.upper >= 25.0); // true median is 25
250    }
251
252    #[test]
253    fn guards() {
254        assert!(bootstrap_estimate(&[], 100, 0, |_| 0.0).is_none());
255        assert!(bootstrap_estimate(&[1.0, 2.0], 1, 0, |_| 0.0).is_none());
256        assert!(bootstrap_ci(&[1.0], 100, 0.05, 0, CiMethod::Percentile, |_| 0.0).is_none());
257        assert!(bootstrap_ci(&[1.0, 2.0], 100, 1.5, 0, CiMethod::Percentile, |_| 0.0).is_none());
258    }
259}