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::{CateSignal, LinearScoreModel, LossKind, ScoreInput};
13use crate::policy::PolicyTree;
14use crate::sensitivity::SensitivityStore;
15use crate::stats::{median, norm_ppf, norm_sf};
16
17pub fn fit_model<M: LinearScoreModel + ?Sized>(
25 model: &M,
26 data: &DmlData,
27 splits: &[Vec<Fold>],
28) -> Result<DmlFit> {
29 let cluster = match data.clusters() {
31 Some(c) => Some(ClusterInfo::new(
32 c.to_vec(),
33 splits
34 .iter()
35 .map(|rep| {
36 rep.iter()
37 .map(|f| ClusterFold {
38 fold: f.clone(),
39 test_clusters: derive_test_clusters(c, f),
40 })
41 .collect()
42 })
43 .collect(),
44 )?),
45 None => None,
46 };
47 fit_model_impl(model, data, splits, cluster)
48}
49
50pub fn fit_model_clustered<M: LinearScoreModel + ?Sized>(
53 model: &M,
54 data: &DmlData,
55 splits: &[Vec<ClusterFold>],
56) -> Result<DmlFit> {
57 let clusters = data.clusters().ok_or_else(|| {
58 DmlError::InvalidArgument(
59 "cluster folds need clustered data: use DmlData::with_clusters".into(),
60 )
61 })?;
62 let folds: Vec<Vec<Fold>> = splits
63 .iter()
64 .map(|rep| rep.iter().map(|cf| cf.fold.clone()).collect())
65 .collect();
66 let cluster = ClusterInfo::new(clusters.to_vec(), splits.to_vec())?;
67 fit_model_impl(model, data, &folds, Some(cluster))
68}
69
70fn fit_model_impl<M: LinearScoreModel + ?Sized>(
71 model: &M,
72 data: &DmlData,
73 splits: &[Vec<Fold>],
74 cluster: Option<ClusterInfo>,
75) -> Result<DmlFit> {
76 model.validate(data)?;
77 let n = data.n_obs();
78 check_splits(splits, n)?;
79 let (k, n_rep) = (data.n_treat(), splits.len());
80
81 let mut all_coef = Array2::<f64>::zeros((k, n_rep));
82 let mut all_se = Array2::<f64>::zeros((k, n_rep));
83 let mut psi = Array3::<f64>::zeros((n, k, n_rep));
84 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
85 let mut psi_a = Array3::<f64>::zeros((n, k, n_rep));
86 let mut psi_b = Array3::<f64>::zeros((n, k, n_rep));
87 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
88 let y_mean = data.y().mean().expect("n > 0");
90 let var_y = data
91 .y()
92 .mapv(|v| (v - y_mean).powi(2))
93 .mean()
94 .expect("n > 0");
95 let mut sensitivity = Some(SensitivityStore::new(n, k, n_rep, var_y));
96 let mut cate = Some(CateStore {
97 signal: Array3::zeros((n, k, n_rep)),
98 scale: None,
99 is_effect: true,
100 });
101 let mut losses: Vec<NuisanceLoss> = Vec::new();
102
103 for j in 0..k {
104 let x_j = data.covariates_for(j);
105 for (r, folds) in splits.iter().enumerate() {
106 let input = ScoreInput {
107 y: data.y(),
108 d: data.treatment(j),
109 x: x_j.view(),
110 z: data.z(),
111 instruments: data.instruments(),
112 t: data.time(),
113 s: data.selection(),
114 rep: r,
115 treatment: j,
116 external: data.external_predictions(),
117 };
118 let est = model.estimate_score(&input, folds)?;
119 let mut sol = est.score.solve()?;
120 if let Some(ci) = &cluster {
121 let theta = ci.solve_theta(&est.score, r)?;
124 let psi_r = &est.score.psi_a * theta + &est.score.psi_b;
125 let sigma2 = ci.variance(psi_r.view(), est.score.psi_a.view(), r);
126 sol.theta = theta;
127 sol.se = sigma2.sqrt();
128 sol.psi = psi_r;
129 }
130 if !(sol.theta.is_finite() && sol.se.is_finite()) {
131 return Err(DmlError::InvalidArgument(format!(
132 "non-finite estimate for treatment {j}, repetition {r}"
133 )));
134 }
135 all_coef[[j, r]] = sol.theta;
136 all_se[[j, r]] = sol.se;
137 psi_deriv[[j, r]] = sol.j;
138 psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
139 psi_a
140 .slice_mut(ndarray::s![.., j, r])
141 .assign(&est.score.psi_a);
142 psi_b
143 .slice_mut(ndarray::s![.., j, r])
144 .assign(&est.score.psi_b);
145 for target in &est.targets {
146 if data
148 .external_predictions()
149 .is_some_and(|e| e.contains(j, &target.name))
150 {
151 continue;
152 }
153 let Some((_, pred)) = est.predictions.iter().find(|(nm, _)| *nm == target.name)
154 else {
155 continue;
156 };
157 let slot = match losses.iter().position(|l| l.name == target.name) {
158 Some(i) => i,
159 None => {
160 losses.push(NuisanceLoss {
161 name: target.name.to_string(),
162 kind: target.kind,
163 loss: Array2::from_elem((k, n_rep), f64::NAN),
164 targets: Array3::from_elem((n, n_rep, k), f64::NAN),
165 });
166 losses.len() - 1
167 }
168 };
169 losses[slot].loss[[j, r]] = target.loss(pred);
170 losses[slot]
171 .targets
172 .slice_mut(ndarray::s![.., r, j])
173 .assign(&target.target);
174 }
175 match (&est.sensitivity, &mut sensitivity) {
176 (Some(parts), Some(store)) => store.set(j, r, &parts.elements(sol.theta)),
177 _ => sensitivity = None,
178 }
179 match (&est.cate, &mut cate) {
180 (Some(c), Some(store)) => store.set(j, r, c, sol.theta),
181 _ => cate = None,
182 }
183 for (name, pred) in est.predictions {
184 let slot = match predictions.iter().position(|(nm, _)| *nm == name) {
185 Some(i) => i,
186 None => {
187 predictions
188 .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
189 predictions.len() - 1
190 }
191 };
192 predictions[slot]
193 .1
194 .slice_mut(ndarray::s![.., r, j])
195 .assign(&pred);
196 }
197 }
198 }
199
200 if let Some(store) = &mut sensitivity {
201 store.validate();
202 }
203 let (coef, se) = aggregate(&all_coef, &all_se);
204 Ok(DmlFit {
205 model: model.name(),
206 treatment_names: data.treatment_names().to_vec(),
207 n_obs: n,
208 n_folds: splits[0].len(),
209 coef,
210 se,
211 all_coef,
212 all_se,
213 psi,
214 psi_a,
215 psi_b,
216 psi_deriv,
217 predictions,
218 sensitivity,
219 cate,
220 losses,
221 cluster,
222 })
223}
224
225#[derive(Debug, Clone)]
228pub(crate) struct CateStore {
229 pub signal: Array3<f64>,
230 pub scale: Option<Array3<f64>>,
231 pub is_effect: bool,
232}
233
234impl CateStore {
235 fn set(&mut self, j: usize, r: usize, c: &CateSignal, theta: f64) {
236 let signal = match &c.theta_factor {
237 Some(f) => &c.signal - &(theta * f),
238 None => c.signal.clone(),
239 };
240 self.signal.slice_mut(ndarray::s![.., j, r]).assign(&signal);
241 self.is_effect &= c.is_effect;
242 if let Some(scale) = &c.basis_scale {
243 let dim = self.signal.dim();
244 self.scale
245 .get_or_insert_with(|| Array3::zeros(dim))
246 .slice_mut(ndarray::s![.., j, r])
247 .assign(scale);
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub(crate) struct NuisanceLoss {
255 pub name: String,
256 pub kind: LossKind,
257 pub loss: Array2<f64>,
259 pub targets: Array3<f64>,
261}
262
263fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
264 if splits.is_empty() {
265 return Err(DmlError::InvalidArgument("no sample splits given".into()));
266 }
267 for (r, folds) in splits.iter().enumerate() {
268 let mut seen = vec![false; n];
269 for fold in folds {
270 for &i in &fold.test {
271 if i >= n || seen[i] {
272 return Err(DmlError::InvalidArgument(format!(
273 "repetition {r}: test sets must partition 0..{n}"
274 )));
275 }
276 seen[i] = true;
277 }
278 if fold.train.iter().any(|&i| i >= n) {
279 return Err(DmlError::InvalidArgument(format!(
280 "repetition {r}: training index out of range"
281 )));
282 }
283 }
284 if seen.iter().any(|s| !s) {
285 return Err(DmlError::InvalidArgument(format!(
286 "repetition {r}: test sets must partition 0..{n}"
287 )));
288 }
289 }
290 Ok(())
291}
292
293pub(crate) fn aggregate(
296 all_coef: &Array2<f64>,
297 all_se: &Array2<f64>,
298) -> (Array1<f64>, Array1<f64>) {
299 const CRIT: f64 = 1.96;
300 let k = all_coef.nrows();
301 let mut coef = Array1::zeros(k);
302 let mut se = Array1::zeros(k);
303 for j in 0..k {
304 let c = all_coef.row(j);
305 coef[j] = median(&c.to_vec());
306 let upper: Vec<f64> = c
307 .iter()
308 .zip(all_se.row(j))
309 .map(|(&t, &s)| t + CRIT * s)
310 .collect();
311 se[j] = (median(&upper) - coef[j]) / CRIT;
312 }
313 (coef, se)
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum PAdjust {
319 Bonferroni,
320 Holm,
322}
323
324#[derive(Debug, Clone)]
330pub struct DmlFit {
331 pub(crate) model: String,
332 pub(crate) treatment_names: Vec<String>,
333 pub(crate) n_obs: usize,
334 pub(crate) n_folds: usize,
335 pub(crate) coef: Array1<f64>,
336 pub(crate) se: Array1<f64>,
337 pub(crate) all_coef: Array2<f64>,
338 pub(crate) all_se: Array2<f64>,
339 pub(crate) psi: Array3<f64>,
341 pub(crate) psi_a: Array3<f64>,
343 pub(crate) psi_b: Array3<f64>,
344 pub(crate) psi_deriv: Array2<f64>,
346 pub(crate) predictions: Vec<(String, Array3<f64>)>,
348 pub(crate) sensitivity: Option<SensitivityStore>,
350 pub(crate) cate: Option<CateStore>,
352 pub(crate) losses: Vec<NuisanceLoss>,
354 pub(crate) cluster: Option<ClusterInfo>,
356}
357
358impl DmlFit {
359 pub fn model_name(&self) -> &str {
360 &self.model
361 }
362
363 pub fn treatment_names(&self) -> &[String] {
364 &self.treatment_names
365 }
366
367 pub fn n_obs(&self) -> usize {
368 self.n_obs
369 }
370
371 pub fn n_treat(&self) -> usize {
372 self.coef.len()
373 }
374
375 pub fn n_rep(&self) -> usize {
376 self.all_coef.ncols()
377 }
378
379 pub fn n_folds(&self) -> usize {
380 self.n_folds
381 }
382
383 pub fn n_clusters(&self) -> Option<usize> {
386 self.cluster.as_ref().map(ClusterInfo::n_clusters)
387 }
388
389 pub fn coef(&self) -> ArrayView1<'_, f64> {
391 self.coef.view()
392 }
393
394 pub fn se(&self) -> ArrayView1<'_, f64> {
396 self.se.view()
397 }
398
399 pub fn t_stat(&self) -> Array1<f64> {
400 &self.coef / &self.se
401 }
402
403 pub fn p_value(&self) -> Array1<f64> {
407 self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
408 }
409
410 fn per_rep_pvals(&self) -> Array2<f64> {
411 let mut p = &self.all_coef / &self.all_se;
412 p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
413 p
414 }
415
416 pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
419 check_level(level)?;
420 let z = norm_ppf(0.5 + level / 2.0);
421 Ok(confint_from(
422 &self.all_coef,
423 &self.all_se,
424 &Array1::from_elem(self.n_rep(), z),
425 ))
426 }
427
428 pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
432 let raw = self.per_rep_pvals();
433 let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
434 for (r, col) in raw.columns().into_iter().enumerate() {
435 let adj = match method {
436 PAdjust::Bonferroni => {
437 let m = col.len() as f64;
438 col.mapv(|p| (m * p).min(1.0))
439 }
440 PAdjust::Holm => holm(col),
441 };
442 adjusted.column_mut(r).assign(&adj);
443 }
444 adjusted
445 .rows()
446 .into_iter()
447 .map(|row| median(&row.to_vec()))
448 .collect()
449 }
450
451 pub fn all_coef(&self) -> ArrayView2<'_, f64> {
453 self.all_coef.view()
454 }
455
456 pub fn all_se(&self) -> ArrayView2<'_, f64> {
457 self.all_se.view()
458 }
459
460 pub fn psi(&self) -> ArrayView3<'_, f64> {
462 self.psi.view()
463 }
464
465 pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
467 (self.psi_a.view(), self.psi_b.view())
468 }
469
470 pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
477 let (signal, bases) = self.cate_inputs(basis)?;
478 Blp::fit_bases(signal.view(), &bases)
479 }
480
481 pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
484 let (signal, bases) = self.cate_inputs(groups)?;
485 Blp::fit_gate_bases(signal.view(), groups, &bases)
486 }
487
488 pub fn policy_tree(
493 &self,
494 features: ArrayView2<'_, f64>,
495 depth: usize,
496 min_samples_leaf: usize,
497 ccp_alpha: f64,
498 ) -> Result<PolicyTree> {
499 let store = self.cate.as_ref().filter(|c| c.is_effect).ok_or_else(|| {
500 DmlError::InvalidArgument(format!(
501 "policy trees need a treatment-effect signal (IRM with the ATE score), not {}",
502 self.model
503 ))
504 })?;
505 if self.n_treat() != 1 || self.n_rep() != 1 {
506 return Err(DmlError::InvalidArgument(
507 "policy trees need a single treatment and one repetition (as DoubleML)".into(),
508 ));
509 }
510 if features.nrows() != self.n_obs {
511 return Err(DmlError::DimensionMismatch(format!(
512 "features have {} rows, the data {}",
513 features.nrows(),
514 self.n_obs
515 )));
516 }
517 PolicyTree::fit(
518 store.signal.slice(ndarray::s![.., 0, 0]),
519 features,
520 depth,
521 min_samples_leaf,
522 ccp_alpha,
523 )
524 }
525
526 fn cate_inputs(&self, basis: ArrayView2<'_, f64>) -> Result<(Array2<f64>, Vec<Array2<f64>>)> {
528 let store = self.cate.as_ref().ok_or_else(|| {
529 DmlError::InvalidArgument(format!(
530 "CATE / GATE are not available for {} (PLR, IRM with the ATE score and APO support them)",
531 self.model
532 ))
533 })?;
534 if self.n_treat() != 1 {
535 return Err(DmlError::InvalidArgument(
536 "CATE / GATE need a single treatment".into(),
537 ));
538 }
539 if basis.nrows() != self.n_obs {
540 return Err(DmlError::DimensionMismatch(format!(
541 "basis has {} rows, the data {}",
542 basis.nrows(),
543 self.n_obs
544 )));
545 }
546 let signal = store.signal.index_axis(Axis(1), 0).to_owned();
547 let bases = (0..self.n_rep())
548 .map(|r| match &store.scale {
549 Some(scale) => {
550 let s = scale.slice(ndarray::s![.., 0, r]);
551 &basis * &s.insert_axis(Axis(1))
552 }
553 None => basis.to_owned(),
554 })
555 .collect();
556 Ok((signal, bases))
557 }
558
559 pub fn nuisance_loss(&self, name: &str) -> Option<ArrayView2<'_, f64>> {
563 self.losses
564 .iter()
565 .find(|l| l.name == name)
566 .map(|l| l.loss.view())
567 }
568
569 pub fn nuisance_losses(&self) -> impl Iterator<Item = (&str, LossKind, ArrayView2<'_, f64>)> {
571 self.losses
572 .iter()
573 .map(|l| (l.name.as_str(), l.kind, l.loss.view()))
574 }
575
576 pub fn nuisance_targets(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
579 self.losses
580 .iter()
581 .find(|l| l.name == name)
582 .map(|l| l.targets.view())
583 }
584
585 pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
587 self.predictions.iter().map(|(n, _)| n.as_str())
588 }
589
590 pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
592 self.predictions
593 .iter()
594 .find(|(n, _)| n == name)
595 .map(|(_, p)| p.view())
596 }
597
598 pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
600 let p = self.predictions(name)?;
601 (rep < self.n_rep() && treatment < self.n_treat())
602 .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
603 }
604}
605
606pub(crate) fn confint_from(
609 all_coef: &Array2<f64>,
610 all_se: &Array2<f64>,
611 crit: &Array1<f64>,
612) -> Array2<f64> {
613 let (k, n_rep) = all_coef.dim();
614 let mut ci = Array2::zeros((k, 2));
615 for j in 0..k {
616 let lo: Vec<f64> = (0..n_rep)
617 .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
618 .collect();
619 let hi: Vec<f64> = (0..n_rep)
620 .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
621 .collect();
622 ci[[j, 0]] = median(&lo);
623 ci[[j, 1]] = median(&hi);
624 }
625 ci
626}
627
628impl DmlFit {
629 #[allow(clippy::too_many_arguments)]
634 pub(crate) fn from_scores(
635 model: String,
636 treatment_names: Vec<String>,
637 n_folds: usize,
638 all_coef: Array2<f64>,
639 psi: Array3<f64>,
640 psi_a: Array3<f64>,
641 psi_b: Array3<f64>,
642 predictions: Vec<(String, Array3<f64>)>,
643 ) -> Self {
644 let (n, k, n_rep) = psi.dim();
645 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
646 let mut all_se = Array2::<f64>::zeros((k, n_rep));
647 for j in 0..k {
648 for r in 0..n_rep {
649 let jac = psi_a.slice(ndarray::s![.., j, r]).sum() / n as f64;
650 let col = psi.slice(ndarray::s![.., j, r]);
651 psi_deriv[[j, r]] = jac;
652 all_se[[j, r]] = (col.dot(&col) / n as f64 / (jac * jac) / n as f64).sqrt();
653 }
654 }
655 let (coef, se) = aggregate(&all_coef, &all_se);
656 Self {
657 model,
658 treatment_names,
659 n_obs: n,
660 n_folds,
661 coef,
662 se,
663 all_coef,
664 all_se,
665 psi,
666 psi_a,
667 psi_b,
668 psi_deriv,
669 predictions,
670 sensitivity: None,
671 cate: None,
672 losses: Vec::new(),
673 cluster: None,
674 }
675 }
676
677 pub fn linear_combination(
698 &self,
699 weights: ArrayView2<'_, f64>,
700 names: Vec<String>,
701 ) -> Result<DmlFit> {
702 if weights.ncols() != self.n_treat() || weights.nrows() == 0 {
703 return Err(DmlError::DimensionMismatch(format!(
704 "weights need {} columns (one per estimate) and at least one row, got {:?}",
705 self.n_treat(),
706 weights.dim()
707 )));
708 }
709 if names.len() != weights.nrows() {
710 return Err(DmlError::DimensionMismatch(format!(
711 "{} names for {} combinations",
712 names.len(),
713 weights.nrows()
714 )));
715 }
716 if !weights.iter().all(|w| w.is_finite()) {
717 return Err(DmlError::InvalidArgument("weights must be finite".into()));
718 }
719 Ok(self.combine(self.model.clone(), names, &weights.to_owned()))
720 }
721
722 pub(crate) fn combine(&self, model: String, names: Vec<String>, weights: &Array2<f64>) -> Self {
724 let (n, k, n_rep) = self.psi.dim();
725 assert_eq!(weights.ncols(), k, "one weight per estimate");
726 let n_comb = weights.nrows();
727 let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
728 let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
729 let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
730 for r in 0..n_rep {
731 for c in 0..n_comb {
732 let mut col = Array1::<f64>::zeros(n);
733 let mut theta = 0.0;
734 for j in 0..k {
735 let w = weights[[c, j]];
736 if w != 0.0 {
737 col.scaled_add(
738 w / self.psi_deriv[[j, r]],
739 &self.psi.slice(ndarray::s![.., j, r]),
740 );
741 theta += w * self.all_coef[[j, r]];
742 }
743 }
744 all_coef[[c, r]] = theta;
745 all_se[[c, r]] = match &self.cluster {
746 Some(ci) => ci.variance(col.view(), Array1::ones(n).view(), r).sqrt(),
748 None => (col.dot(&col) / n as f64 / n as f64).sqrt(),
749 };
750 scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
751 }
752 }
753 let (coef, se) = aggregate(&all_coef, &all_se);
754 Self {
755 model,
756 treatment_names: names,
757 n_obs: n,
758 n_folds: self.n_folds,
759 coef,
760 se,
761 all_coef,
762 all_se,
763 psi: scaled,
766 psi_a: Array3::zeros((n, n_comb, n_rep)),
767 psi_b: Array3::zeros((n, n_comb, n_rep)),
768 psi_deriv: Array2::ones((n_comb, n_rep)),
769 predictions: Vec::new(),
770 sensitivity: self.sensitivity.as_ref().map(|s| s.combine(weights)),
771 cate: None,
772 losses: Vec::new(),
773 cluster: self.cluster.clone(),
774 }
775 }
776
777 pub fn concat(fits: &[DmlFit]) -> Result<DmlFit> {
786 let first = fits
787 .first()
788 .ok_or_else(|| DmlError::InvalidArgument("concat needs at least one fit".into()))?;
789 let (n, n_rep) = (first.n_obs, first.n_rep());
790 for f in fits {
791 if f.n_obs != n || f.n_rep() != n_rep {
792 return Err(DmlError::DimensionMismatch(
793 "concat needs fits with the same observations and repetitions".into(),
794 ));
795 }
796 let same_clusters = match (&f.cluster, &first.cluster) {
797 (None, None) => true,
798 (Some(a), Some(b)) => a.clusters == b.clusters && a.folds == b.folds,
799 _ => false,
800 };
801 if !same_clusters {
802 return Err(DmlError::InvalidArgument(
803 "concat needs the same clusters and cluster splits in every fit".into(),
804 ));
805 }
806 }
807 let cat1 = |f: fn(&DmlFit) -> &Array1<f64>| {
808 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
809 ndarray::concatenate(Axis(0), &v).expect("1-D")
810 };
811 let cat2 = |f: fn(&DmlFit) -> &Array2<f64>| {
812 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
813 ndarray::concatenate(Axis(0), &v).expect("same n_rep")
814 };
815 let cat3 = |f: fn(&DmlFit) -> &Array3<f64>| {
816 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
817 ndarray::concatenate(Axis(1), &v).expect("same n and n_rep")
818 };
819 let ks: Vec<usize> = fits.iter().map(DmlFit::n_treat).collect();
820 let k_total: usize = ks.iter().sum();
821
822 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
824 let mut losses: Vec<NuisanceLoss> = Vec::new();
825 let mut offset = 0;
826 for (f, &k) in fits.iter().zip(&ks) {
827 for (name, pred) in &f.predictions {
828 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
829 Some(i) => i,
830 None => {
831 predictions.push((
832 name.clone(),
833 Array3::from_elem((n, n_rep, k_total), f64::NAN),
834 ));
835 predictions.len() - 1
836 }
837 };
838 predictions[slot]
839 .1
840 .slice_mut(ndarray::s![.., .., offset..offset + k])
841 .assign(pred);
842 }
843 for l in &f.losses {
844 let slot = match losses.iter().position(|x| x.name == l.name) {
845 Some(i) => i,
846 None => {
847 losses.push(NuisanceLoss {
848 name: l.name.clone(),
849 kind: l.kind,
850 loss: Array2::from_elem((k_total, n_rep), f64::NAN),
851 targets: Array3::from_elem((n, n_rep, k_total), f64::NAN),
852 });
853 losses.len() - 1
854 }
855 };
856 losses[slot]
857 .loss
858 .slice_mut(ndarray::s![offset..offset + k, ..])
859 .assign(&l.loss);
860 losses[slot]
861 .targets
862 .slice_mut(ndarray::s![.., .., offset..offset + k])
863 .assign(&l.targets);
864 }
865 offset += k;
866 }
867 let stores: Option<Vec<&SensitivityStore>> =
868 fits.iter().map(|f| f.sensitivity.as_ref()).collect();
869 let cates: Option<Vec<&CateStore>> = fits.iter().map(|f| f.cate.as_ref()).collect();
871 let cate_store = cates.and_then(|c| {
872 let views: Vec<_> = c.iter().map(|s| s.signal.view()).collect();
873 let signal = ndarray::concatenate(Axis(1), &views).ok()?;
874 let scales: Option<Vec<_>> = c
875 .iter()
876 .map(|s| s.scale.as_ref().map(|a| a.view()))
877 .collect();
878 let is_effect = c.iter().all(|s| s.is_effect);
879 match scales {
880 Some(v) => Some(CateStore {
881 signal,
882 scale: Some(ndarray::concatenate(Axis(1), &v).ok()?),
883 is_effect,
884 }),
885 None if c.iter().all(|s| s.scale.is_none()) => Some(CateStore {
886 signal,
887 scale: None,
888 is_effect,
889 }),
890 None => None,
891 }
892 });
893 let names: Vec<&str> = fits.iter().map(|f| f.model.as_str()).collect();
894 Ok(DmlFit {
895 model: if names.iter().all(|m| *m == names[0]) {
896 names[0].to_string()
897 } else {
898 names.join(" + ")
899 },
900 treatment_names: fits
901 .iter()
902 .flat_map(|f| f.treatment_names.iter().cloned())
903 .collect(),
904 n_obs: n,
905 n_folds: first.n_folds,
906 coef: cat1(|f| &f.coef),
907 se: cat1(|f| &f.se),
908 all_coef: cat2(|f| &f.all_coef),
909 all_se: cat2(|f| &f.all_se),
910 psi: cat3(|f| &f.psi),
911 psi_a: cat3(|f| &f.psi_a),
912 psi_b: cat3(|f| &f.psi_b),
913 psi_deriv: cat2(|f| &f.psi_deriv),
914 predictions,
915 sensitivity: stores.map(|s| SensitivityStore::concat(&s)),
916 cate: cate_store,
917 losses,
918 cluster: first.cluster.clone(),
919 })
920 }
921}
922
923fn elementwise(a: &DmlFit, b: &DmlFit, sign: f64) -> Result<DmlFit> {
926 let k = a.n_treat();
927 if b.n_treat() != k {
928 return Err(DmlError::DimensionMismatch(format!(
929 "cannot combine fits with {k} and {} estimates",
930 b.n_treat()
931 )));
932 }
933 let both = DmlFit::concat(&[a.clone(), b.clone()])?;
934 let mut weights = Array2::<f64>::zeros((k, 2 * k));
935 for j in 0..k {
936 weights[[j, j]] = 1.0;
937 weights[[j, k + j]] = sign;
938 }
939 let op = if sign > 0.0 { "+" } else { "-" };
940 let names = a
941 .treatment_names
942 .iter()
943 .zip(&b.treatment_names)
944 .map(|(x, y)| {
945 if x == y {
946 x.clone()
947 } else {
948 format!("{x} {op} {y}")
949 }
950 })
951 .collect();
952 let model = if a.model == b.model {
953 a.model.clone()
954 } else {
955 format!("{} {op} {}", a.model, b.model)
956 };
957 Ok(both.combine(model, names, &weights))
958}
959
960impl std::ops::Add for &DmlFit {
965 type Output = Result<DmlFit>;
966 fn add(self, other: &DmlFit) -> Result<DmlFit> {
967 elementwise(self, other, 1.0)
968 }
969}
970
971impl std::ops::Sub for &DmlFit {
973 type Output = Result<DmlFit>;
974 fn sub(self, other: &DmlFit) -> Result<DmlFit> {
975 elementwise(self, other, -1.0)
976 }
977}
978
979impl std::ops::Mul<f64> for &DmlFit {
981 type Output = DmlFit;
982 fn mul(self, c: f64) -> DmlFit {
983 let weights = Array2::from_diag(&Array1::from_elem(self.n_treat(), c));
984 self.combine(self.model.clone(), self.treatment_names.clone(), &weights)
985 }
986}
987
988impl std::ops::Mul<&DmlFit> for f64 {
990 type Output = DmlFit;
991 fn mul(self, fit: &DmlFit) -> DmlFit {
992 fit * self
993 }
994}
995
996impl std::ops::Neg for &DmlFit {
998 type Output = DmlFit;
999 fn neg(self) -> DmlFit {
1000 self * -1.0
1001 }
1002}
1003
1004pub(crate) fn check_level(level: f64) -> Result<()> {
1005 if level > 0.0 && level < 1.0 {
1006 Ok(())
1007 } else {
1008 Err(DmlError::InvalidArgument(
1009 "confidence level must be in (0, 1)".into(),
1010 ))
1011 }
1012}
1013
1014fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
1016 let m = p.len();
1017 let mut order: Vec<usize> = (0..m).collect();
1018 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
1019 let mut out = Array1::zeros(m);
1020 let mut running = 0.0_f64;
1021 for (rank, &i) in order.iter().enumerate() {
1022 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
1023 out[i] = running;
1024 }
1025 out
1026}
1027
1028impl fmt::Display for DmlFit {
1029 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1030 let clustering = match &self.cluster {
1031 Some(ci) if ci.n_ways() == 2 => {
1032 format!(", two-way clustered ({} clusters)", ci.n_clusters())
1033 }
1034 Some(ci) => format!(", clustered ({} clusters)", ci.n_clusters()),
1035 None => String::new(),
1036 };
1037 writeln!(
1038 f,
1039 "{} | n_obs = {}, n_folds = {}, n_rep = {}{clustering}",
1040 self.model,
1041 self.n_obs,
1042 self.n_folds,
1043 self.n_rep()
1044 )?;
1045 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
1046 let (t, p) = (self.t_stat(), self.p_value());
1047 let width = self
1048 .treatment_names
1049 .iter()
1050 .map(String::len)
1051 .max()
1052 .unwrap_or(1)
1053 .max(4);
1054 write!(
1055 f,
1056 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
1057 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
1058 )?;
1059 for j in 0..self.n_treat() {
1060 write!(
1061 f,
1062 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
1063 self.treatment_names[j],
1064 self.coef[j],
1065 self.se[j],
1066 t[j],
1067 p[j],
1068 ci[[j, 0]],
1069 ci[[j, 1]]
1070 )?;
1071 }
1072 if !self.losses.is_empty() {
1073 write!(
1074 f,
1075 "\nNuisance learners (out-of-sample, mean over repetitions):"
1076 )?;
1077 for l in &self.losses {
1078 let metric = match l.kind {
1079 LossKind::Rmse => "RMSE",
1080 LossKind::LogLoss => "log loss",
1081 };
1082 for j in 0..self.n_treat() {
1083 let row: Vec<f64> = l
1084 .loss
1085 .row(j)
1086 .iter()
1087 .copied()
1088 .filter(|v| v.is_finite())
1089 .collect();
1090 if row.is_empty() {
1091 continue;
1092 }
1093 let mean = row.iter().sum::<f64>() / row.len() as f64;
1094 let who = if self.n_treat() > 1 {
1095 format!(" ({})", self.treatment_names[j])
1096 } else {
1097 String::new()
1098 };
1099 write!(f, "\n {:<12} {metric:<8} {mean:.4}{who}", l.name)?;
1100 }
1101 }
1102 }
1103 Ok(())
1104 }
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use ndarray::array;
1111
1112 #[test]
1113 fn aggregation_matches_doubleml_definition() {
1114 let coef = array![[1.0, 2.0, 4.0]];
1115 let se = array![[0.5, 0.1, 0.2]];
1116 let (c, s) = aggregate(&coef, &se);
1117 assert_eq!(c[0], 2.0);
1118 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
1120 }
1121
1122 #[test]
1123 fn holm_matches_statsmodels() {
1124 let adj = holm(array![0.01, 0.04, 0.03].view());
1126 assert_eq!(adj, array![0.03, 0.06, 0.06]);
1127 }
1128}