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}