#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]
use std::sync::Arc;
use antecedent_core::ExecutionContext;
use antecedent_data::TableView;
use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
use antecedent_kernels::unbiased_index;
use crate::common::{
RefutationProblem, RefutationReport, complete_case_rows, linear_estimator_no_bootstrap,
refit_effect, with_resampled_rows,
};
use crate::error::ValidationError;
#[derive(Clone, Debug)]
pub struct BootstrapRefute {
pub replicates: u32,
pub ci_level: f64,
pub estimator: LinearAdjustmentAte,
}
impl Default for BootstrapRefute {
fn default() -> Self {
Self::new()
}
}
impl BootstrapRefute {
#[must_use]
pub fn new() -> Self {
Self { replicates: 200, ci_level: 0.95, estimator: linear_estimator_no_bootstrap() }
}
pub fn refute(
&self,
problem: &RefutationProblem<'_>,
workspace: &mut EstimationWorkspace,
ctx: &ExecutionContext,
) -> Result<RefutationReport, ValidationError> {
if self.replicates < 2 {
return Err(ValidationError::NotApplicable {
message: "bootstrap CI coverage requires replicates >= 2",
});
}
if !(self.ci_level > 0.0 && self.ci_level < 1.0) {
return Err(ValidationError::NotApplicable {
message: "bootstrap CI coverage requires ci_level in (0, 1)",
});
}
let n = problem.data.row_count();
let mut resample_ids = vec![problem.treatment(), problem.outcome()];
if problem.temporal.is_none() {
resample_ids.extend_from_slice(&problem.estimand.adjustment_set);
} else {
for v in problem.data.schema().variables() {
let id = v.id;
if id != problem.treatment() && id != problem.outcome() {
resample_ids.push(id);
}
}
}
let (keep, valid) = complete_case_rows(problem.data, &resample_ids)?;
if valid.len() < 2 {
return Err(ValidationError::NotApplicable {
message: "bootstrap CI coverage requires at least 2 complete-case rows",
});
}
let mut rng = ctx.rng.stream(0xA7E0_0009_0000_u64);
let mut row_idx = vec![0usize; n];
let mut ates = Vec::with_capacity(self.replicates as usize);
for _ in 0..self.replicates {
for slot in &mut row_idx {
*slot = valid[unbiased_index(&mut rng, valid.len())];
}
let data = with_resampled_rows(problem.data, &resample_ids, &row_idx, &keep)?;
let est = refit_effect(
problem,
&data,
problem.estimand,
&[],
&self.estimator,
workspace,
ctx,
)?;
ates.push(est.ate);
}
ates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let m = ates.len();
let lo_frac = (1.0 - self.ci_level) / 2.0;
let hi_frac = 1.0 - lo_frac;
let lo_idx = ((lo_frac * (m - 1) as f64).round() as usize).min(m - 1);
let hi_idx = ((hi_frac * (m - 1) as f64).round() as usize).min(m - 1);
let lo = ates[lo_idx];
let hi = ates[hi_idx];
let mean_ate = ates.iter().sum::<f64>() / m as f64;
let width = hi - lo;
let passed = problem.original.ate >= lo && problem.original.ate <= hi;
Ok(RefutationReport {
refuter: Arc::from("bootstrap.ci_coverage"),
original_ate: problem.original.ate,
refuted_ate: mean_ate,
comparison: width,
informative: true,
passed,
failure_condition: if passed {
None
} else {
Some(Arc::from(format!(
"original ATE {} outside {}% bootstrap CI [{lo}, {hi}] \
(coverage check of the point estimate, not a placebo falsification)",
problem.original.ate,
self.ci_level * 100.0
)))
},
replicates: self.replicates,
})
}
}