1use std::fmt;
4
5use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3, Axis};
6
7use crate::blp::Blp;
8use crate::crossfit::Fold;
9use crate::data::DmlData;
10use crate::error::{DmlError, Result};
11use crate::model::{LinearScoreModel, ScoreInput};
12use crate::sensitivity::SensitivityStore;
13use crate::stats::{median, norm_ppf, norm_sf};
14
15pub fn fit_model<M: LinearScoreModel + ?Sized>(
23 model: &M,
24 data: &DmlData,
25 splits: &[Vec<Fold>],
26) -> Result<DmlFit> {
27 model.validate(data)?;
28 let n = data.n_obs();
29 check_splits(splits, n)?;
30 let (k, n_rep) = (data.n_treat(), splits.len());
31
32 let mut all_coef = Array2::<f64>::zeros((k, n_rep));
33 let mut all_se = Array2::<f64>::zeros((k, n_rep));
34 let mut psi = Array3::<f64>::zeros((n, k, n_rep));
35 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
36 let mut psi_a = Array3::<f64>::zeros((n, k, n_rep));
37 let mut psi_b = Array3::<f64>::zeros((n, k, n_rep));
38 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
39 let y_mean = data.y().mean().expect("n > 0");
41 let var_y = data
42 .y()
43 .mapv(|v| (v - y_mean).powi(2))
44 .mean()
45 .expect("n > 0");
46 let mut sensitivity = Some(SensitivityStore::new(n, k, n_rep, var_y));
47
48 for j in 0..k {
49 let x_j = data.covariates_for(j);
50 let input = ScoreInput {
51 y: data.y(),
52 d: data.treatment(j),
53 x: x_j.view(),
54 z: data.z(),
55 t: data.time(),
56 };
57 for (r, folds) in splits.iter().enumerate() {
58 let est = model.estimate_score(&input, folds)?;
59 let sol = est.score.solve()?;
60 if !(sol.theta.is_finite() && sol.se.is_finite()) {
61 return Err(DmlError::InvalidArgument(format!(
62 "non-finite estimate for treatment {j}, repetition {r}"
63 )));
64 }
65 all_coef[[j, r]] = sol.theta;
66 all_se[[j, r]] = sol.se;
67 psi_deriv[[j, r]] = sol.j;
68 psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
69 psi_a
70 .slice_mut(ndarray::s![.., j, r])
71 .assign(&est.score.psi_a);
72 psi_b
73 .slice_mut(ndarray::s![.., j, r])
74 .assign(&est.score.psi_b);
75 match (&est.sensitivity, &mut sensitivity) {
76 (Some(parts), Some(store)) => store.set(j, r, &parts.elements(sol.theta)),
77 _ => sensitivity = None,
78 }
79 for (name, pred) in est.predictions {
80 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
81 Some(i) => i,
82 None => {
83 predictions
84 .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
85 predictions.len() - 1
86 }
87 };
88 predictions[slot]
89 .1
90 .slice_mut(ndarray::s![.., r, j])
91 .assign(&pred);
92 }
93 }
94 }
95
96 let (coef, se) = aggregate(&all_coef, &all_se);
97 Ok(DmlFit {
98 model: model.name(),
99 treatment_names: data.treatment_names().to_vec(),
100 n_obs: n,
101 n_folds: splits[0].len(),
102 coef,
103 se,
104 all_coef,
105 all_se,
106 psi,
107 psi_a,
108 psi_b,
109 psi_deriv,
110 predictions,
111 sensitivity,
112 })
113}
114
115fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
116 if splits.is_empty() {
117 return Err(DmlError::InvalidArgument("no sample splits given".into()));
118 }
119 for (r, folds) in splits.iter().enumerate() {
120 let mut seen = vec![false; n];
121 for fold in folds {
122 for &i in &fold.test {
123 if i >= n || seen[i] {
124 return Err(DmlError::InvalidArgument(format!(
125 "repetition {r}: test sets must partition 0..{n}"
126 )));
127 }
128 seen[i] = true;
129 }
130 if fold.train.iter().any(|&i| i >= n) {
131 return Err(DmlError::InvalidArgument(format!(
132 "repetition {r}: training index out of range"
133 )));
134 }
135 }
136 if seen.iter().any(|s| !s) {
137 return Err(DmlError::InvalidArgument(format!(
138 "repetition {r}: test sets must partition 0..{n}"
139 )));
140 }
141 }
142 Ok(())
143}
144
145pub(crate) fn aggregate(
148 all_coef: &Array2<f64>,
149 all_se: &Array2<f64>,
150) -> (Array1<f64>, Array1<f64>) {
151 const CRIT: f64 = 1.96;
152 let k = all_coef.nrows();
153 let mut coef = Array1::zeros(k);
154 let mut se = Array1::zeros(k);
155 for j in 0..k {
156 let c = all_coef.row(j);
157 coef[j] = median(&c.to_vec());
158 let upper: Vec<f64> = c
159 .iter()
160 .zip(all_se.row(j))
161 .map(|(&t, &s)| t + CRIT * s)
162 .collect();
163 se[j] = (median(&upper) - coef[j]) / CRIT;
164 }
165 (coef, se)
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum PAdjust {
171 Bonferroni,
172 Holm,
174}
175
176#[derive(Debug, Clone)]
182pub struct DmlFit {
183 pub(crate) model: String,
184 pub(crate) treatment_names: Vec<String>,
185 pub(crate) n_obs: usize,
186 pub(crate) n_folds: usize,
187 pub(crate) coef: Array1<f64>,
188 pub(crate) se: Array1<f64>,
189 pub(crate) all_coef: Array2<f64>,
190 pub(crate) all_se: Array2<f64>,
191 pub(crate) psi: Array3<f64>,
193 pub(crate) psi_a: Array3<f64>,
195 pub(crate) psi_b: Array3<f64>,
196 pub(crate) psi_deriv: Array2<f64>,
198 pub(crate) predictions: Vec<(String, Array3<f64>)>,
200 pub(crate) sensitivity: Option<SensitivityStore>,
202}
203
204impl DmlFit {
205 pub fn model_name(&self) -> &str {
206 &self.model
207 }
208
209 pub fn treatment_names(&self) -> &[String] {
210 &self.treatment_names
211 }
212
213 pub fn n_obs(&self) -> usize {
214 self.n_obs
215 }
216
217 pub fn n_treat(&self) -> usize {
218 self.coef.len()
219 }
220
221 pub fn n_rep(&self) -> usize {
222 self.all_coef.ncols()
223 }
224
225 pub fn n_folds(&self) -> usize {
226 self.n_folds
227 }
228
229 pub fn coef(&self) -> ArrayView1<'_, f64> {
231 self.coef.view()
232 }
233
234 pub fn se(&self) -> ArrayView1<'_, f64> {
236 self.se.view()
237 }
238
239 pub fn t_stat(&self) -> Array1<f64> {
240 &self.coef / &self.se
241 }
242
243 pub fn p_value(&self) -> Array1<f64> {
247 self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
248 }
249
250 fn per_rep_pvals(&self) -> Array2<f64> {
251 let mut p = &self.all_coef / &self.all_se;
252 p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
253 p
254 }
255
256 pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
259 check_level(level)?;
260 let z = norm_ppf(0.5 + level / 2.0);
261 Ok(confint_from(
262 &self.all_coef,
263 &self.all_se,
264 &Array1::from_elem(self.n_rep(), z),
265 ))
266 }
267
268 pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
272 let raw = self.per_rep_pvals();
273 let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
274 for (r, col) in raw.columns().into_iter().enumerate() {
275 let adj = match method {
276 PAdjust::Bonferroni => {
277 let m = col.len() as f64;
278 col.mapv(|p| (m * p).min(1.0))
279 }
280 PAdjust::Holm => holm(col),
281 };
282 adjusted.column_mut(r).assign(&adj);
283 }
284 adjusted
285 .rows()
286 .into_iter()
287 .map(|row| median(&row.to_vec()))
288 .collect()
289 }
290
291 pub fn all_coef(&self) -> ArrayView2<'_, f64> {
293 self.all_coef.view()
294 }
295
296 pub fn all_se(&self) -> ArrayView2<'_, f64> {
297 self.all_se.view()
298 }
299
300 pub fn psi(&self) -> ArrayView3<'_, f64> {
302 self.psi.view()
303 }
304
305 pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
307 (self.psi_a.view(), self.psi_b.view())
308 }
309
310 pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
314 Blp::fit(self.orthogonal_signal()?.view(), basis)
315 }
316
317 pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
320 Blp::fit_gate(self.orthogonal_signal()?.view(), groups)
321 }
322
323 fn orthogonal_signal(&self) -> Result<Array2<f64>> {
324 if self.n_treat() != 1 || self.psi_a.iter().any(|&a| a != -1.0) {
325 return Err(DmlError::InvalidArgument(
326 "CATE / GATE need a single treatment and a score with psi_a = -1 (IRM with the ATE score)"
327 .into(),
328 ));
329 }
330 Ok(self.psi_b.index_axis(Axis(1), 0).to_owned())
331 }
332
333 pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
335 self.predictions.iter().map(|(n, _)| n.as_str())
336 }
337
338 pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
340 self.predictions
341 .iter()
342 .find(|(n, _)| n == name)
343 .map(|(_, p)| p.view())
344 }
345
346 pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
348 let p = self.predictions(name)?;
349 (rep < self.n_rep() && treatment < self.n_treat())
350 .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
351 }
352}
353
354pub(crate) fn confint_from(
357 all_coef: &Array2<f64>,
358 all_se: &Array2<f64>,
359 crit: &Array1<f64>,
360) -> Array2<f64> {
361 let (k, n_rep) = all_coef.dim();
362 let mut ci = Array2::zeros((k, 2));
363 for j in 0..k {
364 let lo: Vec<f64> = (0..n_rep)
365 .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
366 .collect();
367 let hi: Vec<f64> = (0..n_rep)
368 .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
369 .collect();
370 ci[[j, 0]] = median(&lo);
371 ci[[j, 1]] = median(&hi);
372 }
373 ci
374}
375
376impl DmlFit {
377 #[allow(clippy::too_many_arguments)]
382 pub(crate) fn from_scores(
383 model: String,
384 treatment_names: Vec<String>,
385 n_folds: usize,
386 all_coef: Array2<f64>,
387 psi: Array3<f64>,
388 psi_a: Array3<f64>,
389 psi_b: Array3<f64>,
390 predictions: Vec<(String, Array3<f64>)>,
391 ) -> Self {
392 let (n, k, n_rep) = psi.dim();
393 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
394 let mut all_se = Array2::<f64>::zeros((k, n_rep));
395 for j in 0..k {
396 for r in 0..n_rep {
397 let jac = psi_a.slice(ndarray::s![.., j, r]).sum() / n as f64;
398 let col = psi.slice(ndarray::s![.., j, r]);
399 psi_deriv[[j, r]] = jac;
400 all_se[[j, r]] = (col.dot(&col) / n as f64 / (jac * jac) / n as f64).sqrt();
401 }
402 }
403 let (coef, se) = aggregate(&all_coef, &all_se);
404 Self {
405 model,
406 treatment_names,
407 n_obs: n,
408 n_folds,
409 coef,
410 se,
411 all_coef,
412 all_se,
413 psi,
414 psi_a,
415 psi_b,
416 psi_deriv,
417 predictions,
418 sensitivity: None,
419 }
420 }
421
422 pub(crate) fn linear_combination(
426 &self,
427 model: String,
428 names: Vec<String>,
429 weights: &Array2<f64>,
430 ) -> Self {
431 let (n, k, n_rep) = self.psi.dim();
432 assert_eq!(weights.ncols(), k, "one weight per estimate");
433 let n_comb = weights.nrows();
434 let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
435 let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
436 let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
437 for r in 0..n_rep {
438 for c in 0..n_comb {
439 let mut col = Array1::<f64>::zeros(n);
440 let mut theta = 0.0;
441 for j in 0..k {
442 let w = weights[[c, j]];
443 if w != 0.0 {
444 col.scaled_add(
445 w / self.psi_deriv[[j, r]],
446 &self.psi.slice(ndarray::s![.., j, r]),
447 );
448 theta += w * self.all_coef[[j, r]];
449 }
450 }
451 all_coef[[c, r]] = theta;
452 all_se[[c, r]] = (col.dot(&col) / n as f64 / n as f64).sqrt();
453 scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
454 }
455 }
456 let (coef, se) = aggregate(&all_coef, &all_se);
457 Self {
458 model,
459 treatment_names: names,
460 n_obs: n,
461 n_folds: self.n_folds,
462 coef,
463 se,
464 all_coef,
465 all_se,
466 psi: scaled,
469 psi_a: Array3::zeros((n, n_comb, n_rep)),
470 psi_b: Array3::zeros((n, n_comb, n_rep)),
471 psi_deriv: Array2::ones((n_comb, n_rep)),
472 predictions: Vec::new(),
473 sensitivity: None,
474 }
475 }
476}
477
478pub(crate) fn check_level(level: f64) -> Result<()> {
479 if level > 0.0 && level < 1.0 {
480 Ok(())
481 } else {
482 Err(DmlError::InvalidArgument(
483 "confidence level must be in (0, 1)".into(),
484 ))
485 }
486}
487
488fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
490 let m = p.len();
491 let mut order: Vec<usize> = (0..m).collect();
492 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
493 let mut out = Array1::zeros(m);
494 let mut running = 0.0_f64;
495 for (rank, &i) in order.iter().enumerate() {
496 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
497 out[i] = running;
498 }
499 out
500}
501
502impl fmt::Display for DmlFit {
503 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504 writeln!(
505 f,
506 "{} | n_obs = {}, n_folds = {}, n_rep = {}",
507 self.model,
508 self.n_obs,
509 self.n_folds,
510 self.n_rep()
511 )?;
512 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
513 let (t, p) = (self.t_stat(), self.p_value());
514 let width = self
515 .treatment_names
516 .iter()
517 .map(String::len)
518 .max()
519 .unwrap_or(1)
520 .max(4);
521 write!(
522 f,
523 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
524 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
525 )?;
526 for j in 0..self.n_treat() {
527 write!(
528 f,
529 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
530 self.treatment_names[j],
531 self.coef[j],
532 self.se[j],
533 t[j],
534 p[j],
535 ci[[j, 0]],
536 ci[[j, 1]]
537 )?;
538 }
539 Ok(())
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use ndarray::array;
547
548 #[test]
549 fn aggregation_matches_doubleml_definition() {
550 let coef = array![[1.0, 2.0, 4.0]];
551 let se = array![[0.5, 0.1, 0.2]];
552 let (c, s) = aggregate(&coef, &se);
553 assert_eq!(c[0], 2.0);
554 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
556 }
557
558 #[test]
559 fn holm_matches_statsmodels() {
560 let adj = holm(array![0.01, 0.04, 0.03].view());
562 assert_eq!(adj, array![0.03, 0.06, 0.06]);
563 }
564}