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}
60
61impl SensitivityParts {
62 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#[derive(Debug, Clone)]
99pub(crate) struct SensitivityStore {
100 pub sigma2: Array2<f64>,
102 pub nu2: Array2<f64>,
103 pub max_bias: Array2<f64>,
104 pub psi_max_bias: Array3<f64>,
106 pub psi_sigma2: Array3<f64>,
109 pub psi_nu2: Array3<f64>,
110 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 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 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 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#[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 pub null_hypothesis: Vec<f64>,
187}
188
189impl Default for SensitivityParams {
190 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 pub fn cf_y(mut self, cf_y: f64) -> Self {
209 self.cf_y = cf_y;
210 self
211 }
212
213 pub fn cf_d(mut self, cf_d: f64) -> Self {
215 self.cf_d = cf_d;
216 self
217 }
218
219 pub fn rho(mut self, rho: f64) -> Self {
221 self.rho = rho;
222 self
223 }
224
225 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#[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#[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 pub ci_lower: Array1<f64>,
283 pub ci_upper: Array1<f64>,
284 pub rv: Array1<f64>,
286 pub rva: Array1<f64>,
288}
289
290const RV_MAX: f64 = 0.9999;
292
293impl DmlFit {
294 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 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 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 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
492fn robustness_value(bound: impl Fn(f64) -> f64, h0: f64, upper: bool) -> f64 {
497 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#[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 pub delta_theta: Array1<f64>,
529}
530
531impl SensitivityBenchmark {
532 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 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 assert!((e.sigma2 - 1.0).abs() < 1e-15);
649 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); }
665
666 #[test]
667 fn robustness_value_solves_the_crossing() {
668 let rv = robustness_value(|v| 1.0 - v, 0.25, false);
670 assert!((rv - 0.75).abs() < 1e-12);
671 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); assert_eq!(robustness_value(|_| 1.0, 0.0, false), RV_MAX); }
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}