Skip to main content

antecedent_validate/
graph_refute.rs

1//! Leave-one-out adjustment-set sensitivity (not structural graph editing).
2//!
3//! Drops each backdoor adjustment covariate in turn, refits, and reports the
4//! worst relative ATE change. This checks sensitivity to the *chosen adjustment
5//! set*, not to edge deletions in an underlying DAG (the refutation problem has
6//! no graph handle).
7//!
8//! SPDX-License-Identifier: MIT OR Apache-2.0
9
10use std::sync::Arc;
11
12use antecedent_core::ExecutionContext;
13use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
14use antecedent_identify::IdentifiedEstimand;
15
16use crate::common::{
17    RefutationProblem, RefutationReport, complete_case_rows, linear_estimator_no_bootstrap,
18    masked_sample_sd, refit_effect,
19};
20use crate::error::ValidationError;
21
22/// Drop each adjustment covariate once and re-estimate (leave-one-out).
23///
24/// A large change flags sensitivity to the assumed adjustment set. When the
25/// adjustment set is empty there is nothing to drop, so the check reports
26/// `informative = false` rather than fabricating a comparison.
27///
28/// Historically named "graph refuter"; the report id is
29/// `adjustment.drop_covariate` to match the actual check.
30#[derive(Clone, Debug)]
31pub struct GraphRefuter {
32    /// Pass if max `|refuted_ate - original_ate| / |original_ate|` is below this
33    /// threshold (relative change, so the verdict is invariant to outcome units).
34    pub rel_delta_threshold: f64,
35    /// Estimator used for the refit (bootstrap disabled).
36    pub estimator: LinearAdjustmentAte,
37}
38
39impl Default for GraphRefuter {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl GraphRefuter {
46    /// Default threshold: 0.5 (the estimate may move by up to half its own magnitude).
47    #[must_use]
48    pub fn new() -> Self {
49        Self { rel_delta_threshold: 0.5, estimator: linear_estimator_no_bootstrap() }
50    }
51
52    /// Run leave-one-out adjustment-set sensitivity.
53    ///
54    /// # Errors
55    ///
56    /// Data or estimation failures.
57    pub fn refute(
58        &self,
59        problem: &RefutationProblem<'_>,
60        workspace: &mut EstimationWorkspace,
61        ctx: &ExecutionContext,
62    ) -> Result<RefutationReport, ValidationError> {
63        if problem.estimand.method_kind().ok()
64            != Some(antecedent_expr::EstimandMethod::BackdoorAdjustment)
65        {
66            return Err(ValidationError::NotApplicable {
67                message: "adjustment drop-covariate requires backdoor.adjustment estimand",
68            });
69        }
70        if problem.estimand.adjustment_set.is_empty() {
71            return Ok(RefutationReport {
72                refuter: Arc::from("adjustment.drop_covariate"),
73                original_ate: problem.original.ate,
74                refuted_ate: problem.original.ate,
75                comparison: 0.0,
76                informative: false,
77                passed: true,
78                failure_condition: None,
79                replicates: 0,
80            });
81        }
82        let mut ids = vec![problem.treatment(), problem.outcome()];
83        ids.extend_from_slice(&problem.estimand.adjustment_set);
84        let (mask, _valid) = complete_case_rows(problem.data, &ids)?;
85        let sd_t = masked_sample_sd(problem.data, problem.treatment(), &mask)?.max(1e-12);
86        let sd_y = masked_sample_sd(problem.data, problem.outcome(), &mask)?.max(1e-12);
87        let floor = 1e-3 * (sd_y / sd_t);
88
89        let mut worst_delta = 0.0_f64;
90        let mut worst_ate = problem.original.ate;
91        let mut worst_dropped = problem.estimand.adjustment_set[0];
92        for drop_idx in 0..problem.estimand.adjustment_set.len() {
93            let reduced = drop_adjustment_at(problem.estimand, drop_idx);
94            let est = refit_effect(
95                problem,
96                problem.data,
97                &reduced,
98                &[],
99                &self.estimator,
100                workspace,
101                ctx,
102            )?;
103            // Relative change with an sd-based floor on the denominator: a near-zero original
104            // estimate that moves materially when a covariate is dropped is set-sensitive.
105            let delta =
106                (est.ate - problem.original.ate).abs() / problem.original.ate.abs().max(floor);
107            if delta >= worst_delta {
108                worst_delta = delta;
109                worst_ate = est.ate;
110                worst_dropped = problem.estimand.adjustment_set[drop_idx];
111            }
112        }
113        let passed = worst_delta < self.rel_delta_threshold;
114        Ok(RefutationReport {
115            refuter: Arc::from("adjustment.drop_covariate"),
116            original_ate: problem.original.ate,
117            refuted_ate: worst_ate,
118            comparison: worst_delta,
119            informative: true,
120            passed,
121            failure_condition: if passed {
122                None
123            } else {
124                Some(Arc::from(format!(
125                    "relative |Ξ”ATE|={worst_delta} exceeded threshold {} after dropping \
126                     adjustment covariate {worst_dropped:?} (leave-one-out max)",
127                    self.rel_delta_threshold
128                )))
129            },
130            replicates: u32::try_from(problem.estimand.adjustment_set.len()).unwrap_or(u32::MAX),
131        })
132    }
133}
134
135fn drop_adjustment_at(base: &IdentifiedEstimand, drop_idx: usize) -> IdentifiedEstimand {
136    let zs: Vec<_> = base
137        .adjustment_set
138        .iter()
139        .copied()
140        .enumerate()
141        .filter(|(i, _)| *i != drop_idx)
142        .map(|(_, z)| z)
143        .collect();
144    IdentifiedEstimand::new(
145        Arc::clone(&base.method),
146        Arc::from(zs),
147        Arc::clone(&base.instruments),
148        Arc::clone(&base.mediators),
149        base.functional,
150        base.rd_design,
151    )
152}