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