1use 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#[derive(Debug, Clone)]
30pub enum Nu2Parts {
31 Elementwise(Array1<f64>),
33 InverseMeanSquare(Array1<f64>),
36}
37
38#[derive(Debug, Clone)]
44pub struct SensitivityParts {
45 pub outcome_residual: Array1<f64>,
46 pub theta_factor: Option<Array1<f64>>,
47 pub riesz: Array1<f64>,
50 pub nu2: Nu2Parts,
51}
52
53pub(crate) struct Elements {
55 pub sigma2: f64,
56 pub nu2: f64,
57 pub psi_sigma2: Array1<f64>,
58 pub psi_nu2: Array1<f64>,
59 pub riesz: Option<Array1<f64>>,
61 pub nu2_fallback: bool,
63}
64
65impl SensitivityParts {
66 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#[derive(Debug, Clone)]
106pub(crate) struct SensitivityStore {
107 pub sigma2: Array2<f64>,
109 pub nu2: Array2<f64>,
110 pub max_bias: Array2<f64>,
111 pub psi_max_bias: Array3<f64>,
113 pub psi_sigma2: Array3<f64>,
116 pub psi_nu2: Array3<f64>,
117 pub riesz: Array3<f64>,
119 pub nu2_fallback: Vec<bool>,
121 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 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 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 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 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#[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 pub null_hypothesis: Vec<f64>,
235}
236
237impl Default for SensitivityParams {
238 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 pub fn cf_y(mut self, cf_y: f64) -> Self {
257 self.cf_y = cf_y;
258 self
259 }
260
261 pub fn cf_d(mut self, cf_d: f64) -> Self {
263 self.cf_d = cf_d;
264 self
265 }
266
267 pub fn rho(mut self, rho: f64) -> Self {
269 self.rho = rho;
270 self
271 }
272
273 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#[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#[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 pub ci_lower: Array1<f64>,
331 pub ci_upper: Array1<f64>,
332 pub rv: Array1<f64>,
334 pub rva: Array1<f64>,
336}
337
338const RV_MAX: f64 = 0.9999;
340
341impl DmlFit {
342 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 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 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 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
540fn robustness_value(bound: impl Fn(f64) -> f64, h0: f64, upper: bool) -> f64 {
545 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#[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 pub delta_theta: Array1<f64>,
577}
578
579impl SensitivityBenchmark {
580 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 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 assert!((e.sigma2 - 1.0).abs() < 1e-15);
697 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); }
713
714 #[test]
715 fn robustness_value_solves_the_crossing() {
716 let rv = robustness_value(|v| 1.0 - v, 0.25, false);
718 assert!((rv - 0.75).abs() < 1e-12);
719 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); assert_eq!(robustness_value(|_| 1.0, 0.0, false), RV_MAX); }
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}