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 scaled = self.psi.slice(ndarray::s![.., j, r]).mapv(|v| v / jac);
346 let pmb = store
347 .psi_max_bias
348 .slice(ndarray::s![.., j, r])
349 .mapv(|v| strength * v);
350 let (psi_lo, psi_hi) = (&scaled - &pmb, &scaled + &pmb);
351 let variance = |psi: &Array1<f64>| match &self.cluster {
352 Some(ci) => ci.variance(psi.view(), Array1::ones(n).view(), r),
353 None => psi.dot(psi) / n as f64 / n as f64,
354 };
355 se_lo[[j, r]] = variance(&psi_lo).sqrt();
356 se_hi[[j, r]] = variance(&psi_hi).sqrt();
357 }
358 }
359 let quant = norm_ppf(level);
360 let ci = |theta: &Array2<f64>, se: &Array2<f64>, sign: f64| -> Array1<f64> {
361 (0..k)
362 .map(|j| {
363 let v: Vec<f64> = (0..n_rep)
364 .map(|r| theta[[j, r]] + sign * quant * se[[j, r]])
365 .collect();
366 median(&v)
367 })
368 .collect()
369 };
370 let ci_lo = ci(&theta_lo, &se_lo, -1.0);
371 let ci_hi = ci(&theta_hi, &se_hi, 1.0);
372 let (t_lo, s_lo) = aggregate(&theta_lo, &se_lo);
373 let (t_hi, s_hi) = aggregate(&theta_hi, &se_hi);
374 Bounds {
375 theta: (t_lo, t_hi),
376 se: (s_lo, s_hi),
377 ci: (ci_lo, ci_hi),
378 }
379 }
380
381 pub fn sensitivity_benchmark(&self, short: &DmlFit) -> Result<SensitivityBenchmark> {
385 let long = self.sensitivity_store()?;
386 let short_store = short.sensitivity_store()?;
387 if short.all_coef.dim() != self.all_coef.dim() {
388 return Err(DmlError::DimensionMismatch(
389 "long and short fits need the same treatments and repetitions".into(),
390 ));
391 }
392 let (k, n_rep) = self.all_coef.dim();
393 let (mut cf_y, mut cf_d, mut rho, mut delta) = (
394 Array1::zeros(k),
395 Array1::zeros(k),
396 Array1::zeros(k),
397 Array1::zeros(k),
398 );
399 for j in 0..k {
400 let mut cy = Vec::with_capacity(n_rep);
401 let mut cd = Vec::with_capacity(n_rep);
402 let mut rh = Vec::with_capacity(n_rep);
403 let mut dt = Vec::with_capacity(n_rep);
404 for r in 0..n_rep {
405 let (s_long, s_short) = (long.sigma2[[j, r]], short_store.sigma2[[j, r]]);
406 let (n_long, n_short) = (long.nu2[[j, r]], short_store.nu2[[j, r]]);
407 let r2_long = 1.0 - s_long / long.var_y;
408 let r2_short = 1.0 - s_short / long.var_y;
409 let r2_riesz = n_short / n_long;
410 cy.push(((r2_long - r2_short) / (1.0 - r2_long)).clamp(0.0, 1.0));
411 cd.push(((1.0 - r2_riesz) / r2_riesz).clamp(0.0, 1.0));
412 let d_theta = short.all_coef[[j, r]] - self.all_coef[[j, r]];
413 dt.push(d_theta);
414 let (var_g, var_riesz) = (s_short - s_long, n_long - n_short);
415 let denom = if var_g > 0.0 && var_riesz > 0.0 {
416 (var_g * var_riesz).sqrt()
417 } else {
418 0.0
419 };
420 let magnitude = if denom != 0.0 {
421 (d_theta.abs() / denom).clamp(0.0, 1.0)
422 } else {
423 1.0
424 };
425 let sign = if d_theta > 0.0 {
426 1.0
427 } else if d_theta < 0.0 {
428 -1.0
429 } else {
430 0.0
431 };
432 rh.push(magnitude * sign);
433 }
434 cf_y[j] = median(&cy);
435 cf_d[j] = median(&cd);
436 rho[j] = median(&rh);
437 delta[j] = median(&dt);
438 }
439 Ok(SensitivityBenchmark {
440 treatment_names: self.treatment_names.clone(),
441 cf_y,
442 cf_d,
443 rho,
444 delta_theta: delta,
445 })
446 }
447}
448
449fn robustness_value(bound: impl Fn(f64) -> f64, h0: f64, upper: bool) -> f64 {
454 let gap = |v: f64| if upper { h0 - bound(v) } else { bound(v) - h0 };
456 if gap(0.0) <= 0.0 {
457 return 0.0;
458 }
459 if gap(RV_MAX) > 0.0 {
460 return RV_MAX;
461 }
462 let (mut lo, mut hi) = (0.0, RV_MAX);
463 for _ in 0..100 {
464 let mid = 0.5 * (lo + hi);
465 if gap(mid) > 0.0 {
466 lo = mid;
467 } else {
468 hi = mid;
469 }
470 if hi - lo < 1e-13 {
471 break;
472 }
473 }
474 0.5 * (lo + hi)
475}
476
477#[derive(Debug, Clone)]
479pub struct SensitivityBenchmark {
480 treatment_names: Vec<String>,
481 pub cf_y: Array1<f64>,
482 pub cf_d: Array1<f64>,
483 pub rho: Array1<f64>,
484 pub delta_theta: Array1<f64>,
486}
487
488impl SensitivityBenchmark {
489 pub fn params(&self, j: usize) -> SensitivityParams {
492 SensitivityParams::new()
493 .cf_y(self.cf_y[j])
494 .cf_d(self.cf_d[j])
495 .rho(self.rho[j])
496 }
497}
498
499impl fmt::Display for SensitivityResult {
500 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501 let p = &self.params;
502 writeln!(
503 f,
504 "================== Sensitivity Analysis =================="
505 )?;
506 writeln!(f)?;
507 writeln!(f, "------------------ Scenario ------------------")?;
508 writeln!(f, "Significance Level: level={}", p.level)?;
509 writeln!(
510 f,
511 "Sensitivity parameters: cf_y={}; cf_d={}, rho={}",
512 p.cf_y, p.cf_d, p.rho
513 )?;
514 writeln!(f)?;
515 writeln!(f, "------------------ Bounds with CI ------------------")?;
516 let w = self
517 .treatment_names
518 .iter()
519 .map(String::len)
520 .max()
521 .unwrap_or(1)
522 .max(2);
523 writeln!(
524 f,
525 "{:<w$} {:>10} {:>12} {:>10} {:>12} {:>10}",
526 "", "CI lower", "theta lower", "theta", "theta upper", "CI upper"
527 )?;
528 for (j, name) in self.treatment_names.iter().enumerate() {
529 writeln!(
530 f,
531 "{:<w$} {:>10.6} {:>12.6} {:>10.6} {:>12.6} {:>10.6}",
532 name,
533 self.ci_lower[j],
534 self.theta_lower[j],
535 self.theta[j],
536 self.theta_upper[j],
537 self.ci_upper[j]
538 )?;
539 }
540 writeln!(f)?;
541 writeln!(f, "------------------ Robustness Values ------------------")?;
542 writeln!(
543 f,
544 "{:<w$} {:>8} {:>10} {:>10}",
545 "", "H_0", "RV (%)", "RVa (%)"
546 )?;
547 for (j, name) in self.treatment_names.iter().enumerate() {
548 write!(
549 f,
550 "{:<w$} {:>8.1} {:>10.6} {:>10.6}",
551 name,
552 p.null_hypothesis[j],
553 100.0 * self.rv[j],
554 100.0 * self.rva[j]
555 )?;
556 if j + 1 < self.treatment_names.len() {
557 writeln!(f)?;
558 }
559 }
560 Ok(())
561 }
562}
563
564impl fmt::Display for SensitivityBenchmark {
565 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
566 let w = self
567 .treatment_names
568 .iter()
569 .map(String::len)
570 .max()
571 .unwrap_or(1)
572 .max(2);
573 write!(
574 f,
575 "{:<w$} {:>10} {:>10} {:>10} {:>12}",
576 "", "cf_y", "cf_d", "rho", "delta_theta"
577 )?;
578 for (j, name) in self.treatment_names.iter().enumerate() {
579 write!(
580 f,
581 "\n{:<w$} {:>10.6} {:>10.6} {:>10.6} {:>12.6}",
582 name, self.cf_y[j], self.cf_d[j], self.rho[j], self.delta_theta[j]
583 )?;
584 }
585 Ok(())
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use ndarray::array;
593
594 #[test]
595 fn plr_elements_match_closed_form() {
596 let parts = SensitivityParts {
598 outcome_residual: array![1.0, -1.0, 2.0, 0.0],
599 theta_factor: Some(array![0.5, -0.5, 1.0, -1.0]),
600 riesz: Array1::zeros(4),
601 nu2: Nu2Parts::InverseMeanSquare(array![0.5, -0.5, 1.0, -1.0]),
602 };
603 let e = parts.elements(2.0);
604 assert!((e.sigma2 - 1.0).abs() < 1e-15);
606 assert!((e.nu2 - 1.6).abs() < 1e-15);
608 assert!(e.psi_nu2.sum().abs() < 1e-12 && e.psi_sigma2.sum().abs() < 1e-12);
609 }
610
611 #[test]
612 fn negative_nu2_falls_back_to_riesz_square() {
613 let parts = SensitivityParts {
614 outcome_residual: array![1.0, 2.0],
615 theta_factor: None,
616 riesz: array![1.0, 3.0],
617 nu2: Nu2Parts::Elementwise(array![-1.0, -2.0]),
618 };
619 let e = parts.elements(0.0);
620 assert!((e.nu2 - 5.0).abs() < 1e-15); }
622
623 #[test]
624 fn robustness_value_solves_the_crossing() {
625 let rv = robustness_value(|v| 1.0 - v, 0.25, false);
627 assert!((rv - 0.75).abs() < 1e-12);
628 assert!((robustness_value(|v| 1.0 + v, 1.5, true) - 0.5).abs() < 1e-12);
630 assert_eq!(robustness_value(|v| 1.0 - v, 1.5, false), 0.0); assert_eq!(robustness_value(|_| 1.0, 0.0, false), RV_MAX); }
633
634 #[test]
635 fn params_validation() {
636 assert!(SensitivityParams::new().cf_y(1.0).validate(1).is_err());
637 assert!(SensitivityParams::new().rho(1.5).validate(1).is_err());
638 assert!(SensitivityParams::new()
639 .null_hypotheses(vec![0.0, 1.0])
640 .validate(3)
641 .is_err());
642 assert!(SensitivityParams::new().validate(2).is_ok());
643 }
644}