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(crate) fn linear_combination(
681 &self,
682 model: String,
683 names: Vec<String>,
684 weights: &Array2<f64>,
685 ) -> Self {
686 let (n, k, n_rep) = self.psi.dim();
687 assert_eq!(weights.ncols(), k, "one weight per estimate");
688 let n_comb = weights.nrows();
689 let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
690 let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
691 let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
692 for r in 0..n_rep {
693 for c in 0..n_comb {
694 let mut col = Array1::<f64>::zeros(n);
695 let mut theta = 0.0;
696 for j in 0..k {
697 let w = weights[[c, j]];
698 if w != 0.0 {
699 col.scaled_add(
700 w / self.psi_deriv[[j, r]],
701 &self.psi.slice(ndarray::s![.., j, r]),
702 );
703 theta += w * self.all_coef[[j, r]];
704 }
705 }
706 all_coef[[c, r]] = theta;
707 all_se[[c, r]] = match &self.cluster {
708 Some(ci) => ci.variance(col.view(), Array1::ones(n).view(), r).sqrt(),
710 None => (col.dot(&col) / n as f64 / n as f64).sqrt(),
711 };
712 scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
713 }
714 }
715 let (coef, se) = aggregate(&all_coef, &all_se);
716 Self {
717 model,
718 treatment_names: names,
719 n_obs: n,
720 n_folds: self.n_folds,
721 coef,
722 se,
723 all_coef,
724 all_se,
725 psi: scaled,
728 psi_a: Array3::zeros((n, n_comb, n_rep)),
729 psi_b: Array3::zeros((n, n_comb, n_rep)),
730 psi_deriv: Array2::ones((n_comb, n_rep)),
731 predictions: Vec::new(),
732 sensitivity: None,
733 cate: None,
734 losses: Vec::new(),
735 cluster: self.cluster.clone(),
736 }
737 }
738
739 pub fn concat(fits: &[DmlFit]) -> Result<DmlFit> {
748 let first = fits
749 .first()
750 .ok_or_else(|| DmlError::InvalidArgument("concat needs at least one fit".into()))?;
751 let (n, n_rep) = (first.n_obs, first.n_rep());
752 for f in fits {
753 if f.n_obs != n || f.n_rep() != n_rep || f.n_folds != first.n_folds {
754 return Err(DmlError::DimensionMismatch(
755 "concat needs fits with the same observations, folds and repetitions".into(),
756 ));
757 }
758 let same_clusters = match (&f.cluster, &first.cluster) {
759 (None, None) => true,
760 (Some(a), Some(b)) => a.clusters == b.clusters && a.folds == b.folds,
761 _ => false,
762 };
763 if !same_clusters {
764 return Err(DmlError::InvalidArgument(
765 "concat needs the same clusters and cluster splits in every fit".into(),
766 ));
767 }
768 }
769 let cat1 = |f: fn(&DmlFit) -> &Array1<f64>| {
770 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
771 ndarray::concatenate(Axis(0), &v).expect("1-D")
772 };
773 let cat2 = |f: fn(&DmlFit) -> &Array2<f64>| {
774 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
775 ndarray::concatenate(Axis(0), &v).expect("same n_rep")
776 };
777 let cat3 = |f: fn(&DmlFit) -> &Array3<f64>| {
778 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
779 ndarray::concatenate(Axis(1), &v).expect("same n and n_rep")
780 };
781 let ks: Vec<usize> = fits.iter().map(DmlFit::n_treat).collect();
782 let k_total: usize = ks.iter().sum();
783
784 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
786 let mut losses: Vec<NuisanceLoss> = Vec::new();
787 let mut offset = 0;
788 for (f, &k) in fits.iter().zip(&ks) {
789 for (name, pred) in &f.predictions {
790 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
791 Some(i) => i,
792 None => {
793 predictions.push((
794 name.clone(),
795 Array3::from_elem((n, n_rep, k_total), f64::NAN),
796 ));
797 predictions.len() - 1
798 }
799 };
800 predictions[slot]
801 .1
802 .slice_mut(ndarray::s![.., .., offset..offset + k])
803 .assign(pred);
804 }
805 for l in &f.losses {
806 let slot = match losses.iter().position(|x| x.name == l.name) {
807 Some(i) => i,
808 None => {
809 losses.push(NuisanceLoss {
810 name: l.name.clone(),
811 kind: l.kind,
812 loss: Array2::from_elem((k_total, n_rep), f64::NAN),
813 targets: Array3::from_elem((n, n_rep, k_total), f64::NAN),
814 });
815 losses.len() - 1
816 }
817 };
818 losses[slot]
819 .loss
820 .slice_mut(ndarray::s![offset..offset + k, ..])
821 .assign(&l.loss);
822 losses[slot]
823 .targets
824 .slice_mut(ndarray::s![.., .., offset..offset + k])
825 .assign(&l.targets);
826 }
827 offset += k;
828 }
829 let stores: Option<Vec<&SensitivityStore>> =
830 fits.iter().map(|f| f.sensitivity.as_ref()).collect();
831 let cates: Option<Vec<&CateStore>> = fits.iter().map(|f| f.cate.as_ref()).collect();
833 let cate_store = cates.and_then(|c| {
834 let views: Vec<_> = c.iter().map(|s| s.signal.view()).collect();
835 let signal = ndarray::concatenate(Axis(1), &views).ok()?;
836 let scales: Option<Vec<_>> = c
837 .iter()
838 .map(|s| s.scale.as_ref().map(|a| a.view()))
839 .collect();
840 let is_effect = c.iter().all(|s| s.is_effect);
841 match scales {
842 Some(v) => Some(CateStore {
843 signal,
844 scale: Some(ndarray::concatenate(Axis(1), &v).ok()?),
845 is_effect,
846 }),
847 None if c.iter().all(|s| s.scale.is_none()) => Some(CateStore {
848 signal,
849 scale: None,
850 is_effect,
851 }),
852 None => None,
853 }
854 });
855 let names: Vec<&str> = fits.iter().map(|f| f.model.as_str()).collect();
856 Ok(DmlFit {
857 model: if names.iter().all(|m| *m == names[0]) {
858 names[0].to_string()
859 } else {
860 names.join(" + ")
861 },
862 treatment_names: fits
863 .iter()
864 .flat_map(|f| f.treatment_names.iter().cloned())
865 .collect(),
866 n_obs: n,
867 n_folds: first.n_folds,
868 coef: cat1(|f| &f.coef),
869 se: cat1(|f| &f.se),
870 all_coef: cat2(|f| &f.all_coef),
871 all_se: cat2(|f| &f.all_se),
872 psi: cat3(|f| &f.psi),
873 psi_a: cat3(|f| &f.psi_a),
874 psi_b: cat3(|f| &f.psi_b),
875 psi_deriv: cat2(|f| &f.psi_deriv),
876 predictions,
877 sensitivity: stores.map(|s| SensitivityStore::concat(&s)),
878 cate: cate_store,
879 losses,
880 cluster: first.cluster.clone(),
881 })
882 }
883}
884
885pub(crate) fn check_level(level: f64) -> Result<()> {
886 if level > 0.0 && level < 1.0 {
887 Ok(())
888 } else {
889 Err(DmlError::InvalidArgument(
890 "confidence level must be in (0, 1)".into(),
891 ))
892 }
893}
894
895fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
897 let m = p.len();
898 let mut order: Vec<usize> = (0..m).collect();
899 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
900 let mut out = Array1::zeros(m);
901 let mut running = 0.0_f64;
902 for (rank, &i) in order.iter().enumerate() {
903 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
904 out[i] = running;
905 }
906 out
907}
908
909impl fmt::Display for DmlFit {
910 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
911 let clustering = match &self.cluster {
912 Some(ci) if ci.n_ways() == 2 => {
913 format!(", two-way clustered ({} clusters)", ci.n_clusters())
914 }
915 Some(ci) => format!(", clustered ({} clusters)", ci.n_clusters()),
916 None => String::new(),
917 };
918 writeln!(
919 f,
920 "{} | n_obs = {}, n_folds = {}, n_rep = {}{clustering}",
921 self.model,
922 self.n_obs,
923 self.n_folds,
924 self.n_rep()
925 )?;
926 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
927 let (t, p) = (self.t_stat(), self.p_value());
928 let width = self
929 .treatment_names
930 .iter()
931 .map(String::len)
932 .max()
933 .unwrap_or(1)
934 .max(4);
935 write!(
936 f,
937 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
938 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
939 )?;
940 for j in 0..self.n_treat() {
941 write!(
942 f,
943 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
944 self.treatment_names[j],
945 self.coef[j],
946 self.se[j],
947 t[j],
948 p[j],
949 ci[[j, 0]],
950 ci[[j, 1]]
951 )?;
952 }
953 if !self.losses.is_empty() {
954 write!(
955 f,
956 "\nNuisance learners (out-of-sample, mean over repetitions):"
957 )?;
958 for l in &self.losses {
959 let metric = match l.kind {
960 LossKind::Rmse => "RMSE",
961 LossKind::LogLoss => "log loss",
962 };
963 for j in 0..self.n_treat() {
964 let row: Vec<f64> = l
965 .loss
966 .row(j)
967 .iter()
968 .copied()
969 .filter(|v| v.is_finite())
970 .collect();
971 if row.is_empty() {
972 continue;
973 }
974 let mean = row.iter().sum::<f64>() / row.len() as f64;
975 let who = if self.n_treat() > 1 {
976 format!(" ({})", self.treatment_names[j])
977 } else {
978 String::new()
979 };
980 write!(f, "\n {:<12} {metric:<8} {mean:.4}{who}", l.name)?;
981 }
982 }
983 }
984 Ok(())
985 }
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991 use ndarray::array;
992
993 #[test]
994 fn aggregation_matches_doubleml_definition() {
995 let coef = array![[1.0, 2.0, 4.0]];
996 let se = array![[0.5, 0.1, 0.2]];
997 let (c, s) = aggregate(&coef, &se);
998 assert_eq!(c[0], 2.0);
999 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
1001 }
1002
1003 #[test]
1004 fn holm_matches_statsmodels() {
1005 let adj = holm(array![0.01, 0.04, 0.03].view());
1007 assert_eq!(adj, array![0.03, 0.06, 0.06]);
1008 }
1009}