Skip to main content

dml_core/
sensitivity.rs

1//! Sensitivity analysis for omitted confounders (Chernozhukov, Cinelli, Newey,
2//! Sharma & Syrgkanis, 2022, "Long Story Short"), as in DoubleML.
3//!
4//! If an unobserved confounder `A` was omitted, the estimate is biased by at
5//! most
6//!
7//! ```text
8//! |bias| ≤ |ρ| · √(c_y · c_d / (1 − c_d)) · √(σ² · ν²)
9//! ```
10//!
11//! where `σ²` is the residual variance of the outcome, `ν²` the variance of the
12//! Riesz representer, `c_y` the share of the outcome's residual variation
13//! explained by `A`, `c_d` the relative gain in the Riesz representer's
14//! variation from `A`, and `ρ` the correlation between the two (adversarial at
15//! 1). [`DmlFit::sensitivity_analysis`] turns a scenario `(c_y, c_d, ρ)` into
16//! bounds on θ with confidence bounds, and computes robustness values: how
17//! strong confounding (`c_y = c_d`) must be to move θ (RV) or its confidence
18//! bound (RVa) to the null hypothesis.
19
20use std::fmt;
21
22use ndarray::{Array1, Array2, Array3, ArrayView2};
23
24use crate::error::{DmlError, Result};
25use crate::fit::{aggregate, DmlFit};
26use crate::stats::{median, norm_ppf};
27
28/// How a model's `ν²` is formed from per-observation elements.
29#[derive(Debug, Clone)]
30pub enum Nu2Parts {
31    /// `ν² = mean(e)` with per-observation elements `e = 2·m(W, α) − α²`.
32    Elementwise(Array1<f64>),
33    /// Partially linear models: `ν² = 1 / mean(v²)` for the treatment residual
34    /// `v`, with Riesz representer `α = v · ν²`.
35    InverseMeanSquare(Array1<f64>),
36}
37
38/// The per-observation ingredients of a model's sensitivity elements, produced
39/// while estimating the score.
40///
41/// The outcome residual is `a − θ·b` (`b` only for partially linear models,
42/// whose residual depends on θ).
43#[derive(Debug, Clone)]
44pub struct SensitivityParts {
45    pub outcome_residual: Array1<f64>,
46    pub theta_factor: Option<Array1<f64>>,
47    /// Riesz representer `α`; ignored for [`Nu2Parts::InverseMeanSquare`],
48    /// where it follows from `v`.
49    pub riesz: Array1<f64>,
50    pub nu2: Nu2Parts,
51}
52
53/// Sensitivity elements of one treatment and repetition.
54pub(crate) struct Elements {
55    pub sigma2: f64,
56    pub nu2: f64,
57    pub psi_sigma2: Array1<f64>,
58    pub psi_nu2: Array1<f64>,
59    /// Riesz representer, for the fallback when `ν²` is not positive.
60    pub riesz: Option<Array1<f64>>,
61    /// The orthogonal `ν²` was not positive (and fell back to `mean(α²)`).
62    pub nu2_fallback: bool,
63}
64
65impl SensitivityParts {
66    /// `σ²`, `ν²` and their influence functions at the estimate θ. A non-positive
67    /// `ν²` falls back to the (non-orthogonal) `mean(α²)`, as DoubleML does.
68    pub(crate) fn elements(&self, theta: f64) -> Elements {
69        let resid = match &self.theta_factor {
70            Some(b) => &self.outcome_residual - &(theta * b),
71            None => self.outcome_residual.clone(),
72        };
73        let sigma_el = resid.mapv(|e| e * e);
74        let sigma2 = sigma_el.mean().expect("n > 0");
75        let (nu2, psi_nu2, riesz) = match &self.nu2 {
76            Nu2Parts::Elementwise(e) => {
77                let nu2 = e.mean().expect("n > 0");
78                (nu2, e - nu2, self.riesz.clone())
79            }
80            Nu2Parts::InverseMeanSquare(v) => {
81                let v2 = v.mapv(|x| x * x);
82                let nu2 = 1.0 / v2.mean().expect("n > 0");
83                (nu2, v2.mapv(|s| nu2 - s * nu2 * nu2), v * nu2)
84            }
85        };
86        let nu2_fallback = nu2 <= 0.0;
87        let (nu2, psi_nu2) = if nu2_fallback {
88            let alpha2 = riesz.mapv(|a| a * a);
89            (alpha2.mean().expect("n > 0"), alpha2)
90        } else {
91            (nu2, psi_nu2)
92        };
93        Elements {
94            sigma2,
95            nu2,
96            psi_sigma2: sigma_el - sigma2,
97            psi_nu2,
98            riesz: Some(riesz),
99            nu2_fallback,
100        }
101    }
102}
103
104/// Stored sensitivity elements of a fit, per treatment `j` and repetition `r`.
105#[derive(Debug, Clone)]
106pub(crate) struct SensitivityStore {
107    /// Shape `(k, n_rep)`.
108    pub sigma2: Array2<f64>,
109    pub nu2: Array2<f64>,
110    pub max_bias: Array2<f64>,
111    /// Shape `(n, k, n_rep)`.
112    pub psi_max_bias: Array3<f64>,
113    /// Influence functions of `σ²` and `ν²`, shape `(n, k, n_rep)` (to combine
114    /// the elements of several estimates, e.g. APO contrasts).
115    pub psi_sigma2: Array3<f64>,
116    pub psi_nu2: Array3<f64>,
117    /// Riesz representers, shape `(n, k, n_rep)`.
118    pub riesz: Array3<f64>,
119    /// Estimates whose orthogonal `ν²` was not positive in some repetition.
120    pub nu2_fallback: Vec<bool>,
121    /// Population variance of the outcome (for benchmarking).
122    pub var_y: f64,
123}
124
125impl SensitivityStore {
126    pub(crate) fn new(n: usize, k: usize, n_rep: usize, var_y: f64) -> Self {
127        Self {
128            sigma2: Array2::zeros((k, n_rep)),
129            nu2: Array2::zeros((k, n_rep)),
130            max_bias: Array2::zeros((k, n_rep)),
131            psi_max_bias: Array3::zeros((n, k, n_rep)),
132            psi_sigma2: Array3::zeros((n, k, n_rep)),
133            psi_nu2: Array3::zeros((n, k, n_rep)),
134            riesz: Array3::zeros((n, k, n_rep)),
135            nu2_fallback: vec![false; k],
136            var_y,
137        }
138    }
139
140    /// The stored elements of estimate `j` in repetition `r`.
141    pub(crate) fn elements(&self, j: usize, r: usize) -> Elements {
142        Elements {
143            sigma2: self.sigma2[[j, r]],
144            nu2: self.nu2[[j, r]],
145            psi_sigma2: self.psi_sigma2.slice(ndarray::s![.., j, r]).to_owned(),
146            psi_nu2: self.psi_nu2.slice(ndarray::s![.., j, r]).to_owned(),
147            riesz: Some(self.riesz.slice(ndarray::s![.., j, r]).to_owned()),
148            nu2_fallback: self.nu2_fallback[j],
149        }
150    }
151
152    /// Stack stores of fits on the same observations along the estimates.
153    pub(crate) fn concat(stores: &[&SensitivityStore]) -> Self {
154        let cat2 = |f: fn(&SensitivityStore) -> &Array2<f64>| {
155            let views: Vec<_> = stores.iter().map(|s| f(s).view()).collect();
156            ndarray::concatenate(ndarray::Axis(0), &views).expect("same n_rep")
157        };
158        let cat3 = |f: fn(&SensitivityStore) -> &Array3<f64>| {
159            let views: Vec<_> = stores.iter().map(|s| f(s).view()).collect();
160            ndarray::concatenate(ndarray::Axis(1), &views).expect("same n and n_rep")
161        };
162        Self {
163            sigma2: cat2(|s| &s.sigma2),
164            nu2: cat2(|s| &s.nu2),
165            max_bias: cat2(|s| &s.max_bias),
166            psi_max_bias: cat3(|s| &s.psi_max_bias),
167            psi_sigma2: cat3(|s| &s.psi_sigma2),
168            psi_nu2: cat3(|s| &s.psi_nu2),
169            riesz: cat3(|s| &s.riesz),
170            nu2_fallback: stores
171                .iter()
172                .flat_map(|s| s.nu2_fallback.iter().copied())
173                .collect(),
174            var_y: stores[0].var_y,
175        }
176    }
177
178    /// Record one treatment / repetition: `max_bias = √(σ²ν²)` and its
179    /// influence function `(σ²·ψ_ν² + ν²·ψ_σ²) / (2·max_bias)`.
180    pub(crate) fn set(&mut self, j: usize, r: usize, e: &Elements) {
181        if let Some(riesz) = &e.riesz {
182            self.riesz.slice_mut(ndarray::s![.., j, r]).assign(riesz);
183        }
184        self.nu2_fallback[j] |= e.nu2_fallback;
185        let max_bias = (e.sigma2 * e.nu2).sqrt();
186        self.sigma2[[j, r]] = e.sigma2;
187        self.nu2[[j, r]] = e.nu2;
188        self.max_bias[[j, r]] = max_bias;
189        let psi = (e.sigma2 * &e.psi_nu2 + e.nu2 * &e.psi_sigma2) / (2.0 * max_bias);
190        self.psi_max_bias
191            .slice_mut(ndarray::s![.., j, r])
192            .assign(&psi);
193        self.psi_sigma2
194            .slice_mut(ndarray::s![.., j, r])
195            .assign(&e.psi_sigma2);
196        self.psi_nu2
197            .slice_mut(ndarray::s![.., j, r])
198            .assign(&e.psi_nu2);
199    }
200
201    /// DoubleML's rule after all repetitions: if `ν²` was not positive in any
202    /// repetition of an estimate, every repetition of it uses the
203    /// (non-orthogonal) `mean(α²)`, with `α²` as its influence function (as
204    /// DoubleML, which leaves it uncentred, a conservative choice).
205    pub(crate) fn validate(&mut self) {
206        for j in 0..self.nu2_fallback.len() {
207            if !self.nu2_fallback[j] {
208                continue;
209            }
210            for r in 0..self.nu2.ncols() {
211                let alpha2 = self.riesz.slice(ndarray::s![.., j, r]).mapv(|a| a * a);
212                let e = Elements {
213                    sigma2: self.sigma2[[j, r]],
214                    nu2: alpha2.mean().expect("n > 0"),
215                    psi_sigma2: self.psi_sigma2.slice(ndarray::s![.., j, r]).to_owned(),
216                    psi_nu2: alpha2,
217                    riesz: None,
218                    nu2_fallback: true,
219                };
220                self.set(j, r, &e);
221            }
222        }
223    }
224}
225
226/// A confounding scenario for [`DmlFit::sensitivity_analysis`].
227#[derive(Debug, Clone, PartialEq)]
228pub struct SensitivityParams {
229    pub cf_y: f64,
230    pub cf_d: f64,
231    pub rho: f64,
232    pub level: f64,
233    /// One null hypothesis per treatment, or a single value for all.
234    pub null_hypothesis: Vec<f64>,
235}
236
237impl Default for SensitivityParams {
238    /// DoubleML's defaults: `c_y = c_d = 0.03`, `ρ = 1`, level 0.95, `H0: θ = 0`.
239    fn default() -> Self {
240        Self {
241            cf_y: 0.03,
242            cf_d: 0.03,
243            rho: 1.0,
244            level: 0.95,
245            null_hypothesis: vec![0.0],
246        }
247    }
248}
249
250impl SensitivityParams {
251    pub fn new() -> Self {
252        Self::default()
253    }
254
255    /// Share of the outcome's residual variation explained by the confounder.
256    pub fn cf_y(mut self, cf_y: f64) -> Self {
257        self.cf_y = cf_y;
258        self
259    }
260
261    /// Relative gain in the Riesz representer's variation from the confounder.
262    pub fn cf_d(mut self, cf_d: f64) -> Self {
263        self.cf_d = cf_d;
264        self
265    }
266
267    /// Correlation of the two confounding channels, in `[−1, 1]`.
268    pub fn rho(mut self, rho: f64) -> Self {
269        self.rho = rho;
270        self
271    }
272
273    /// Level of the one-sided confidence bounds.
274    pub fn level(mut self, level: f64) -> Self {
275        self.level = level;
276        self
277    }
278
279    pub fn null_hypothesis(mut self, value: f64) -> Self {
280        self.null_hypothesis = vec![value];
281        self
282    }
283
284    pub fn null_hypotheses(mut self, values: Vec<f64>) -> Self {
285        self.null_hypothesis = values;
286        self
287    }
288
289    fn validate(&self, k: usize) -> Result<()> {
290        let open01 = |v: f64| (0.0..1.0).contains(&v);
291        if !open01(self.cf_y) || !open01(self.cf_d) {
292            return Err(DmlError::InvalidArgument(
293                "cf_y and cf_d must be in [0, 1)".into(),
294            ));
295        }
296        if self.rho.is_nan() || self.rho.abs() > 1.0 {
297            return Err(DmlError::InvalidArgument("rho must be in [-1, 1]".into()));
298        }
299        if !(self.level > 0.0 && self.level < 1.0) {
300            return Err(DmlError::InvalidArgument("level must be in (0, 1)".into()));
301        }
302        if self.null_hypothesis.len() != 1 && self.null_hypothesis.len() != k {
303            return Err(DmlError::DimensionMismatch(format!(
304                "give one null hypothesis or one per treatment ({k})"
305            )));
306        }
307        Ok(())
308    }
309}
310
311/// Bounds under one confounding scenario (per treatment).
312#[derive(Debug, Clone)]
313struct Bounds {
314    theta: (Array1<f64>, Array1<f64>),
315    se: (Array1<f64>, Array1<f64>),
316    ci: (Array1<f64>, Array1<f64>),
317}
318
319/// Result of [`DmlFit::sensitivity_analysis`].
320#[derive(Debug, Clone)]
321pub struct SensitivityResult {
322    pub params: SensitivityParams,
323    treatment_names: Vec<String>,
324    theta: Array1<f64>,
325    pub theta_lower: Array1<f64>,
326    pub theta_upper: Array1<f64>,
327    pub se_lower: Array1<f64>,
328    pub se_upper: Array1<f64>,
329    /// One-sided confidence bounds at `level`.
330    pub ci_lower: Array1<f64>,
331    pub ci_upper: Array1<f64>,
332    /// Robustness value: confounding strength `c_y = c_d` that moves θ to the null.
333    pub rv: Array1<f64>,
334    /// Robustness value for the confidence bound.
335    pub rva: Array1<f64>,
336}
337
338/// Largest confounding strength searched for robustness values (as DoubleML).
339const RV_MAX: f64 = 0.9999;
340
341impl DmlFit {
342    /// Bounds on θ, their confidence bounds and robustness values under the
343    /// confounding scenario `params` (PLR, IRM and DID models).
344    pub fn sensitivity_analysis(&self, params: &SensitivityParams) -> Result<SensitivityResult> {
345        let store = self.sensitivity_store()?;
346        let k = self.n_treat();
347        params.validate(k)?;
348        let b = self.bounds(store, params.cf_y, params.cf_d, params.rho, params.level);
349        let null = |j: usize| {
350            params.null_hypothesis[if params.null_hypothesis.len() == 1 {
351                0
352            } else {
353                j
354            }]
355        };
356        let mut rv = Array1::zeros(k);
357        let mut rva = Array1::zeros(k);
358        for j in 0..k {
359            let h0 = null(j);
360            let upper = h0 > self.coef[j];
361            let theta_at = |v: f64| {
362                let b = self.bounds(store, v, v, params.rho, params.level);
363                if upper {
364                    b.theta.1[j]
365                } else {
366                    b.theta.0[j]
367                }
368            };
369            let ci_at = |v: f64| {
370                let b = self.bounds(store, v, v, params.rho, params.level);
371                if upper {
372                    b.ci.1[j]
373                } else {
374                    b.ci.0[j]
375                }
376            };
377            rv[j] = robustness_value(theta_at, h0, upper);
378            rva[j] = robustness_value(ci_at, h0, upper);
379        }
380        Ok(SensitivityResult {
381            params: SensitivityParams {
382                null_hypothesis: (0..k).map(null).collect(),
383                ..params.clone()
384            },
385            treatment_names: self.treatment_names.clone(),
386            theta: self.coef.clone(),
387            theta_lower: b.theta.0,
388            theta_upper: b.theta.1,
389            se_lower: b.se.0,
390            se_upper: b.se.1,
391            ci_lower: b.ci.0,
392            ci_upper: b.ci.1,
393            rv,
394            rva,
395        })
396    }
397
398    /// Sensitivity elements `(σ², ν²)`, each of shape `(n_treat, n_rep)`:
399    /// the residual variance of the outcome and the variance of the Riesz
400    /// representer. `None` for models without sensitivity analysis.
401    pub fn sensitivity_elements(&self) -> Option<(ArrayView2<'_, f64>, ArrayView2<'_, f64>)> {
402        self.sensitivity
403            .as_ref()
404            .map(|s| (s.sigma2.view(), s.nu2.view()))
405    }
406
407    fn sensitivity_store(&self) -> Result<&SensitivityStore> {
408        self.sensitivity.as_ref().ok_or_else(|| {
409            DmlError::InvalidArgument(
410                "sensitivity analysis is available for PLR, IRM and DID fits".into(),
411            )
412        })
413    }
414
415    /// DoubleML's `_calc_sensitivity_analysis`.
416    fn bounds(
417        &self,
418        store: &SensitivityStore,
419        cf_y: f64,
420        cf_d: f64,
421        rho: f64,
422        level: f64,
423    ) -> Bounds {
424        let strength = rho.abs() * (cf_y * cf_d / (1.0 - cf_d)).sqrt();
425        let (n, k, n_rep) = self.psi.dim();
426        let mut theta_lo = Array2::zeros((k, n_rep));
427        let mut theta_hi = Array2::zeros((k, n_rep));
428        let mut se_lo = Array2::zeros((k, n_rep));
429        let mut se_hi = Array2::zeros((k, n_rep));
430        for j in 0..k {
431            for r in 0..n_rep {
432                let shift = strength * store.max_bias[[j, r]];
433                theta_lo[[j, r]] = self.all_coef[[j, r]] - shift;
434                theta_hi[[j, r]] = self.all_coef[[j, r]] + shift;
435                let jac = self.psi_deriv[[j, r]];
436                let scaled = self.psi.slice(ndarray::s![.., j, r]).mapv(|v| v / jac);
437                let pmb = store
438                    .psi_max_bias
439                    .slice(ndarray::s![.., j, r])
440                    .mapv(|v| strength * v);
441                let (psi_lo, psi_hi) = (&scaled - &pmb, &scaled + &pmb);
442                let variance = |psi: &Array1<f64>| match &self.cluster {
443                    Some(ci) => ci.variance(psi.view(), Array1::ones(n).view(), r),
444                    None => psi.dot(psi) / n as f64 / n as f64,
445                };
446                se_lo[[j, r]] = variance(&psi_lo).sqrt();
447                se_hi[[j, r]] = variance(&psi_hi).sqrt();
448            }
449        }
450        let quant = norm_ppf(level);
451        let ci = |theta: &Array2<f64>, se: &Array2<f64>, sign: f64| -> Array1<f64> {
452            (0..k)
453                .map(|j| {
454                    let v: Vec<f64> = (0..n_rep)
455                        .map(|r| theta[[j, r]] + sign * quant * se[[j, r]])
456                        .collect();
457                    median(&v)
458                })
459                .collect()
460        };
461        let ci_lo = ci(&theta_lo, &se_lo, -1.0);
462        let ci_hi = ci(&theta_hi, &se_hi, 1.0);
463        let (t_lo, s_lo) = aggregate(&theta_lo, &se_lo);
464        let (t_hi, s_hi) = aggregate(&theta_hi, &se_hi);
465        Bounds {
466            theta: (t_lo, t_hi),
467            se: (s_lo, s_hi),
468            ci: (ci_lo, ci_hi),
469        }
470    }
471
472    /// Calibrate a confounding scenario against observed covariates: `short`
473    /// must be the same model refitted **on the same sample splits** without
474    /// the benchmarking covariates (see `sensitivity_benchmark` on the models).
475    pub fn sensitivity_benchmark(&self, short: &DmlFit) -> Result<SensitivityBenchmark> {
476        let long = self.sensitivity_store()?;
477        let short_store = short.sensitivity_store()?;
478        if short.all_coef.dim() != self.all_coef.dim() {
479            return Err(DmlError::DimensionMismatch(
480                "long and short fits need the same treatments and repetitions".into(),
481            ));
482        }
483        let (k, n_rep) = self.all_coef.dim();
484        let (mut cf_y, mut cf_d, mut rho, mut delta) = (
485            Array1::zeros(k),
486            Array1::zeros(k),
487            Array1::zeros(k),
488            Array1::zeros(k),
489        );
490        for j in 0..k {
491            let mut cy = Vec::with_capacity(n_rep);
492            let mut cd = Vec::with_capacity(n_rep);
493            let mut rh = Vec::with_capacity(n_rep);
494            let mut dt = Vec::with_capacity(n_rep);
495            for r in 0..n_rep {
496                let (s_long, s_short) = (long.sigma2[[j, r]], short_store.sigma2[[j, r]]);
497                let (n_long, n_short) = (long.nu2[[j, r]], short_store.nu2[[j, r]]);
498                let r2_long = 1.0 - s_long / long.var_y;
499                let r2_short = 1.0 - s_short / long.var_y;
500                let r2_riesz = n_short / n_long;
501                cy.push(((r2_long - r2_short) / (1.0 - r2_long)).clamp(0.0, 1.0));
502                cd.push(((1.0 - r2_riesz) / r2_riesz).clamp(0.0, 1.0));
503                let d_theta = short.all_coef[[j, r]] - self.all_coef[[j, r]];
504                dt.push(d_theta);
505                let (var_g, var_riesz) = (s_short - s_long, n_long - n_short);
506                let denom = if var_g > 0.0 && var_riesz > 0.0 {
507                    (var_g * var_riesz).sqrt()
508                } else {
509                    0.0
510                };
511                let magnitude = if denom != 0.0 {
512                    (d_theta.abs() / denom).clamp(0.0, 1.0)
513                } else {
514                    1.0
515                };
516                let sign = if d_theta > 0.0 {
517                    1.0
518                } else if d_theta < 0.0 {
519                    -1.0
520                } else {
521                    0.0
522                };
523                rh.push(magnitude * sign);
524            }
525            cf_y[j] = median(&cy);
526            cf_d[j] = median(&cd);
527            rho[j] = median(&rh);
528            delta[j] = median(&dt);
529        }
530        Ok(SensitivityBenchmark {
531            treatment_names: self.treatment_names.clone(),
532            cf_y,
533            cf_d,
534            rho,
535            delta_theta: delta,
536        })
537    }
538}
539
540/// Smallest `v ∈ [0, 0.9999]` at which `bound(v)` reaches `h0` (bisection on a
541/// bound that moves monotonically towards the null as confounding grows).
542/// Returns 0 if the bound already reaches the null without confounding and
543/// 0.9999 if it never does, matching DoubleML's bounded search.
544fn robustness_value(bound: impl Fn(f64) -> f64, h0: f64, upper: bool) -> f64 {
545    // gap > 0 while the bound has not yet crossed the null.
546    let gap = |v: f64| if upper { h0 - bound(v) } else { bound(v) - h0 };
547    if gap(0.0) <= 0.0 {
548        return 0.0;
549    }
550    if gap(RV_MAX) > 0.0 {
551        return RV_MAX;
552    }
553    let (mut lo, mut hi) = (0.0, RV_MAX);
554    for _ in 0..100 {
555        let mid = 0.5 * (lo + hi);
556        if gap(mid) > 0.0 {
557            lo = mid;
558        } else {
559            hi = mid;
560        }
561        if hi - lo < 1e-13 {
562            break;
563        }
564    }
565    0.5 * (lo + hi)
566}
567
568/// Benchmark values for `(c_y, c_d, ρ)` from omitting observed covariates.
569#[derive(Debug, Clone)]
570pub struct SensitivityBenchmark {
571    treatment_names: Vec<String>,
572    pub cf_y: Array1<f64>,
573    pub cf_d: Array1<f64>,
574    pub rho: Array1<f64>,
575    /// Change in the estimate when the covariates are dropped.
576    pub delta_theta: Array1<f64>,
577}
578
579impl SensitivityBenchmark {
580    /// The benchmarked scenario for treatment `j`, ready for
581    /// [`DmlFit::sensitivity_analysis`].
582    pub fn params(&self, j: usize) -> SensitivityParams {
583        SensitivityParams::new()
584            .cf_y(self.cf_y[j])
585            .cf_d(self.cf_d[j])
586            .rho(self.rho[j])
587    }
588}
589
590impl fmt::Display for SensitivityResult {
591    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
592        let p = &self.params;
593        writeln!(
594            f,
595            "================== Sensitivity Analysis =================="
596        )?;
597        writeln!(f)?;
598        writeln!(f, "------------------ Scenario          ------------------")?;
599        writeln!(f, "Significance Level: level={}", p.level)?;
600        writeln!(
601            f,
602            "Sensitivity parameters: cf_y={}; cf_d={}, rho={}",
603            p.cf_y, p.cf_d, p.rho
604        )?;
605        writeln!(f)?;
606        writeln!(f, "------------------ Bounds with CI    ------------------")?;
607        let w = self
608            .treatment_names
609            .iter()
610            .map(String::len)
611            .max()
612            .unwrap_or(1)
613            .max(2);
614        writeln!(
615            f,
616            "{:<w$} {:>10} {:>12} {:>10} {:>12} {:>10}",
617            "", "CI lower", "theta lower", "theta", "theta upper", "CI upper"
618        )?;
619        for (j, name) in self.treatment_names.iter().enumerate() {
620            writeln!(
621                f,
622                "{:<w$} {:>10.6} {:>12.6} {:>10.6} {:>12.6} {:>10.6}",
623                name,
624                self.ci_lower[j],
625                self.theta_lower[j],
626                self.theta[j],
627                self.theta_upper[j],
628                self.ci_upper[j]
629            )?;
630        }
631        writeln!(f)?;
632        writeln!(f, "------------------ Robustness Values ------------------")?;
633        writeln!(
634            f,
635            "{:<w$} {:>8} {:>10} {:>10}",
636            "", "H_0", "RV (%)", "RVa (%)"
637        )?;
638        for (j, name) in self.treatment_names.iter().enumerate() {
639            write!(
640                f,
641                "{:<w$} {:>8.1} {:>10.6} {:>10.6}",
642                name,
643                p.null_hypothesis[j],
644                100.0 * self.rv[j],
645                100.0 * self.rva[j]
646            )?;
647            if j + 1 < self.treatment_names.len() {
648                writeln!(f)?;
649            }
650        }
651        Ok(())
652    }
653}
654
655impl fmt::Display for SensitivityBenchmark {
656    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
657        let w = self
658            .treatment_names
659            .iter()
660            .map(String::len)
661            .max()
662            .unwrap_or(1)
663            .max(2);
664        write!(
665            f,
666            "{:<w$} {:>10} {:>10} {:>10} {:>12}",
667            "", "cf_y", "cf_d", "rho", "delta_theta"
668        )?;
669        for (j, name) in self.treatment_names.iter().enumerate() {
670            write!(
671                f,
672                "\n{:<w$} {:>10.6} {:>10.6} {:>10.6} {:>12.6}",
673                name, self.cf_y[j], self.cf_d[j], self.rho[j], self.delta_theta[j]
674            )?;
675        }
676        Ok(())
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683    use ndarray::array;
684
685    #[test]
686    fn plr_elements_match_closed_form() {
687        // a = y − ℓ̂, b = d − m̂ = v
688        let parts = SensitivityParts {
689            outcome_residual: array![1.0, -1.0, 2.0, 0.0],
690            theta_factor: Some(array![0.5, -0.5, 1.0, -1.0]),
691            riesz: Array1::zeros(4),
692            nu2: Nu2Parts::InverseMeanSquare(array![0.5, -0.5, 1.0, -1.0]),
693        };
694        let e = parts.elements(2.0);
695        // residuals 1−1, −1+1, 2−2, 0+2 → (0, 0, 0, 2): σ² = 1
696        assert!((e.sigma2 - 1.0).abs() < 1e-15);
697        // mean v² = (0.25+0.25+1+1)/4 = 0.625 → ν² = 1.6
698        assert!((e.nu2 - 1.6).abs() < 1e-15);
699        assert!(e.psi_nu2.sum().abs() < 1e-12 && e.psi_sigma2.sum().abs() < 1e-12);
700    }
701
702    #[test]
703    fn negative_nu2_falls_back_to_riesz_square() {
704        let parts = SensitivityParts {
705            outcome_residual: array![1.0, 2.0],
706            theta_factor: None,
707            riesz: array![1.0, 3.0],
708            nu2: Nu2Parts::Elementwise(array![-1.0, -2.0]),
709        };
710        let e = parts.elements(0.0);
711        assert!((e.nu2 - 5.0).abs() < 1e-15); // mean(1, 9)
712    }
713
714    #[test]
715    fn robustness_value_solves_the_crossing() {
716        // bound(v) = 1 − v, null 0.25 (lower bound) → v = 0.75
717        let rv = robustness_value(|v| 1.0 - v, 0.25, false);
718        assert!((rv - 0.75).abs() < 1e-12);
719        // Upper bound rising with confounding: bound(v) = 1 + v reaches 1.5 at v = 0.5.
720        assert!((robustness_value(|v| 1.0 + v, 1.5, true) - 0.5).abs() < 1e-12);
721        assert_eq!(robustness_value(|v| 1.0 - v, 1.5, false), 0.0); // null already beyond the bound
722        assert_eq!(robustness_value(|_| 1.0, 0.0, false), RV_MAX); // never reached
723    }
724
725    #[test]
726    fn params_validation() {
727        assert!(SensitivityParams::new().cf_y(1.0).validate(1).is_err());
728        assert!(SensitivityParams::new().rho(1.5).validate(1).is_err());
729        assert!(SensitivityParams::new()
730            .null_hypotheses(vec![0.0, 1.0])
731            .validate(3)
732            .is_err());
733        assert!(SensitivityParams::new().validate(2).is_ok());
734    }
735}