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