qualia_core_db/solvers/learning/resampling/
bootstrap.rs1use crate::solvers::statistics::descriptive::{mean, std_dev};
5
6struct 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
18pub 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#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct BootstrapResult {
27 pub estimate: f64,
29 pub boot_mean: f64,
31 pub std_error: f64,
33 pub bias: f64,
35}
36
37pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum CiMethod {
73 Percentile,
75 Bca,
78}
79
80#[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
91pub 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 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 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 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 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 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 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 assert!(r.bias.abs() < 0.5);
206 }
207
208 #[test]
209 fn percentile_ci_brackets_the_true_mean() {
210 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 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 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); }
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}