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 var_y: f64,
108}
109
110impl SensitivityStore {
111 pub(crate) fn new(n: usize, k: usize, n_rep: usize, var_y: f64) -> Self {
112 Self {
113 sigma2: Array2::zeros((k, n_rep)),
114 nu2: Array2::zeros((k, n_rep)),
115 max_bias: Array2::zeros((k, n_rep)),
116 psi_max_bias: Array3::zeros((n, k, n_rep)),
117 var_y,
118 }
119 }
120
121 pub(crate) fn set(&mut self, j: usize, r: usize, e: &Elements) {
124 let max_bias = (e.sigma2 * e.nu2).sqrt();
125 self.sigma2[[j, r]] = e.sigma2;
126 self.nu2[[j, r]] = e.nu2;
127 self.max_bias[[j, r]] = max_bias;
128 let psi = (e.sigma2 * &e.psi_nu2 + e.nu2 * &e.psi_sigma2) / (2.0 * max_bias);
129 self.psi_max_bias
130 .slice_mut(ndarray::s![.., j, r])
131 .assign(&psi);
132 }
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct SensitivityParams {
138 pub cf_y: f64,
139 pub cf_d: f64,
140 pub rho: f64,
141 pub level: f64,
142 pub null_hypothesis: Vec<f64>,
144}
145
146impl Default for SensitivityParams {
147 fn default() -> Self {
149 Self {
150 cf_y: 0.03,
151 cf_d: 0.03,
152 rho: 1.0,
153 level: 0.95,
154 null_hypothesis: vec![0.0],
155 }
156 }
157}
158
159impl SensitivityParams {
160 pub fn new() -> Self {
161 Self::default()
162 }
163
164 pub fn cf_y(mut self, cf_y: f64) -> Self {
166 self.cf_y = cf_y;
167 self
168 }
169
170 pub fn cf_d(mut self, cf_d: f64) -> Self {
172 self.cf_d = cf_d;
173 self
174 }
175
176 pub fn rho(mut self, rho: f64) -> Self {
178 self.rho = rho;
179 self
180 }
181
182 pub fn level(mut self, level: f64) -> Self {
184 self.level = level;
185 self
186 }
187
188 pub fn null_hypothesis(mut self, value: f64) -> Self {
189 self.null_hypothesis = vec![value];
190 self
191 }
192
193 pub fn null_hypotheses(mut self, values: Vec<f64>) -> Self {
194 self.null_hypothesis = values;
195 self
196 }
197
198 fn validate(&self, k: usize) -> Result<()> {
199 let open01 = |v: f64| (0.0..1.0).contains(&v);
200 if !open01(self.cf_y) || !open01(self.cf_d) {
201 return Err(DmlError::InvalidArgument(
202 "cf_y and cf_d must be in [0, 1)".into(),
203 ));
204 }
205 if self.rho.is_nan() || self.rho.abs() > 1.0 {
206 return Err(DmlError::InvalidArgument("rho must be in [-1, 1]".into()));
207 }
208 if !(self.level > 0.0 && self.level < 1.0) {
209 return Err(DmlError::InvalidArgument("level must be in (0, 1)".into()));
210 }
211 if self.null_hypothesis.len() != 1 && self.null_hypothesis.len() != k {
212 return Err(DmlError::DimensionMismatch(format!(
213 "give one null hypothesis or one per treatment ({k})"
214 )));
215 }
216 Ok(())
217 }
218}
219
220#[derive(Debug, Clone)]
222struct Bounds {
223 theta: (Array1<f64>, Array1<f64>),
224 se: (Array1<f64>, Array1<f64>),
225 ci: (Array1<f64>, Array1<f64>),
226}
227
228#[derive(Debug, Clone)]
230pub struct SensitivityResult {
231 pub params: SensitivityParams,
232 treatment_names: Vec<String>,
233 theta: Array1<f64>,
234 pub theta_lower: Array1<f64>,
235 pub theta_upper: Array1<f64>,
236 pub se_lower: Array1<f64>,
237 pub se_upper: Array1<f64>,
238 pub ci_lower: Array1<f64>,
240 pub ci_upper: Array1<f64>,
241 pub rv: Array1<f64>,
243 pub rva: Array1<f64>,
245}
246
247const RV_MAX: f64 = 0.9999;
249
250impl DmlFit {
251 pub fn sensitivity_analysis(&self, params: &SensitivityParams) -> Result<SensitivityResult> {
254 let store = self.sensitivity_store()?;
255 let k = self.n_treat();
256 params.validate(k)?;
257 let b = self.bounds(store, params.cf_y, params.cf_d, params.rho, params.level);
258 let null = |j: usize| {
259 params.null_hypothesis[if params.null_hypothesis.len() == 1 {
260 0
261 } else {
262 j
263 }]
264 };
265 let mut rv = Array1::zeros(k);
266 let mut rva = Array1::zeros(k);
267 for j in 0..k {
268 let h0 = null(j);
269 let upper = h0 > self.coef[j];
270 let theta_at = |v: f64| {
271 let b = self.bounds(store, v, v, params.rho, params.level);
272 if upper {
273 b.theta.1[j]
274 } else {
275 b.theta.0[j]
276 }
277 };
278 let ci_at = |v: f64| {
279 let b = self.bounds(store, v, v, params.rho, params.level);
280 if upper {
281 b.ci.1[j]
282 } else {
283 b.ci.0[j]
284 }
285 };
286 rv[j] = robustness_value(theta_at, h0, upper);
287 rva[j] = robustness_value(ci_at, h0, upper);
288 }
289 Ok(SensitivityResult {
290 params: SensitivityParams {
291 null_hypothesis: (0..k).map(null).collect(),
292 ..params.clone()
293 },
294 treatment_names: self.treatment_names.clone(),
295 theta: self.coef.clone(),
296 theta_lower: b.theta.0,
297 theta_upper: b.theta.1,
298 se_lower: b.se.0,
299 se_upper: b.se.1,
300 ci_lower: b.ci.0,
301 ci_upper: b.ci.1,
302 rv,
303 rva,
304 })
305 }
306
307 pub fn sensitivity_elements(&self) -> Option<(ArrayView2<'_, f64>, ArrayView2<'_, f64>)> {
311 self.sensitivity
312 .as_ref()
313 .map(|s| (s.sigma2.view(), s.nu2.view()))
314 }
315
316 fn sensitivity_store(&self) -> Result<&SensitivityStore> {
317 self.sensitivity.as_ref().ok_or_else(|| {
318 DmlError::InvalidArgument(
319 "sensitivity analysis is available for PLR, IRM and DID fits".into(),
320 )
321 })
322 }
323
324 fn bounds(
326 &self,
327 store: &SensitivityStore,
328 cf_y: f64,
329 cf_d: f64,
330 rho: f64,
331 level: f64,
332 ) -> Bounds {
333 let strength = rho.abs() * (cf_y * cf_d / (1.0 - cf_d)).sqrt();
334 let (n, k, n_rep) = self.psi.dim();
335 let mut theta_lo = Array2::zeros((k, n_rep));
336 let mut theta_hi = Array2::zeros((k, n_rep));
337 let mut se_lo = Array2::zeros((k, n_rep));
338 let mut se_hi = Array2::zeros((k, n_rep));
339 for j in 0..k {
340 for r in 0..n_rep {
341 let shift = strength * store.max_bias[[j, r]];
342 theta_lo[[j, r]] = self.all_coef[[j, r]] - shift;
343 theta_hi[[j, r]] = self.all_coef[[j, r]] + shift;
344 let jac = self.psi_deriv[[j, r]];
345 let (mut ss_lo, mut ss_hi) = (0.0, 0.0);
346 for i in 0..n {
347 let scaled = self.psi[[i, j, r]] / jac;
348 let pmb = strength * store.psi_max_bias[[i, j, r]];
349 ss_lo += (scaled - pmb).powi(2);
350 ss_hi += (scaled + pmb).powi(2);
351 }
352 se_lo[[j, r]] = (ss_lo / n as f64 / n as f64).sqrt();
353 se_hi[[j, r]] = (ss_hi / n as f64 / n as f64).sqrt();
354 }
355 }
356 let quant = norm_ppf(level);
357 let ci = |theta: &Array2<f64>, se: &Array2<f64>, sign: f64| -> Array1<f64> {
358 (0..k)
359 .map(|j| {
360 let v: Vec<f64> = (0..n_rep)
361 .map(|r| theta[[j, r]] + sign * quant * se[[j, r]])
362 .collect();
363 median(&v)
364 })
365 .collect()
366 };
367 let ci_lo = ci(&theta_lo, &se_lo, -1.0);
368 let ci_hi = ci(&theta_hi, &se_hi, 1.0);
369 let (t_lo, s_lo) = aggregate(&theta_lo, &se_lo);
370 let (t_hi, s_hi) = aggregate(&theta_hi, &se_hi);
371 Bounds {
372 theta: (t_lo, t_hi),
373 se: (s_lo, s_hi),
374 ci: (ci_lo, ci_hi),
375 }
376 }
377
378 pub fn sensitivity_benchmark(&self, short: &DmlFit) -> Result<SensitivityBenchmark> {
382 let long = self.sensitivity_store()?;
383 let short_store = short.sensitivity_store()?;
384 if short.all_coef.dim() != self.all_coef.dim() {
385 return Err(DmlError::DimensionMismatch(
386 "long and short fits need the same treatments and repetitions".into(),
387 ));
388 }
389 let (k, n_rep) = self.all_coef.dim();
390 let (mut cf_y, mut cf_d, mut rho, mut delta) = (
391 Array1::zeros(k),
392 Array1::zeros(k),
393 Array1::zeros(k),
394 Array1::zeros(k),
395 );
396 for j in 0..k {
397 let mut cy = Vec::with_capacity(n_rep);
398 let mut cd = Vec::with_capacity(n_rep);
399 let mut rh = Vec::with_capacity(n_rep);
400 let mut dt = Vec::with_capacity(n_rep);
401 for r in 0..n_rep {
402 let (s_long, s_short) = (long.sigma2[[j, r]], short_store.sigma2[[j, r]]);
403 let (n_long, n_short) = (long.nu2[[j, r]], short_store.nu2[[j, r]]);
404 let r2_long = 1.0 - s_long / long.var_y;
405 let r2_short = 1.0 - s_short / long.var_y;
406 let r2_riesz = n_short / n_long;
407 cy.push(((r2_long - r2_short) / (1.0 - r2_long)).clamp(0.0, 1.0));
408 cd.push(((1.0 - r2_riesz) / r2_riesz).clamp(0.0, 1.0));
409 let d_theta = short.all_coef[[j, r]] - self.all_coef[[j, r]];
410 dt.push(d_theta);
411 let (var_g, var_riesz) = (s_short - s_long, n_long - n_short);
412 let denom = if var_g > 0.0 && var_riesz > 0.0 {
413 (var_g * var_riesz).sqrt()
414 } else {
415 0.0
416 };
417 let magnitude = if denom != 0.0 {
418 (d_theta.abs() / denom).clamp(0.0, 1.0)
419 } else {
420 1.0
421 };
422 let sign = if d_theta > 0.0 {
423 1.0
424 } else if d_theta < 0.0 {
425 -1.0
426 } else {
427 0.0
428 };
429 rh.push(magnitude * sign);
430 }
431 cf_y[j] = median(&cy);
432 cf_d[j] = median(&cd);
433 rho[j] = median(&rh);
434 delta[j] = median(&dt);
435 }
436 Ok(SensitivityBenchmark {
437 treatment_names: self.treatment_names.clone(),
438 cf_y,
439 cf_d,
440 rho,
441 delta_theta: delta,
442 })
443 }
444}
445
446fn robustness_value(bound: impl Fn(f64) -> f64, h0: f64, upper: bool) -> f64 {
451 let gap = |v: f64| if upper { h0 - bound(v) } else { bound(v) - h0 };
453 if gap(0.0) <= 0.0 {
454 return 0.0;
455 }
456 if gap(RV_MAX) > 0.0 {
457 return RV_MAX;
458 }
459 let (mut lo, mut hi) = (0.0, RV_MAX);
460 for _ in 0..100 {
461 let mid = 0.5 * (lo + hi);
462 if gap(mid) > 0.0 {
463 lo = mid;
464 } else {
465 hi = mid;
466 }
467 if hi - lo < 1e-13 {
468 break;
469 }
470 }
471 0.5 * (lo + hi)
472}
473
474#[derive(Debug, Clone)]
476pub struct SensitivityBenchmark {
477 treatment_names: Vec<String>,
478 pub cf_y: Array1<f64>,
479 pub cf_d: Array1<f64>,
480 pub rho: Array1<f64>,
481 pub delta_theta: Array1<f64>,
483}
484
485impl SensitivityBenchmark {
486 pub fn params(&self, j: usize) -> SensitivityParams {
489 SensitivityParams::new()
490 .cf_y(self.cf_y[j])
491 .cf_d(self.cf_d[j])
492 .rho(self.rho[j])
493 }
494}
495
496impl fmt::Display for SensitivityResult {
497 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498 let p = &self.params;
499 writeln!(
500 f,
501 "================== Sensitivity Analysis =================="
502 )?;
503 writeln!(f)?;
504 writeln!(f, "------------------ Scenario ------------------")?;
505 writeln!(f, "Significance Level: level={}", p.level)?;
506 writeln!(
507 f,
508 "Sensitivity parameters: cf_y={}; cf_d={}, rho={}",
509 p.cf_y, p.cf_d, p.rho
510 )?;
511 writeln!(f)?;
512 writeln!(f, "------------------ Bounds with CI ------------------")?;
513 let w = self
514 .treatment_names
515 .iter()
516 .map(String::len)
517 .max()
518 .unwrap_or(1)
519 .max(2);
520 writeln!(
521 f,
522 "{:<w$} {:>10} {:>12} {:>10} {:>12} {:>10}",
523 "", "CI lower", "theta lower", "theta", "theta upper", "CI upper"
524 )?;
525 for (j, name) in self.treatment_names.iter().enumerate() {
526 writeln!(
527 f,
528 "{:<w$} {:>10.6} {:>12.6} {:>10.6} {:>12.6} {:>10.6}",
529 name,
530 self.ci_lower[j],
531 self.theta_lower[j],
532 self.theta[j],
533 self.theta_upper[j],
534 self.ci_upper[j]
535 )?;
536 }
537 writeln!(f)?;
538 writeln!(f, "------------------ Robustness Values ------------------")?;
539 writeln!(
540 f,
541 "{:<w$} {:>8} {:>10} {:>10}",
542 "", "H_0", "RV (%)", "RVa (%)"
543 )?;
544 for (j, name) in self.treatment_names.iter().enumerate() {
545 write!(
546 f,
547 "{:<w$} {:>8.1} {:>10.6} {:>10.6}",
548 name,
549 p.null_hypothesis[j],
550 100.0 * self.rv[j],
551 100.0 * self.rva[j]
552 )?;
553 if j + 1 < self.treatment_names.len() {
554 writeln!(f)?;
555 }
556 }
557 Ok(())
558 }
559}
560
561impl fmt::Display for SensitivityBenchmark {
562 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
563 let w = self
564 .treatment_names
565 .iter()
566 .map(String::len)
567 .max()
568 .unwrap_or(1)
569 .max(2);
570 write!(
571 f,
572 "{:<w$} {:>10} {:>10} {:>10} {:>12}",
573 "", "cf_y", "cf_d", "rho", "delta_theta"
574 )?;
575 for (j, name) in self.treatment_names.iter().enumerate() {
576 write!(
577 f,
578 "\n{:<w$} {:>10.6} {:>10.6} {:>10.6} {:>12.6}",
579 name, self.cf_y[j], self.cf_d[j], self.rho[j], self.delta_theta[j]
580 )?;
581 }
582 Ok(())
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use ndarray::array;
590
591 #[test]
592 fn plr_elements_match_closed_form() {
593 let parts = SensitivityParts {
595 outcome_residual: array![1.0, -1.0, 2.0, 0.0],
596 theta_factor: Some(array![0.5, -0.5, 1.0, -1.0]),
597 riesz: Array1::zeros(4),
598 nu2: Nu2Parts::InverseMeanSquare(array![0.5, -0.5, 1.0, -1.0]),
599 };
600 let e = parts.elements(2.0);
601 assert!((e.sigma2 - 1.0).abs() < 1e-15);
603 assert!((e.nu2 - 1.6).abs() < 1e-15);
605 assert!(e.psi_nu2.sum().abs() < 1e-12 && e.psi_sigma2.sum().abs() < 1e-12);
606 }
607
608 #[test]
609 fn negative_nu2_falls_back_to_riesz_square() {
610 let parts = SensitivityParts {
611 outcome_residual: array![1.0, 2.0],
612 theta_factor: None,
613 riesz: array![1.0, 3.0],
614 nu2: Nu2Parts::Elementwise(array![-1.0, -2.0]),
615 };
616 let e = parts.elements(0.0);
617 assert!((e.nu2 - 5.0).abs() < 1e-15); }
619
620 #[test]
621 fn robustness_value_solves_the_crossing() {
622 let rv = robustness_value(|v| 1.0 - v, 0.25, false);
624 assert!((rv - 0.75).abs() < 1e-12);
625 assert!((robustness_value(|v| 1.0 + v, 1.5, true) - 0.5).abs() < 1e-12);
627 assert_eq!(robustness_value(|v| 1.0 - v, 1.5, false), 0.0); assert_eq!(robustness_value(|_| 1.0, 0.0, false), RV_MAX); }
630
631 #[test]
632 fn params_validation() {
633 assert!(SensitivityParams::new().cf_y(1.0).validate(1).is_err());
634 assert!(SensitivityParams::new().rho(1.5).validate(1).is_err());
635 assert!(SensitivityParams::new()
636 .null_hypotheses(vec![0.0, 1.0])
637 .validate(3)
638 .is_err());
639 assert!(SensitivityParams::new().validate(2).is_ok());
640 }
641}