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