1use std::fmt;
4
5use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3, Axis};
6
7use crate::blp::Blp;
8use crate::cluster::{derive_test_clusters, ClusterFold, ClusterInfo};
9use crate::crossfit::Fold;
10use crate::data::DmlData;
11use crate::error::{DmlError, Result};
12use crate::model::{LinearScoreModel, LossKind, ScoreInput};
13use crate::sensitivity::SensitivityStore;
14use crate::stats::{median, norm_ppf, norm_sf};
15
16pub fn fit_model<M: LinearScoreModel + ?Sized>(
24 model: &M,
25 data: &DmlData,
26 splits: &[Vec<Fold>],
27) -> Result<DmlFit> {
28 let cluster = match data.clusters() {
30 Some(c) => Some(ClusterInfo::new(
31 c.to_vec(),
32 splits
33 .iter()
34 .map(|rep| {
35 rep.iter()
36 .map(|f| ClusterFold {
37 fold: f.clone(),
38 test_clusters: derive_test_clusters(c, f),
39 })
40 .collect()
41 })
42 .collect(),
43 )?),
44 None => None,
45 };
46 fit_model_impl(model, data, splits, cluster)
47}
48
49pub fn fit_model_clustered<M: LinearScoreModel + ?Sized>(
52 model: &M,
53 data: &DmlData,
54 splits: &[Vec<ClusterFold>],
55) -> Result<DmlFit> {
56 let clusters = data.clusters().ok_or_else(|| {
57 DmlError::InvalidArgument(
58 "cluster folds need clustered data: use DmlData::with_clusters".into(),
59 )
60 })?;
61 let folds: Vec<Vec<Fold>> = splits
62 .iter()
63 .map(|rep| rep.iter().map(|cf| cf.fold.clone()).collect())
64 .collect();
65 let cluster = ClusterInfo::new(clusters.to_vec(), splits.to_vec())?;
66 fit_model_impl(model, data, &folds, Some(cluster))
67}
68
69fn fit_model_impl<M: LinearScoreModel + ?Sized>(
70 model: &M,
71 data: &DmlData,
72 splits: &[Vec<Fold>],
73 cluster: Option<ClusterInfo>,
74) -> Result<DmlFit> {
75 model.validate(data)?;
76 let n = data.n_obs();
77 check_splits(splits, n)?;
78 let (k, n_rep) = (data.n_treat(), splits.len());
79
80 let mut all_coef = Array2::<f64>::zeros((k, n_rep));
81 let mut all_se = Array2::<f64>::zeros((k, n_rep));
82 let mut psi = Array3::<f64>::zeros((n, k, n_rep));
83 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
84 let mut psi_a = Array3::<f64>::zeros((n, k, n_rep));
85 let mut psi_b = Array3::<f64>::zeros((n, k, n_rep));
86 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
87 let y_mean = data.y().mean().expect("n > 0");
89 let var_y = data
90 .y()
91 .mapv(|v| (v - y_mean).powi(2))
92 .mean()
93 .expect("n > 0");
94 let mut sensitivity = Some(SensitivityStore::new(n, k, n_rep, var_y));
95 let mut losses: Vec<NuisanceLoss> = Vec::new();
96
97 for j in 0..k {
98 let x_j = data.covariates_for(j);
99 let input = ScoreInput {
100 y: data.y(),
101 d: data.treatment(j),
102 x: x_j.view(),
103 z: data.z(),
104 t: data.time(),
105 };
106 for (r, folds) in splits.iter().enumerate() {
107 let est = model.estimate_score(&input, folds)?;
108 let mut sol = est.score.solve()?;
109 if let Some(ci) = &cluster {
110 let theta = ci.solve_theta(&est.score, r)?;
113 let psi_r = &est.score.psi_a * theta + &est.score.psi_b;
114 let sigma2 = ci.variance(psi_r.view(), est.score.psi_a.view(), r);
115 sol.theta = theta;
116 sol.se = sigma2.sqrt();
117 sol.psi = psi_r;
118 }
119 if !(sol.theta.is_finite() && sol.se.is_finite()) {
120 return Err(DmlError::InvalidArgument(format!(
121 "non-finite estimate for treatment {j}, repetition {r}"
122 )));
123 }
124 all_coef[[j, r]] = sol.theta;
125 all_se[[j, r]] = sol.se;
126 psi_deriv[[j, r]] = sol.j;
127 psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
128 psi_a
129 .slice_mut(ndarray::s![.., j, r])
130 .assign(&est.score.psi_a);
131 psi_b
132 .slice_mut(ndarray::s![.., j, r])
133 .assign(&est.score.psi_b);
134 for target in &est.targets {
135 let Some((_, pred)) = est.predictions.iter().find(|(nm, _)| *nm == target.name)
136 else {
137 continue;
138 };
139 let slot = match losses.iter().position(|l| l.name == target.name) {
140 Some(i) => i,
141 None => {
142 losses.push(NuisanceLoss {
143 name: target.name.to_string(),
144 kind: target.kind,
145 loss: Array2::from_elem((k, n_rep), f64::NAN),
146 targets: Array3::from_elem((n, n_rep, k), f64::NAN),
147 });
148 losses.len() - 1
149 }
150 };
151 losses[slot].loss[[j, r]] = target.loss(pred);
152 losses[slot]
153 .targets
154 .slice_mut(ndarray::s![.., r, j])
155 .assign(&target.target);
156 }
157 match (&est.sensitivity, &mut sensitivity) {
158 (Some(parts), Some(store)) => store.set(j, r, &parts.elements(sol.theta)),
159 _ => sensitivity = None,
160 }
161 for (name, pred) in est.predictions {
162 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
163 Some(i) => i,
164 None => {
165 predictions
166 .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
167 predictions.len() - 1
168 }
169 };
170 predictions[slot]
171 .1
172 .slice_mut(ndarray::s![.., r, j])
173 .assign(&pred);
174 }
175 }
176 }
177
178 let (coef, se) = aggregate(&all_coef, &all_se);
179 Ok(DmlFit {
180 model: model.name(),
181 treatment_names: data.treatment_names().to_vec(),
182 n_obs: n,
183 n_folds: splits[0].len(),
184 coef,
185 se,
186 all_coef,
187 all_se,
188 psi,
189 psi_a,
190 psi_b,
191 psi_deriv,
192 predictions,
193 sensitivity,
194 losses,
195 cluster,
196 })
197}
198
199#[derive(Debug, Clone)]
201pub(crate) struct NuisanceLoss {
202 pub name: String,
203 pub kind: LossKind,
204 pub loss: Array2<f64>,
206 pub targets: Array3<f64>,
208}
209
210fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
211 if splits.is_empty() {
212 return Err(DmlError::InvalidArgument("no sample splits given".into()));
213 }
214 for (r, folds) in splits.iter().enumerate() {
215 let mut seen = vec![false; n];
216 for fold in folds {
217 for &i in &fold.test {
218 if i >= n || seen[i] {
219 return Err(DmlError::InvalidArgument(format!(
220 "repetition {r}: test sets must partition 0..{n}"
221 )));
222 }
223 seen[i] = true;
224 }
225 if fold.train.iter().any(|&i| i >= n) {
226 return Err(DmlError::InvalidArgument(format!(
227 "repetition {r}: training index out of range"
228 )));
229 }
230 }
231 if seen.iter().any(|s| !s) {
232 return Err(DmlError::InvalidArgument(format!(
233 "repetition {r}: test sets must partition 0..{n}"
234 )));
235 }
236 }
237 Ok(())
238}
239
240pub(crate) fn aggregate(
243 all_coef: &Array2<f64>,
244 all_se: &Array2<f64>,
245) -> (Array1<f64>, Array1<f64>) {
246 const CRIT: f64 = 1.96;
247 let k = all_coef.nrows();
248 let mut coef = Array1::zeros(k);
249 let mut se = Array1::zeros(k);
250 for j in 0..k {
251 let c = all_coef.row(j);
252 coef[j] = median(&c.to_vec());
253 let upper: Vec<f64> = c
254 .iter()
255 .zip(all_se.row(j))
256 .map(|(&t, &s)| t + CRIT * s)
257 .collect();
258 se[j] = (median(&upper) - coef[j]) / CRIT;
259 }
260 (coef, se)
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum PAdjust {
266 Bonferroni,
267 Holm,
269}
270
271#[derive(Debug, Clone)]
277pub struct DmlFit {
278 pub(crate) model: String,
279 pub(crate) treatment_names: Vec<String>,
280 pub(crate) n_obs: usize,
281 pub(crate) n_folds: usize,
282 pub(crate) coef: Array1<f64>,
283 pub(crate) se: Array1<f64>,
284 pub(crate) all_coef: Array2<f64>,
285 pub(crate) all_se: Array2<f64>,
286 pub(crate) psi: Array3<f64>,
288 pub(crate) psi_a: Array3<f64>,
290 pub(crate) psi_b: Array3<f64>,
291 pub(crate) psi_deriv: Array2<f64>,
293 pub(crate) predictions: Vec<(String, Array3<f64>)>,
295 pub(crate) sensitivity: Option<SensitivityStore>,
297 pub(crate) losses: Vec<NuisanceLoss>,
299 pub(crate) cluster: Option<ClusterInfo>,
301}
302
303impl DmlFit {
304 pub fn model_name(&self) -> &str {
305 &self.model
306 }
307
308 pub fn treatment_names(&self) -> &[String] {
309 &self.treatment_names
310 }
311
312 pub fn n_obs(&self) -> usize {
313 self.n_obs
314 }
315
316 pub fn n_treat(&self) -> usize {
317 self.coef.len()
318 }
319
320 pub fn n_rep(&self) -> usize {
321 self.all_coef.ncols()
322 }
323
324 pub fn n_folds(&self) -> usize {
325 self.n_folds
326 }
327
328 pub fn n_clusters(&self) -> Option<usize> {
331 self.cluster.as_ref().map(ClusterInfo::n_clusters)
332 }
333
334 pub fn coef(&self) -> ArrayView1<'_, f64> {
336 self.coef.view()
337 }
338
339 pub fn se(&self) -> ArrayView1<'_, f64> {
341 self.se.view()
342 }
343
344 pub fn t_stat(&self) -> Array1<f64> {
345 &self.coef / &self.se
346 }
347
348 pub fn p_value(&self) -> Array1<f64> {
352 self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
353 }
354
355 fn per_rep_pvals(&self) -> Array2<f64> {
356 let mut p = &self.all_coef / &self.all_se;
357 p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
358 p
359 }
360
361 pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
364 check_level(level)?;
365 let z = norm_ppf(0.5 + level / 2.0);
366 Ok(confint_from(
367 &self.all_coef,
368 &self.all_se,
369 &Array1::from_elem(self.n_rep(), z),
370 ))
371 }
372
373 pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
377 let raw = self.per_rep_pvals();
378 let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
379 for (r, col) in raw.columns().into_iter().enumerate() {
380 let adj = match method {
381 PAdjust::Bonferroni => {
382 let m = col.len() as f64;
383 col.mapv(|p| (m * p).min(1.0))
384 }
385 PAdjust::Holm => holm(col),
386 };
387 adjusted.column_mut(r).assign(&adj);
388 }
389 adjusted
390 .rows()
391 .into_iter()
392 .map(|row| median(&row.to_vec()))
393 .collect()
394 }
395
396 pub fn all_coef(&self) -> ArrayView2<'_, f64> {
398 self.all_coef.view()
399 }
400
401 pub fn all_se(&self) -> ArrayView2<'_, f64> {
402 self.all_se.view()
403 }
404
405 pub fn psi(&self) -> ArrayView3<'_, f64> {
407 self.psi.view()
408 }
409
410 pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
412 (self.psi_a.view(), self.psi_b.view())
413 }
414
415 pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
419 Blp::fit(self.orthogonal_signal()?.view(), basis)
420 }
421
422 pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
425 Blp::fit_gate(self.orthogonal_signal()?.view(), groups)
426 }
427
428 fn orthogonal_signal(&self) -> Result<Array2<f64>> {
429 if self.n_treat() != 1 || self.psi_a.iter().any(|&a| a != -1.0) {
430 return Err(DmlError::InvalidArgument(
431 "CATE / GATE need a single treatment and a score with psi_a = -1 (IRM with the ATE score)"
432 .into(),
433 ));
434 }
435 Ok(self.psi_b.index_axis(Axis(1), 0).to_owned())
436 }
437
438 pub fn nuisance_loss(&self, name: &str) -> Option<ArrayView2<'_, f64>> {
442 self.losses
443 .iter()
444 .find(|l| l.name == name)
445 .map(|l| l.loss.view())
446 }
447
448 pub fn nuisance_losses(&self) -> impl Iterator<Item = (&str, LossKind, ArrayView2<'_, f64>)> {
450 self.losses
451 .iter()
452 .map(|l| (l.name.as_str(), l.kind, l.loss.view()))
453 }
454
455 pub fn nuisance_targets(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
458 self.losses
459 .iter()
460 .find(|l| l.name == name)
461 .map(|l| l.targets.view())
462 }
463
464 pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
466 self.predictions.iter().map(|(n, _)| n.as_str())
467 }
468
469 pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
471 self.predictions
472 .iter()
473 .find(|(n, _)| n == name)
474 .map(|(_, p)| p.view())
475 }
476
477 pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
479 let p = self.predictions(name)?;
480 (rep < self.n_rep() && treatment < self.n_treat())
481 .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
482 }
483}
484
485pub(crate) fn confint_from(
488 all_coef: &Array2<f64>,
489 all_se: &Array2<f64>,
490 crit: &Array1<f64>,
491) -> Array2<f64> {
492 let (k, n_rep) = all_coef.dim();
493 let mut ci = Array2::zeros((k, 2));
494 for j in 0..k {
495 let lo: Vec<f64> = (0..n_rep)
496 .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
497 .collect();
498 let hi: Vec<f64> = (0..n_rep)
499 .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
500 .collect();
501 ci[[j, 0]] = median(&lo);
502 ci[[j, 1]] = median(&hi);
503 }
504 ci
505}
506
507impl DmlFit {
508 #[allow(clippy::too_many_arguments)]
513 pub(crate) fn from_scores(
514 model: String,
515 treatment_names: Vec<String>,
516 n_folds: usize,
517 all_coef: Array2<f64>,
518 psi: Array3<f64>,
519 psi_a: Array3<f64>,
520 psi_b: Array3<f64>,
521 predictions: Vec<(String, Array3<f64>)>,
522 ) -> Self {
523 let (n, k, n_rep) = psi.dim();
524 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
525 let mut all_se = Array2::<f64>::zeros((k, n_rep));
526 for j in 0..k {
527 for r in 0..n_rep {
528 let jac = psi_a.slice(ndarray::s![.., j, r]).sum() / n as f64;
529 let col = psi.slice(ndarray::s![.., j, r]);
530 psi_deriv[[j, r]] = jac;
531 all_se[[j, r]] = (col.dot(&col) / n as f64 / (jac * jac) / n as f64).sqrt();
532 }
533 }
534 let (coef, se) = aggregate(&all_coef, &all_se);
535 Self {
536 model,
537 treatment_names,
538 n_obs: n,
539 n_folds,
540 coef,
541 se,
542 all_coef,
543 all_se,
544 psi,
545 psi_a,
546 psi_b,
547 psi_deriv,
548 predictions,
549 sensitivity: None,
550 losses: Vec::new(),
551 cluster: None,
552 }
553 }
554
555 pub(crate) fn linear_combination(
559 &self,
560 model: String,
561 names: Vec<String>,
562 weights: &Array2<f64>,
563 ) -> Self {
564 let (n, k, n_rep) = self.psi.dim();
565 assert_eq!(weights.ncols(), k, "one weight per estimate");
566 let n_comb = weights.nrows();
567 let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
568 let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
569 let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
570 for r in 0..n_rep {
571 for c in 0..n_comb {
572 let mut col = Array1::<f64>::zeros(n);
573 let mut theta = 0.0;
574 for j in 0..k {
575 let w = weights[[c, j]];
576 if w != 0.0 {
577 col.scaled_add(
578 w / self.psi_deriv[[j, r]],
579 &self.psi.slice(ndarray::s![.., j, r]),
580 );
581 theta += w * self.all_coef[[j, r]];
582 }
583 }
584 all_coef[[c, r]] = theta;
585 all_se[[c, r]] = (col.dot(&col) / n as f64 / n as f64).sqrt();
586 scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
587 }
588 }
589 let (coef, se) = aggregate(&all_coef, &all_se);
590 Self {
591 model,
592 treatment_names: names,
593 n_obs: n,
594 n_folds: self.n_folds,
595 coef,
596 se,
597 all_coef,
598 all_se,
599 psi: scaled,
602 psi_a: Array3::zeros((n, n_comb, n_rep)),
603 psi_b: Array3::zeros((n, n_comb, n_rep)),
604 psi_deriv: Array2::ones((n_comb, n_rep)),
605 predictions: Vec::new(),
606 sensitivity: None,
607 losses: Vec::new(),
608 cluster: None,
609 }
610 }
611}
612
613pub(crate) fn check_level(level: f64) -> Result<()> {
614 if level > 0.0 && level < 1.0 {
615 Ok(())
616 } else {
617 Err(DmlError::InvalidArgument(
618 "confidence level must be in (0, 1)".into(),
619 ))
620 }
621}
622
623fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
625 let m = p.len();
626 let mut order: Vec<usize> = (0..m).collect();
627 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
628 let mut out = Array1::zeros(m);
629 let mut running = 0.0_f64;
630 for (rank, &i) in order.iter().enumerate() {
631 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
632 out[i] = running;
633 }
634 out
635}
636
637impl fmt::Display for DmlFit {
638 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
639 let clustering = match &self.cluster {
640 Some(ci) if ci.n_ways() == 2 => {
641 format!(", two-way clustered ({} clusters)", ci.n_clusters())
642 }
643 Some(ci) => format!(", clustered ({} clusters)", ci.n_clusters()),
644 None => String::new(),
645 };
646 writeln!(
647 f,
648 "{} | n_obs = {}, n_folds = {}, n_rep = {}{clustering}",
649 self.model,
650 self.n_obs,
651 self.n_folds,
652 self.n_rep()
653 )?;
654 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
655 let (t, p) = (self.t_stat(), self.p_value());
656 let width = self
657 .treatment_names
658 .iter()
659 .map(String::len)
660 .max()
661 .unwrap_or(1)
662 .max(4);
663 write!(
664 f,
665 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
666 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
667 )?;
668 for j in 0..self.n_treat() {
669 write!(
670 f,
671 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
672 self.treatment_names[j],
673 self.coef[j],
674 self.se[j],
675 t[j],
676 p[j],
677 ci[[j, 0]],
678 ci[[j, 1]]
679 )?;
680 }
681 if !self.losses.is_empty() {
682 write!(
683 f,
684 "\nNuisance learners (out-of-sample, mean over repetitions):"
685 )?;
686 for l in &self.losses {
687 let metric = match l.kind {
688 LossKind::Rmse => "RMSE",
689 LossKind::LogLoss => "log loss",
690 };
691 for j in 0..self.n_treat() {
692 let row: Vec<f64> = l
693 .loss
694 .row(j)
695 .iter()
696 .copied()
697 .filter(|v| v.is_finite())
698 .collect();
699 if row.is_empty() {
700 continue;
701 }
702 let mean = row.iter().sum::<f64>() / row.len() as f64;
703 let who = if self.n_treat() > 1 {
704 format!(" ({})", self.treatment_names[j])
705 } else {
706 String::new()
707 };
708 write!(f, "\n {:<12} {metric:<8} {mean:.4}{who}", l.name)?;
709 }
710 }
711 }
712 Ok(())
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use ndarray::array;
720
721 #[test]
722 fn aggregation_matches_doubleml_definition() {
723 let coef = array![[1.0, 2.0, 4.0]];
724 let se = array![[0.5, 0.1, 0.2]];
725 let (c, s) = aggregate(&coef, &se);
726 assert_eq!(c[0], 2.0);
727 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
729 }
730
731 #[test]
732 fn holm_matches_statsmodels() {
733 let adj = holm(array![0.01, 0.04, 0.03].view());
735 assert_eq!(adj, array![0.03, 0.06, 0.06]);
736 }
737}