Skip to main content

antecedent_validate/
sensitivity.rs

1//! Linear, partial-linear, and nonparametric confounding sensitivity analysis.
2//!
3//! [`LinearSensitivity`] and [`PartialLinearSensitivity`] simulate a confounder `U` with a
4//! configurable *partial R²* on treatment and outcome under a linear (Gaussian) or
5//! partial-linear (bounded) shape. [`NonparametricSensitivity`] first residualizes treatment
6//! and outcome on adjustment covariates with Nadaraya–Watson kernel regression, then runs the
7//! same partial-R² grid on the residualized series — a production nonparametric path distinct
8//! from the partial-linear shape stand-in.
9//!
10//! SPDX-License-Identifier: MIT OR Apache-2.0
11
12#![allow(
13    clippy::cast_possible_truncation,
14    clippy::cast_precision_loss,
15    clippy::many_single_char_names,
16    clippy::similar_names,
17    clippy::float_cmp
18)]
19
20use std::sync::Arc;
21
22use antecedent_core::ExecutionContext;
23use antecedent_data::TableView;
24use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
25
26use crate::common::{
27    RefutationProblem, RefutationReport, complete_case_rows, fill_gaussian, fit_once, float64_full,
28    linear_estimator_no_bootstrap, masked_sample_sd, refit_effect, sample_sd, with_replaced_float,
29};
30use crate::error::ValidationError;
31
32/// Default partial-R² grid, ascending.
33fn default_grid() -> Vec<f64> {
34    vec![0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]
35}
36
37fn run_grid(
38    problem: &RefutationProblem<'_>,
39    workspace: &mut EstimationWorkspace,
40    ctx: &ExecutionContext,
41    estimator: &LinearAdjustmentAte,
42    grid: &[f64],
43    noise_stream: u64,
44    nonparametric: bool,
45) -> Result<(f64, f64, bool), ValidationError> {
46    let n = problem.data.row_count();
47    let t0 = float64_full(problem.data, problem.treatment())?;
48    let y0 = float64_full(problem.data, problem.outcome())?;
49    let mut ids = vec![problem.treatment(), problem.outcome()];
50    if problem.temporal.is_none() {
51        ids.extend_from_slice(&problem.estimand.adjustment_set);
52    }
53    let (mask, _valid) = complete_case_rows(problem.data, &ids)?;
54    let sd_t = masked_sample_sd(problem.data, problem.treatment(), &mask)?.max(1e-12);
55    let sd_y = masked_sample_sd(problem.data, problem.outcome(), &mask)?.max(1e-12);
56    let mut u = vec![0.0; n];
57    if nonparametric {
58        fill_bounded(&mut u, ctx, noise_stream);
59    } else {
60        fill_gaussian(&mut u, ctx, noise_stream);
61    }
62
63    let mut sorted_grid = grid.to_vec();
64    sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
65
66    let original_sign = problem.original.ate.signum();
67    // Worst-case orientation: load the confounder on Y against the observed effect so the
68    // induced omitted-variable bias works to explain the effect away; a same-sign loading
69    // could never flip a positive estimate and would spuriously kill a negative one.
70    let dir = if problem.original.ate >= 0.0 { -1.0 } else { 1.0 };
71    let mut last_ate = problem.original.ate;
72    for &r in &sorted_grid {
73        let r = r.clamp(0.0, 0.999);
74        let scale = (r / (1.0 - r)).sqrt();
75        let t: Vec<f64> = t0.iter().zip(&u).map(|(&t, &u)| t + scale * sd_t * u).collect();
76        let y: Vec<f64> = y0.iter().zip(&u).map(|(&y, &u)| y + dir * scale * sd_y * u).collect();
77        let data = with_replaced_float(problem.data, problem.treatment(), Arc::from(t))?;
78        let data = with_replaced_float(&data, problem.outcome(), Arc::from(y))?;
79        let est = if problem.temporal.is_some() {
80            refit_effect(problem, &data, problem.estimand, &[], workspace, ctx)?
81        } else {
82            fit_once(estimator, &data, problem.estimand, problem.query, workspace, ctx)?
83        };
84        last_ate = est.ate;
85        let explained_away = est.ate.abs() < 1e-9 || est.ate.signum() != original_sign;
86        if explained_away {
87            return Ok((r, last_ate, true));
88        }
89    }
90    let robustness_value = sorted_grid.last().copied().unwrap_or(1.0);
91    Ok((robustness_value, last_ate, false))
92}
93
94fn fill_bounded(out: &mut [f64], ctx: &ExecutionContext, stream_id: u64) {
95    // Uniform on [-√3, √3): unit variance, so the partial-R² grid calibration derived for
96    // a standardized confounder holds for the bounded shape too.
97    let mut rng = ctx.rng.stream(stream_id);
98    let sqrt3 = 3.0_f64.sqrt();
99    for slot in out.iter_mut() {
100        *slot = rng.next_f64().mul_add(2.0, -1.0) * sqrt3;
101    }
102}
103
104/// Linear confounding sensitivity: simulated Gaussian confounder with configurable partial R².
105#[derive(Clone, Debug)]
106pub struct LinearSensitivity {
107    /// Ascending grid of partial-R² values to test (shared for treatment and outcome).
108    pub partial_r2_grid: Vec<f64>,
109    /// Pass if the robustness value exceeds this threshold (harder to explain away).
110    pub pass_threshold: f64,
111    /// Estimator used for refits (bootstrap disabled).
112    pub estimator: LinearAdjustmentAte,
113}
114
115impl Default for LinearSensitivity {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl LinearSensitivity {
122    /// Defaults: grid `[0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]`, pass threshold 0.1.
123    #[must_use]
124    pub fn new() -> Self {
125        Self {
126            partial_r2_grid: default_grid(),
127            pass_threshold: 0.1,
128            estimator: linear_estimator_no_bootstrap(),
129        }
130    }
131
132    /// Run the linear sensitivity refuter.
133    ///
134    /// # Errors
135    ///
136    /// Data or estimation failures, or an empty `partial_r2_grid`.
137    pub fn refute(
138        &self,
139        problem: &RefutationProblem<'_>,
140        workspace: &mut EstimationWorkspace,
141        ctx: &ExecutionContext,
142    ) -> Result<RefutationReport, ValidationError> {
143        if self.partial_r2_grid.is_empty() {
144            return Err(ValidationError::NotApplicable {
145                message: "linear sensitivity requires a non-empty partial_r2_grid",
146            });
147        }
148        let (robustness_value, refuted_ate, _explained_away) = run_grid(
149            problem,
150            workspace,
151            ctx,
152            &self.estimator,
153            &self.partial_r2_grid,
154            0xA7E0_000A_0000_u64,
155            false,
156        )?;
157        let passed = robustness_value >= self.pass_threshold;
158        Ok(RefutationReport {
159            refuter: Arc::from("sensitivity.linear"),
160            original_ate: problem.original.ate,
161            refuted_ate,
162            comparison: robustness_value,
163            informative: true,
164            passed,
165            failure_condition: if passed {
166                None
167            } else {
168                Some(Arc::from(format!(
169                    "effect explained away at partial R²={robustness_value}, below threshold {}",
170                    self.pass_threshold
171                )))
172            },
173            replicates: self.partial_r2_grid.len() as u32,
174        })
175    }
176}
177
178/// Partial-linear sensitivity: same grid as [`LinearSensitivity`] with a bounded uniform
179/// confounder shape (partial-linear misspecification), not a nonparametric residualization path.
180#[derive(Clone, Debug)]
181pub struct PartialLinearSensitivity {
182    /// Ascending grid of partial-R² values to test (shared for treatment and outcome).
183    pub partial_r2_grid: Vec<f64>,
184    /// Pass if the robustness value exceeds this threshold (harder to explain away).
185    pub pass_threshold: f64,
186    /// Estimator used for refits (bootstrap disabled).
187    pub estimator: LinearAdjustmentAte,
188}
189
190impl Default for PartialLinearSensitivity {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl PartialLinearSensitivity {
197    /// Defaults: grid `[0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]`, pass threshold 0.1.
198    #[must_use]
199    pub fn new() -> Self {
200        Self {
201            partial_r2_grid: default_grid(),
202            pass_threshold: 0.1,
203            estimator: linear_estimator_no_bootstrap(),
204        }
205    }
206
207    /// Run the partial-linear sensitivity refuter.
208    ///
209    /// # Errors
210    ///
211    /// Data or estimation failures, or an empty `partial_r2_grid`.
212    pub fn refute(
213        &self,
214        problem: &RefutationProblem<'_>,
215        workspace: &mut EstimationWorkspace,
216        ctx: &ExecutionContext,
217    ) -> Result<RefutationReport, ValidationError> {
218        if self.partial_r2_grid.is_empty() {
219            return Err(ValidationError::NotApplicable {
220                message: "partial-linear sensitivity requires a non-empty partial_r2_grid",
221            });
222        }
223        let (robustness_value, refuted_ate, _explained_away) = run_grid(
224            problem,
225            workspace,
226            ctx,
227            &self.estimator,
228            &self.partial_r2_grid,
229            0xA7E0_000B_0000_u64,
230            true,
231        )?;
232        let passed = robustness_value >= self.pass_threshold;
233        Ok(RefutationReport {
234            refuter: Arc::from("sensitivity.partial_linear"),
235            original_ate: problem.original.ate,
236            refuted_ate,
237            comparison: robustness_value,
238            informative: true,
239            passed,
240            failure_condition: if passed {
241                None
242            } else {
243                Some(Arc::from(format!(
244                    "effect explained away at partial R²={robustness_value}, below threshold {}",
245                    self.pass_threshold
246                )))
247            },
248            replicates: self.partial_r2_grid.len() as u32,
249        })
250    }
251}
252
253/// Nadaraya–Watson leave-one-out prediction of `y` on covariate rows (`n × dim`, row-major).
254fn nw_loo_predict(y: &[f64], cov_rowmajor: &[f64], dim: usize, bandwidth: f64) -> Vec<f64> {
255    let n = y.len();
256    let h2 = (bandwidth.max(1e-6)).powi(2);
257    let mut out = vec![0.0; n];
258    for i in 0..n {
259        let xi = &cov_rowmajor[i * dim..(i + 1) * dim];
260        let mut num = 0.0;
261        let mut den = 0.0;
262        for j in 0..n {
263            if i == j {
264                continue;
265            }
266            let xj = &cov_rowmajor[j * dim..(j + 1) * dim];
267            let mut d2 = 0.0;
268            for d in 0..dim {
269                let t = xi[d] - xj[d];
270                d2 += t * t;
271            }
272            let w = (-0.5 * d2 / h2).exp();
273            num += w * y[j];
274            den += w;
275        }
276        out[i] = if den > 1e-15 { num / den } else { y[i] };
277    }
278    out
279}
280
281fn covariate_matrix(
282    problem: &RefutationProblem<'_>,
283) -> Result<(Vec<f64>, usize, usize), ValidationError> {
284    let ids = problem.estimand.adjustment_set.to_vec();
285    let mut all = ids.clone();
286    all.push(problem.treatment());
287    all.push(problem.outcome());
288    let mask = problem.data.complete_case_mask(&all).map_err(ValidationError::from)?;
289    let n = mask.iter().filter(|&&k| k).count();
290    if ids.is_empty() {
291        return Ok((vec![1.0; n], n, 1));
292    }
293    let dim = ids.len();
294    let mut cov = vec![0.0; n * dim];
295    for (c, &z) in ids.iter().enumerate() {
296        let col = problem.data.float64_masked(z, &mask).map_err(ValidationError::from)?;
297        for (r, &v) in col.iter().enumerate() {
298            cov[r * dim + c] = v;
299        }
300    }
301    Ok((cov, n, dim))
302}
303
304fn silverman_bandwidth(cov_rowmajor: &[f64], n: usize, dim: usize) -> f64 {
305    if n == 0 || dim == 0 {
306        return 1.0;
307    }
308    let mut sum_sd = 0.0;
309    for d in 0..dim {
310        let mut vals = Vec::with_capacity(n);
311        for r in 0..n {
312            vals.push(cov_rowmajor[r * dim + d]);
313        }
314        sum_sd += sample_sd(&vals);
315    }
316    let mean_sd = (sum_sd / dim as f64).max(1e-6);
317    mean_sd * (n as f64).powf(-1.0 / (dim as f64 + 4.0))
318}
319
320/// Nonparametric sensitivity: kernel-residualize T and Y on Z, then partial-R² grid on residuals.
321#[derive(Clone, Debug)]
322pub struct NonparametricSensitivity {
323    /// Ascending grid of partial-R² values to test on residualized series.
324    pub partial_r2_grid: Vec<f64>,
325    /// Pass if the robustness value exceeds this threshold.
326    pub pass_threshold: f64,
327    /// Optional bandwidth override; `None` uses Silverman's rule of thumb.
328    pub bandwidth: Option<f64>,
329}
330
331impl Default for NonparametricSensitivity {
332    fn default() -> Self {
333        Self::new()
334    }
335}
336
337impl NonparametricSensitivity {
338    /// Defaults: same partial-R² grid as linear sensitivity, pass threshold 0.1.
339    #[must_use]
340    pub fn new() -> Self {
341        Self { partial_r2_grid: default_grid(), pass_threshold: 0.1, bandwidth: None }
342    }
343
344    /// Run nonparametric sensitivity.
345    ///
346    /// # Errors
347    ///
348    /// Data failures or empty `partial_r2_grid`.
349    pub fn refute(
350        &self,
351        problem: &RefutationProblem<'_>,
352        _workspace: &mut EstimationWorkspace,
353        ctx: &ExecutionContext,
354    ) -> Result<RefutationReport, ValidationError> {
355        if self.partial_r2_grid.is_empty() {
356            return Err(ValidationError::NotApplicable {
357                message: "nonparametric sensitivity requires a non-empty partial_r2_grid",
358            });
359        }
360        let (cov, n, dim) = covariate_matrix(problem)?;
361        let mut ids = problem.estimand.adjustment_set.to_vec();
362        ids.push(problem.treatment());
363        ids.push(problem.outcome());
364        let mask = problem.data.complete_case_mask(&ids).map_err(ValidationError::from)?;
365        let t = problem
366            .data
367            .float64_masked(problem.treatment(), &mask)
368            .map_err(ValidationError::from)?;
369        let y =
370            problem.data.float64_masked(problem.outcome(), &mask).map_err(ValidationError::from)?;
371        if t.len() != n || y.len() != n {
372            return Err(ValidationError::data_msg("nonparametric sensitivity row mismatch"));
373        }
374        let h = self.bandwidth.unwrap_or_else(|| silverman_bandwidth(&cov, n, dim));
375        let t_hat = nw_loo_predict(&t, &cov, dim, h);
376        let y_hat = nw_loo_predict(&y, &cov, dim, h);
377        let t_res: Vec<f64> = t.iter().zip(&t_hat).map(|(&a, &b)| a - b).collect();
378        let y_res: Vec<f64> = y.iter().zip(&y_hat).map(|(&a, &b)| a - b).collect();
379
380        let residual_ate = residual_ols_ate(&t_res, &y_res);
381        let sd_t = sample_sd(&t_res).max(1e-12);
382        let sd_y = sample_sd(&y_res).max(1e-12);
383        let mut u = vec![0.0; n];
384        fill_gaussian(&mut u, ctx, 0xA7E0_000C_0000_u64);
385
386        let mut sorted_grid = self.partial_r2_grid.clone();
387        sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
388        let original_sign = residual_ate.signum();
389        // Worst-case orientation, as in `run_grid`: load U on Y against the observed sign.
390        let dir = if residual_ate >= 0.0 { -1.0 } else { 1.0 };
391        let mut last_ate = residual_ate;
392        let mut robustness_value = sorted_grid.last().copied().unwrap_or(1.0);
393        for &r in &sorted_grid {
394            let r = r.clamp(0.0, 0.999);
395            let scale = (r / (1.0 - r)).sqrt();
396            let t_pert: Vec<f64> =
397                t_res.iter().zip(&u).map(|(&tv, &uu)| tv + scale * sd_t * uu).collect();
398            let y_pert: Vec<f64> =
399                y_res.iter().zip(&u).map(|(&yv, &uu)| yv + dir * scale * sd_y * uu).collect();
400            last_ate = residual_ols_ate(&t_pert, &y_pert);
401            if last_ate.abs() < 1e-9 || last_ate.signum() != original_sign {
402                robustness_value = r;
403                break;
404            }
405        }
406        let passed = robustness_value >= self.pass_threshold;
407        Ok(RefutationReport {
408            refuter: Arc::from("sensitivity.nonparametric"),
409            original_ate: problem.original.ate,
410            refuted_ate: last_ate,
411            comparison: robustness_value,
412            informative: true,
413            passed,
414            failure_condition: if passed {
415                None
416            } else {
417                Some(Arc::from(format!(
418                    "nonparametric residual effect explained away at partial R²={robustness_value}, \
419                     below threshold {}",
420                    self.pass_threshold
421                )))
422            },
423            replicates: self.partial_r2_grid.len() as u32,
424        })
425    }
426}
427
428fn residual_ols_ate(t: &[f64], y: &[f64]) -> f64 {
429    let n = t.len() as f64;
430    if n < 2.0 {
431        return f64::NAN;
432    }
433    let mean_t = t.iter().sum::<f64>() / n;
434    let mean_y = y.iter().sum::<f64>() / n;
435    let mut num = 0.0;
436    let mut den = 0.0;
437    for (&ti, &yi) in t.iter().zip(y) {
438        let dt = ti - mean_t;
439        num += dt * (yi - mean_y);
440        den += dt * dt;
441    }
442    if den < 1e-15 { 0.0 } else { num / den }
443}