Skip to main content

antecedent_validate/
evalue.rs

1//! E-value sensitivity analysis (`VanderWeele` & Ding, 2017).
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::sync::Arc;
6
7use crate::common::{RefutationProblem, RefutationReport, complete_case_rows, masked_sample_sd};
8use crate::error::ValidationError;
9
10/// Default pass threshold: E ≥ 2 is commonly read as moderate robustness to unmeasured
11/// confounding (`VanderWeele` & Ding 2017). The original E-value formulation reports it as a
12/// continuous diagnostic without a pass/fail gate; this library uses 2.0 so `ValidationSuite` verdicts are not
13/// vacuous (the formula always yields E ≥ 1, so threshold 1.0 would pass every estimate,
14/// including a true null).
15pub const DEFAULT_EVALUE_THRESHOLD: f64 = 2.0;
16
17/// E-value for the point estimate: the minimum strength of association, on the risk-ratio
18/// scale, that an unmeasured confounder would need with both treatment and outcome to fully
19/// explain away the observed effect.
20///
21/// For continuous outcomes this uses the `VanderWeele`/Ding approximate conversion of the
22/// standardized mean difference `d = ATE / SD(Y)` to a risk ratio via `RR = exp(0.91 d)`,
23/// then the standard E-value formula `E = RR + sqrt(RR (RR − 1))` (inverted first if `RR < 1`).
24#[derive(Clone, Debug)]
25pub struct EValue {
26    /// Pass if the computed E-value is at least this large.
27    ///
28    /// Default [`DEFAULT_EVALUE_THRESHOLD`] (2.0) marks moderate robustness. Override via
29    /// [`EValue::with_threshold`] when a different convention is needed; the E-value itself
30    /// is always reported in [`RefutationReport::comparison`] regardless of the gate.
31    pub threshold: f64,
32}
33
34impl Default for EValue {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl EValue {
41    /// Default threshold [`DEFAULT_EVALUE_THRESHOLD`] (2.0 ≈ moderate robustness).
42    #[must_use]
43    pub fn new() -> Self {
44        Self { threshold: DEFAULT_EVALUE_THRESHOLD }
45    }
46
47    /// Explicit pass threshold (for report-only use, set `threshold = 0.0` or ignore
48    /// `passed` and read [`RefutationReport::comparison`]).
49    #[must_use]
50    pub fn with_threshold(threshold: f64) -> Self {
51        Self { threshold }
52    }
53
54    /// Compute the E-value for `problem.original.ate`.
55    ///
56    /// # Errors
57    ///
58    /// The outcome has fewer than 2 valid rows or zero variance.
59    pub fn refute(
60        &self,
61        problem: &RefutationProblem<'_>,
62    ) -> Result<RefutationReport, ValidationError> {
63        let mut ids = vec![problem.treatment(), problem.outcome()];
64        ids.extend_from_slice(&problem.estimand.adjustment_set);
65        let (mask, _valid) = complete_case_rows(problem.data, &ids)?;
66        let sd_y = masked_sample_sd(problem.data, problem.outcome(), &mask)?;
67        if !(sd_y.is_finite() && sd_y > 0.0) {
68            return Err(ValidationError::NotApplicable {
69                message: "e-value requires a finite, positive outcome standard deviation",
70            });
71        }
72        let d = problem.original.ate / sd_y;
73        let rr = (0.91 * d).exp();
74        let e_value = e_value_from_risk_ratio(rr);
75        let passed = e_value >= self.threshold;
76        Ok(RefutationReport {
77            refuter: Arc::from("sensitivity.evalue"),
78            original_ate: problem.original.ate,
79            refuted_ate: problem.original.ate,
80            comparison: e_value,
81            informative: true,
82            passed,
83            failure_condition: if passed {
84                None
85            } else {
86                Some(Arc::from(format!("e-value {e_value} below threshold {}", self.threshold)))
87            },
88            replicates: 0,
89        })
90    }
91}
92
93fn e_value_from_risk_ratio(rr: f64) -> f64 {
94    let rr = if rr >= 1.0 { rr } else { 1.0 / rr };
95    rr + (rr * (rr - 1.0)).sqrt()
96}