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 \
68                          temporal.backdoor.unfolded",
69            });
70        }
71        let n = problem.data.row_count();
72        let mut noise = vec![0.0; n];
73        let mut ates = Vec::with_capacity(self.replicates as usize);
74        for r in 0..self.replicates {
75            fill_gaussian(&mut noise, ctx, 0xA7E0_0002_0000_u64.wrapping_add(u64::from(r)));
76            let (data, new_id) = with_extra_float(
77                problem.data,
78                &format!("__rcc_{r}"),
79                Arc::<[f64]>::from(noise.clone()),
80            )?;
81            let est = if temporal_ok {
82                refit_effect(
83                    problem,
84                    &data,
85                    problem.estimand,
86                    &[new_id],
87                    &self.estimator,
88                    workspace,
89                    ctx,
90                )?
91            } else {
92                let estimand = extend_adjustment(problem.estimand, new_id);
93                refit_effect(problem, &data, &estimand, &[], &self.estimator, workspace, ctx)?
94            };
95            ates.push(est.ate);
96        }
97        let mean_ate = ates.iter().sum::<f64>() / f64::from(self.replicates);
98        let p_value = replicate_p_value(&ates, problem.original.ate);
99        let passed = p_value >= self.alpha;
100        Ok(RefutationReport {
101            refuter: Arc::from("random.common_cause"),
102            original_ate: problem.original.ate,
103            refuted_ate: mean_ate,
104            comparison: p_value,
105            informative: true,
106            passed,
107            failure_condition: if passed {
108                None
109            } else {
110                Some(Arc::from(format!(
111                    "refit ATE distribution (mean {mean_ate}) is inconsistent with the \
112                     original estimate (p={p_value} < alpha={})",
113                    self.alpha
114                )))
115            },
116            replicates: self.replicates,
117        })
118    }
119}
120
121fn extend_adjustment(base: &IdentifiedEstimand, extra: VariableId) -> IdentifiedEstimand {
122    let mut zs: Vec<VariableId> = base.adjustment_set.to_vec();
123    zs.push(extra);
124    IdentifiedEstimand::new(
125        Arc::clone(&base.method),
126        Arc::from(zs),
127        Arc::clone(&base.instruments),
128        Arc::clone(&base.mediators),
129        base.functional,
130        base.rd_design,
131    )
132}