Skip to main content

antecedent_validate/
rcc.rs

1//! Random common cause refuter.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
6
7use std::sync::Arc;
8
9use antecedent_core::{ExecutionContext, VariableId};
10use antecedent_data::TableView;
11use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
12use antecedent_identify::IdentifiedEstimand;
13
14use crate::common::{
15    RefutationProblem, RefutationReport, fill_gaussian, linear_estimator_no_bootstrap,
16    refit_effect, replicate_p_value, with_extra_float,
17};
18use crate::error::ValidationError;
19
20/// Add an independent noise covariate; expect ATE largely unchanged.
21#[derive(Clone, Debug)]
22pub struct RandomCommonCause {
23    /// Replicate count.
24    pub replicates: u32,
25    /// Pass if the refit ATE distribution is consistent with the original estimate at
26    /// this significance level (two-sided normal test on the replicates, `p >= alpha`).
27    pub alpha: f64,
28    /// Estimator used for refits (bootstrap disabled).
29    pub estimator: LinearAdjustmentAte,
30}
31
32impl Default for RandomCommonCause {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl RandomCommonCause {
39    /// Default: 20 replicates, significance level 0.05.
40    #[must_use]
41    pub fn new() -> Self {
42        Self { replicates: 20, alpha: 0.05, estimator: linear_estimator_no_bootstrap() }
43    }
44
45    /// Run the random-common-cause refuter.
46    ///
47    /// # Errors
48    ///
49    /// Data or estimation failures.
50    pub fn refute(
51        &self,
52        problem: &RefutationProblem<'_>,
53        workspace: &mut EstimationWorkspace,
54        ctx: &ExecutionContext,
55    ) -> Result<RefutationReport, ValidationError> {
56        if self.replicates < 2 {
57            return Err(ValidationError::NotApplicable {
58                message: "random common cause requires replicates >= 2",
59            });
60        }
61        let method = problem.estimand.method_kind().ok();
62        let static_ok = method == Some(antecedent_expr::EstimandMethod::BackdoorAdjustment);
63        let temporal_ok = method == Some(antecedent_expr::EstimandMethod::TemporalBackdoorUnfolded)
64            && problem.temporal.is_some();
65        if !static_ok && !temporal_ok {
66            return Err(ValidationError::NotApplicable {
67                message: "random common cause requires backdoor.adjustment or temporal.backdoor.unfolded",
68            });
69        }
70        let n = problem.data.row_count();
71        let mut noise = vec![0.0; n];
72        let mut ates = Vec::with_capacity(self.replicates as usize);
73        for r in 0..self.replicates {
74            fill_gaussian(&mut noise, ctx, 0xA7E0_0002_0000_u64.wrapping_add(u64::from(r)));
75            let (data, new_id) = with_extra_float(
76                problem.data,
77                &format!("__rcc_{r}"),
78                Arc::<[f64]>::from(noise.clone()),
79            )?;
80            let est = if temporal_ok {
81                refit_effect(problem, &data, problem.estimand, &[new_id], workspace, ctx)?
82            } else {
83                let estimand = extend_adjustment(problem.estimand, new_id);
84                refit_effect(problem, &data, &estimand, &[], workspace, ctx)?
85            };
86            ates.push(est.ate);
87        }
88        let mean_ate = ates.iter().sum::<f64>() / f64::from(self.replicates);
89        let p_value = replicate_p_value(&ates, problem.original.ate);
90        let passed = p_value >= self.alpha;
91        Ok(RefutationReport {
92            refuter: Arc::from("random.common_cause"),
93            original_ate: problem.original.ate,
94            refuted_ate: mean_ate,
95            comparison: p_value,
96            informative: true,
97            passed,
98            failure_condition: if passed {
99                None
100            } else {
101                Some(Arc::from(format!(
102                    "refit ATE distribution (mean {mean_ate}) is inconsistent with the \
103                     original estimate (p={p_value} < alpha={})",
104                    self.alpha
105                )))
106            },
107            replicates: self.replicates,
108        })
109    }
110}
111
112fn extend_adjustment(base: &IdentifiedEstimand, extra: VariableId) -> IdentifiedEstimand {
113    let mut zs: Vec<VariableId> = base.adjustment_set.to_vec();
114    zs.push(extra);
115    IdentifiedEstimand::new(
116        Arc::clone(&base.method),
117        Arc::from(zs),
118        Arc::clone(&base.instruments),
119        Arc::clone(&base.mediators),
120        base.functional,
121        base.rd_design,
122    )
123}