1use ndarray::{Array1, Array2, Array3, ArrayView3, Axis};
5use rand::{Rng, SeedableRng};
6use rand_chacha::ChaCha8Rng;
7use rand_distr::{Exp1, StandardNormal};
8
9use crate::error::{DmlError, Result};
10use crate::fit::{check_level, confint_from, DmlFit};
11use crate::stats::{median, quantile_mut};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum BootstrapMethod {
16 #[default]
18 Normal,
19 Bayes,
21 Wild,
23}
24
25pub fn draw_weights(
27 method: BootstrapMethod,
28 n_boot: usize,
29 n: usize,
30 rng: &mut impl Rng,
31) -> Array2<f64> {
32 let shape = (n_boot, n);
33 match method {
34 BootstrapMethod::Normal => {
35 Array2::from_shape_simple_fn(shape, || rng.sample(StandardNormal))
36 }
37 BootstrapMethod::Bayes => {
38 Array2::from_shape_simple_fn(shape, || rng.sample::<f64, _>(Exp1) - 1.0)
39 }
40 BootstrapMethod::Wild => Array2::from_shape_simple_fn(shape, || {
41 let a: f64 = rng.sample(StandardNormal);
42 let b: f64 = rng.sample(StandardNormal);
43 a / std::f64::consts::SQRT_2 + (b * b - 1.0) / 2.0
44 }),
45 }
46}
47
48#[derive(Debug, Clone)]
53pub struct Bootstrap {
54 all_coef: Array2<f64>,
55 all_se: Array2<f64>,
56 t_stats: Array3<f64>,
58}
59
60impl DmlFit {
61 pub fn bootstrap(
64 &self,
65 method: BootstrapMethod,
66 n_boot: usize,
67 seed: u64,
68 ) -> Result<Bootstrap> {
69 if n_boot == 0 {
70 return Err(DmlError::InvalidArgument("n_boot must be positive".into()));
71 }
72 let mut rng = ChaCha8Rng::seed_from_u64(seed);
73 let weights: Vec<Array2<f64>> = (0..self.n_rep())
74 .map(|_| draw_weights(method, n_boot, self.n_obs, &mut rng))
75 .collect();
76 self.bootstrap_with_weights(&weights)
77 }
78
79 pub fn bootstrap_with_weights(&self, weights: &[Array2<f64>]) -> Result<Bootstrap> {
82 if self.cluster.is_some() {
83 return Err(DmlError::InvalidArgument(
84 "the multiplier bootstrap is not available for clustered data (as in DoubleML)"
85 .into(),
86 ));
87 }
88 if weights.len() != self.n_rep() {
89 return Err(DmlError::DimensionMismatch(format!(
90 "{} weight matrices for {} repetitions",
91 weights.len(),
92 self.n_rep()
93 )));
94 }
95 let n_boot = weights[0].nrows();
96 let (n, k) = (self.n_obs, self.n_treat());
97 let mut t_stats = Array3::<f64>::zeros((n_boot, k, self.n_rep()));
98 for (r, w) in weights.iter().enumerate() {
99 if w.dim() != (n_boot, n) {
100 return Err(DmlError::DimensionMismatch(
101 "every weight matrix must be (n_boot, n_obs)".into(),
102 ));
103 }
104 let mut scaled = self.psi.index_axis(Axis(2), r).to_owned();
106 for j in 0..k {
107 let denom = self.psi_deriv[[j, r]] * n as f64 * self.all_se[[j, r]];
108 scaled.column_mut(j).mapv_inplace(|v| v / denom);
109 }
110 t_stats.index_axis_mut(Axis(2), r).assign(&w.dot(&scaled));
111 }
112 Ok(Bootstrap {
113 all_coef: self.all_coef.clone(),
114 all_se: self.all_se.clone(),
115 t_stats,
116 })
117 }
118}
119
120impl Bootstrap {
121 pub fn t_stats(&self) -> ArrayView3<'_, f64> {
123 self.t_stats.view()
124 }
125
126 pub fn critical_values(&self, level: f64) -> Result<Array1<f64>> {
128 check_level(level)?;
129 Ok(self
130 .t_stats
131 .axis_iter(Axis(2))
132 .map(|rep| {
133 let mut max_abs: Vec<f64> = rep
134 .rows()
135 .into_iter()
136 .map(|row| row.iter().fold(0.0_f64, |m, v| m.max(v.abs())))
137 .collect();
138 quantile_mut(&mut max_abs, level)
139 })
140 .collect())
141 }
142
143 pub fn joint_confint(&self, level: f64) -> Result<Array2<f64>> {
146 Ok(confint_from(
147 &self.all_coef,
148 &self.all_se,
149 &self.critical_values(level)?,
150 ))
151 }
152
153 pub fn romano_wolf(&self) -> Array1<f64> {
155 let (k, n_rep) = self.all_coef.dim();
156 let mut all = Array2::<f64>::zeros((k, n_rep));
157 for r in 0..n_rep {
158 let boot = self.t_stats.index_axis(Axis(2), r);
159 let abs_t: Vec<f64> = (0..k)
160 .map(|j| (self.all_coef[[j, r]] / self.all_se[[j, r]]).abs())
161 .collect();
162 let mut order: Vec<usize> = (0..k).collect();
164 order.sort_by(|&a, &b| abs_t[b].total_cmp(&abs_t[a]));
165 let mut running = 0.0_f64;
166 for (step, &j) in order.iter().enumerate() {
167 let remaining = &order[step..];
168 let exceed = boot
169 .rows()
170 .into_iter()
171 .filter(|row| {
172 remaining.iter().fold(0.0_f64, |m, &i| m.max(row[i].abs())) >= abs_t[j]
173 })
174 .count();
175 let p = (exceed as f64 / boot.nrows() as f64).min(1.0);
176 running = running.max(p);
177 all[[j, r]] = running;
178 }
179 }
180 all.rows()
181 .into_iter()
182 .map(|row| median(&row.to_vec()))
183 .collect()
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn weights_have_mean_zero_unit_variance() {
193 let mut rng = ChaCha8Rng::seed_from_u64(1);
194 for method in [
195 BootstrapMethod::Normal,
196 BootstrapMethod::Bayes,
197 BootstrapMethod::Wild,
198 ] {
199 let w = draw_weights(method, 200, 500, &mut rng);
200 let mean = w.mean().unwrap();
201 let var = w.mapv(|v| (v - mean).powi(2)).mean().unwrap();
202 assert!(mean.abs() < 0.02, "{method:?} mean {mean}");
203 assert!((var - 1.0).abs() < 0.03, "{method:?} var {var}");
204 }
205 }
206}