Skip to main content

antecedent_validate/
overlap.rs

1//! Overlap / positivity refuter.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_precision_loss)]
6
7use std::sync::Arc;
8
9use antecedent_estimate::OverlapPolicy;
10use antecedent_stats::GlmOptions;
11
12use crate::common::{RefutationProblem, RefutationReport};
13use crate::error::ValidationError;
14
15/// Overlap / positivity assessment.
16///
17/// **No silent propensity rebuild:** when `problem.original.overlap_report` is `Some` (the
18/// original estimate came from a propensity-based estimator), that report is reused verbatim.
19/// When it is `None` (the linear-adjustment path, which deliberately skips propensity via
20/// [`OverlapPolicy::ExplicitOverride`]), this refuter fits its own diagnostic-only logistic
21/// propensity model on the adjustment covariates — explicitly, and only to populate the
22/// diagnostics this check needs. That fit never feeds back into the original point estimate.
23#[derive(Clone, Debug)]
24pub struct OverlapRefuter {
25    /// Minimum acceptable margin from the propensity boundary: pass requires propensities in
26    /// `[eps, 1 - eps]`.
27    pub eps: f64,
28    /// Minimum acceptable fraction of effective sample size retained (`ess / n`).
29    pub min_ess_fraction: f64,
30    /// GLM options used only for the diagnostic-only fit (linear-adjustment path).
31    pub glm_options: GlmOptions,
32}
33
34impl Default for OverlapRefuter {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl OverlapRefuter {
41    /// Defaults: `eps = 0.05`, `min_ess_fraction = 0.5`.
42    #[must_use]
43    pub fn new() -> Self {
44        Self { eps: 0.05, min_ess_fraction: 0.5, glm_options: GlmOptions::default() }
45    }
46
47    /// Run the overlap / positivity refuter.
48    ///
49    /// Complete separation / non-converged diagnostic GLM fits are treated as overlap
50    /// failures (extreme propensities), not as hard errors.
51    ///
52    /// # Errors
53    ///
54    /// Data failures while building a diagnostic-only propensity fit.
55    pub fn refute(
56        &self,
57        problem: &RefutationProblem<'_>,
58    ) -> Result<RefutationReport, ValidationError> {
59        let mut local = antecedent_stats::PropensityWorkspace::default();
60        self.refute_with_propensity(problem, &mut local)
61    }
62
63    /// Like [`Self::refute`], reusing a warmed propensity workspace for diagnostic fits.
64    ///
65    /// # Errors
66    ///
67    /// Data failures while building a diagnostic-only propensity fit.
68    pub fn refute_with_propensity(
69        &self,
70        problem: &RefutationProblem<'_>,
71        propensity: &mut antecedent_stats::PropensityWorkspace,
72    ) -> Result<RefutationReport, ValidationError> {
73        let (report, replicates) = match &problem.original.overlap_report {
74            Some(r) => (r.clone(), 0),
75            None => (
76                crate::common::diagnostic_overlap_report_with(
77                    problem,
78                    &self.glm_options,
79                    OverlapPolicy::require_diagnostics(),
80                    propensity,
81                )?,
82                1,
83            ),
84        };
85        let nrows = estimation_row_count(problem)? as f64;
86        let Some(ess) = report.ess else {
87            return Ok(RefutationReport {
88                refuter: Arc::from("overlap.assessment"),
89                original_ate: problem.original.ate,
90                refuted_ate: problem.original.ate,
91                comparison: f64::NAN,
92                informative: false,
93                passed: false,
94                failure_condition: Some(Arc::from(
95                    "overlap report has no weights; ESS is undefined",
96                )),
97                replicates,
98            });
99        };
100        let ess_fraction = if nrows > 0.0 { ess / nrows } else { 0.0 };
101        let bounds_ok =
102            report.propensity_min >= self.eps && report.propensity_max <= 1.0 - self.eps;
103        let ess_ok = ess_fraction >= self.min_ess_fraction;
104        let passed = bounds_ok && ess_ok;
105        let comparison = 1.0 - ess_fraction;
106        Ok(RefutationReport {
107            refuter: Arc::from("overlap.assessment"),
108            original_ate: problem.original.ate,
109            refuted_ate: problem.original.ate,
110            comparison,
111            informative: true,
112            passed,
113            failure_condition: if passed {
114                None
115            } else {
116                Some(Arc::from(format!(
117                    "propensity range [{}, {}] or ess_fraction={ess_fraction} failed eps={} / \
118                     min_ess_fraction={}",
119                    report.propensity_min, report.propensity_max, self.eps, self.min_ess_fraction
120                )))
121            },
122            replicates,
123        })
124    }
125}
126
127fn estimation_row_count(problem: &RefutationProblem<'_>) -> Result<usize, ValidationError> {
128    let mut ids = vec![problem.treatment(), problem.outcome()];
129    ids.extend_from_slice(&problem.estimand.adjustment_set);
130    let mask = problem.data.complete_case_mask(&ids).map_err(ValidationError::from)?;
131    Ok(mask.iter().filter(|&&k| k).count())
132}