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 t: data.time(),
112 s: data.selection(),
113 rep: r,
114 };
115 let est = model.estimate_score(&input, folds)?;
116 let mut sol = est.score.solve()?;
117 if let Some(ci) = &cluster {
118 let theta = ci.solve_theta(&est.score, r)?;
121 let psi_r = &est.score.psi_a * theta + &est.score.psi_b;
122 let sigma2 = ci.variance(psi_r.view(), est.score.psi_a.view(), r);
123 sol.theta = theta;
124 sol.se = sigma2.sqrt();
125 sol.psi = psi_r;
126 }
127 if !(sol.theta.is_finite() && sol.se.is_finite()) {
128 return Err(DmlError::InvalidArgument(format!(
129 "non-finite estimate for treatment {j}, repetition {r}"
130 )));
131 }
132 all_coef[[j, r]] = sol.theta;
133 all_se[[j, r]] = sol.se;
134 psi_deriv[[j, r]] = sol.j;
135 psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
136 psi_a
137 .slice_mut(ndarray::s![.., j, r])
138 .assign(&est.score.psi_a);
139 psi_b
140 .slice_mut(ndarray::s![.., j, r])
141 .assign(&est.score.psi_b);
142 for target in &est.targets {
143 let Some((_, pred)) = est.predictions.iter().find(|(nm, _)| *nm == target.name)
144 else {
145 continue;
146 };
147 let slot = match losses.iter().position(|l| l.name == target.name) {
148 Some(i) => i,
149 None => {
150 losses.push(NuisanceLoss {
151 name: target.name.to_string(),
152 kind: target.kind,
153 loss: Array2::from_elem((k, n_rep), f64::NAN),
154 targets: Array3::from_elem((n, n_rep, k), f64::NAN),
155 });
156 losses.len() - 1
157 }
158 };
159 losses[slot].loss[[j, r]] = target.loss(pred);
160 losses[slot]
161 .targets
162 .slice_mut(ndarray::s![.., r, j])
163 .assign(&target.target);
164 }
165 match (&est.sensitivity, &mut sensitivity) {
166 (Some(parts), Some(store)) => store.set(j, r, &parts.elements(sol.theta)),
167 _ => sensitivity = None,
168 }
169 match (&est.cate, &mut cate) {
170 (Some(c), Some(store)) => store.set(j, r, c, sol.theta),
171 _ => cate = None,
172 }
173 for (name, pred) in est.predictions {
174 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
175 Some(i) => i,
176 None => {
177 predictions
178 .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
179 predictions.len() - 1
180 }
181 };
182 predictions[slot]
183 .1
184 .slice_mut(ndarray::s![.., r, j])
185 .assign(&pred);
186 }
187 }
188 }
189
190 let (coef, se) = aggregate(&all_coef, &all_se);
191 Ok(DmlFit {
192 model: model.name(),
193 treatment_names: data.treatment_names().to_vec(),
194 n_obs: n,
195 n_folds: splits[0].len(),
196 coef,
197 se,
198 all_coef,
199 all_se,
200 psi,
201 psi_a,
202 psi_b,
203 psi_deriv,
204 predictions,
205 sensitivity,
206 cate,
207 losses,
208 cluster,
209 })
210}
211
212#[derive(Debug, Clone)]
215pub(crate) struct CateStore {
216 pub signal: Array3<f64>,
217 pub scale: Option<Array3<f64>>,
218 pub is_effect: bool,
219}
220
221impl CateStore {
222 fn set(&mut self, j: usize, r: usize, c: &CateSignal, theta: f64) {
223 let signal = match &c.theta_factor {
224 Some(f) => &c.signal - &(theta * f),
225 None => c.signal.clone(),
226 };
227 self.signal.slice_mut(ndarray::s![.., j, r]).assign(&signal);
228 self.is_effect &= c.is_effect;
229 if let Some(scale) = &c.basis_scale {
230 let dim = self.signal.dim();
231 self.scale
232 .get_or_insert_with(|| Array3::zeros(dim))
233 .slice_mut(ndarray::s![.., j, r])
234 .assign(scale);
235 }
236 }
237}
238
239#[derive(Debug, Clone)]
241pub(crate) struct NuisanceLoss {
242 pub name: String,
243 pub kind: LossKind,
244 pub loss: Array2<f64>,
246 pub targets: Array3<f64>,
248}
249
250fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
251 if splits.is_empty() {
252 return Err(DmlError::InvalidArgument("no sample splits given".into()));
253 }
254 for (r, folds) in splits.iter().enumerate() {
255 let mut seen = vec![false; n];
256 for fold in folds {
257 for &i in &fold.test {
258 if i >= n || seen[i] {
259 return Err(DmlError::InvalidArgument(format!(
260 "repetition {r}: test sets must partition 0..{n}"
261 )));
262 }
263 seen[i] = true;
264 }
265 if fold.train.iter().any(|&i| i >= n) {
266 return Err(DmlError::InvalidArgument(format!(
267 "repetition {r}: training index out of range"
268 )));
269 }
270 }
271 if seen.iter().any(|s| !s) {
272 return Err(DmlError::InvalidArgument(format!(
273 "repetition {r}: test sets must partition 0..{n}"
274 )));
275 }
276 }
277 Ok(())
278}
279
280pub(crate) fn aggregate(
283 all_coef: &Array2<f64>,
284 all_se: &Array2<f64>,
285) -> (Array1<f64>, Array1<f64>) {
286 const CRIT: f64 = 1.96;
287 let k = all_coef.nrows();
288 let mut coef = Array1::zeros(k);
289 let mut se = Array1::zeros(k);
290 for j in 0..k {
291 let c = all_coef.row(j);
292 coef[j] = median(&c.to_vec());
293 let upper: Vec<f64> = c
294 .iter()
295 .zip(all_se.row(j))
296 .map(|(&t, &s)| t + CRIT * s)
297 .collect();
298 se[j] = (median(&upper) - coef[j]) / CRIT;
299 }
300 (coef, se)
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum PAdjust {
306 Bonferroni,
307 Holm,
309}
310
311#[derive(Debug, Clone)]
317pub struct DmlFit {
318 pub(crate) model: String,
319 pub(crate) treatment_names: Vec<String>,
320 pub(crate) n_obs: usize,
321 pub(crate) n_folds: usize,
322 pub(crate) coef: Array1<f64>,
323 pub(crate) se: Array1<f64>,
324 pub(crate) all_coef: Array2<f64>,
325 pub(crate) all_se: Array2<f64>,
326 pub(crate) psi: Array3<f64>,
328 pub(crate) psi_a: Array3<f64>,
330 pub(crate) psi_b: Array3<f64>,
331 pub(crate) psi_deriv: Array2<f64>,
333 pub(crate) predictions: Vec<(String, Array3<f64>)>,
335 pub(crate) sensitivity: Option<SensitivityStore>,
337 pub(crate) cate: Option<CateStore>,
339 pub(crate) losses: Vec<NuisanceLoss>,
341 pub(crate) cluster: Option<ClusterInfo>,
343}
344
345impl DmlFit {
346 pub fn model_name(&self) -> &str {
347 &self.model
348 }
349
350 pub fn treatment_names(&self) -> &[String] {
351 &self.treatment_names
352 }
353
354 pub fn n_obs(&self) -> usize {
355 self.n_obs
356 }
357
358 pub fn n_treat(&self) -> usize {
359 self.coef.len()
360 }
361
362 pub fn n_rep(&self) -> usize {
363 self.all_coef.ncols()
364 }
365
366 pub fn n_folds(&self) -> usize {
367 self.n_folds
368 }
369
370 pub fn n_clusters(&self) -> Option<usize> {
373 self.cluster.as_ref().map(ClusterInfo::n_clusters)
374 }
375
376 pub fn coef(&self) -> ArrayView1<'_, f64> {
378 self.coef.view()
379 }
380
381 pub fn se(&self) -> ArrayView1<'_, f64> {
383 self.se.view()
384 }
385
386 pub fn t_stat(&self) -> Array1<f64> {
387 &self.coef / &self.se
388 }
389
390 pub fn p_value(&self) -> Array1<f64> {
394 self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
395 }
396
397 fn per_rep_pvals(&self) -> Array2<f64> {
398 let mut p = &self.all_coef / &self.all_se;
399 p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
400 p
401 }
402
403 pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
406 check_level(level)?;
407 let z = norm_ppf(0.5 + level / 2.0);
408 Ok(confint_from(
409 &self.all_coef,
410 &self.all_se,
411 &Array1::from_elem(self.n_rep(), z),
412 ))
413 }
414
415 pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
419 let raw = self.per_rep_pvals();
420 let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
421 for (r, col) in raw.columns().into_iter().enumerate() {
422 let adj = match method {
423 PAdjust::Bonferroni => {
424 let m = col.len() as f64;
425 col.mapv(|p| (m * p).min(1.0))
426 }
427 PAdjust::Holm => holm(col),
428 };
429 adjusted.column_mut(r).assign(&adj);
430 }
431 adjusted
432 .rows()
433 .into_iter()
434 .map(|row| median(&row.to_vec()))
435 .collect()
436 }
437
438 pub fn all_coef(&self) -> ArrayView2<'_, f64> {
440 self.all_coef.view()
441 }
442
443 pub fn all_se(&self) -> ArrayView2<'_, f64> {
444 self.all_se.view()
445 }
446
447 pub fn psi(&self) -> ArrayView3<'_, f64> {
449 self.psi.view()
450 }
451
452 pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
454 (self.psi_a.view(), self.psi_b.view())
455 }
456
457 pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
464 let (signal, bases) = self.cate_inputs(basis)?;
465 Blp::fit_bases(signal.view(), &bases)
466 }
467
468 pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
471 let (signal, bases) = self.cate_inputs(groups)?;
472 Blp::fit_gate_bases(signal.view(), groups, &bases)
473 }
474
475 pub fn policy_tree(
480 &self,
481 features: ArrayView2<'_, f64>,
482 depth: usize,
483 min_samples_leaf: usize,
484 ccp_alpha: f64,
485 ) -> Result<PolicyTree> {
486 let store = self.cate.as_ref().filter(|c| c.is_effect).ok_or_else(|| {
487 DmlError::InvalidArgument(format!(
488 "policy trees need a treatment-effect signal (IRM with the ATE score), not {}",
489 self.model
490 ))
491 })?;
492 if self.n_treat() != 1 || self.n_rep() != 1 {
493 return Err(DmlError::InvalidArgument(
494 "policy trees need a single treatment and one repetition (as DoubleML)".into(),
495 ));
496 }
497 if features.nrows() != self.n_obs {
498 return Err(DmlError::DimensionMismatch(format!(
499 "features have {} rows, the data {}",
500 features.nrows(),
501 self.n_obs
502 )));
503 }
504 PolicyTree::fit(
505 store.signal.slice(ndarray::s![.., 0, 0]),
506 features,
507 depth,
508 min_samples_leaf,
509 ccp_alpha,
510 )
511 }
512
513 fn cate_inputs(&self, basis: ArrayView2<'_, f64>) -> Result<(Array2<f64>, Vec<Array2<f64>>)> {
515 let store = self.cate.as_ref().ok_or_else(|| {
516 DmlError::InvalidArgument(format!(
517 "CATE / GATE are not available for {} (PLR, IRM with the ATE score and APO support them)",
518 self.model
519 ))
520 })?;
521 if self.n_treat() != 1 {
522 return Err(DmlError::InvalidArgument(
523 "CATE / GATE need a single treatment".into(),
524 ));
525 }
526 if basis.nrows() != self.n_obs {
527 return Err(DmlError::DimensionMismatch(format!(
528 "basis has {} rows, the data {}",
529 basis.nrows(),
530 self.n_obs
531 )));
532 }
533 let signal = store.signal.index_axis(Axis(1), 0).to_owned();
534 let bases = (0..self.n_rep())
535 .map(|r| match &store.scale {
536 Some(scale) => {
537 let s = scale.slice(ndarray::s![.., 0, r]);
538 &basis * &s.insert_axis(Axis(1))
539 }
540 None => basis.to_owned(),
541 })
542 .collect();
543 Ok((signal, bases))
544 }
545
546 pub fn nuisance_loss(&self, name: &str) -> Option<ArrayView2<'_, f64>> {
550 self.losses
551 .iter()
552 .find(|l| l.name == name)
553 .map(|l| l.loss.view())
554 }
555
556 pub fn nuisance_losses(&self) -> impl Iterator<Item = (&str, LossKind, ArrayView2<'_, f64>)> {
558 self.losses
559 .iter()
560 .map(|l| (l.name.as_str(), l.kind, l.loss.view()))
561 }
562
563 pub fn nuisance_targets(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
566 self.losses
567 .iter()
568 .find(|l| l.name == name)
569 .map(|l| l.targets.view())
570 }
571
572 pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
574 self.predictions.iter().map(|(n, _)| n.as_str())
575 }
576
577 pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
579 self.predictions
580 .iter()
581 .find(|(n, _)| n == name)
582 .map(|(_, p)| p.view())
583 }
584
585 pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
587 let p = self.predictions(name)?;
588 (rep < self.n_rep() && treatment < self.n_treat())
589 .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
590 }
591}
592
593pub(crate) fn confint_from(
596 all_coef: &Array2<f64>,
597 all_se: &Array2<f64>,
598 crit: &Array1<f64>,
599) -> Array2<f64> {
600 let (k, n_rep) = all_coef.dim();
601 let mut ci = Array2::zeros((k, 2));
602 for j in 0..k {
603 let lo: Vec<f64> = (0..n_rep)
604 .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
605 .collect();
606 let hi: Vec<f64> = (0..n_rep)
607 .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
608 .collect();
609 ci[[j, 0]] = median(&lo);
610 ci[[j, 1]] = median(&hi);
611 }
612 ci
613}
614
615impl DmlFit {
616 #[allow(clippy::too_many_arguments)]
621 pub(crate) fn from_scores(
622 model: String,
623 treatment_names: Vec<String>,
624 n_folds: usize,
625 all_coef: Array2<f64>,
626 psi: Array3<f64>,
627 psi_a: Array3<f64>,
628 psi_b: Array3<f64>,
629 predictions: Vec<(String, Array3<f64>)>,
630 ) -> Self {
631 let (n, k, n_rep) = psi.dim();
632 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
633 let mut all_se = Array2::<f64>::zeros((k, n_rep));
634 for j in 0..k {
635 for r in 0..n_rep {
636 let jac = psi_a.slice(ndarray::s![.., j, r]).sum() / n as f64;
637 let col = psi.slice(ndarray::s![.., j, r]);
638 psi_deriv[[j, r]] = jac;
639 all_se[[j, r]] = (col.dot(&col) / n as f64 / (jac * jac) / n as f64).sqrt();
640 }
641 }
642 let (coef, se) = aggregate(&all_coef, &all_se);
643 Self {
644 model,
645 treatment_names,
646 n_obs: n,
647 n_folds,
648 coef,
649 se,
650 all_coef,
651 all_se,
652 psi,
653 psi_a,
654 psi_b,
655 psi_deriv,
656 predictions,
657 sensitivity: None,
658 cate: None,
659 losses: Vec::new(),
660 cluster: None,
661 }
662 }
663
664 pub(crate) fn linear_combination(
668 &self,
669 model: String,
670 names: Vec<String>,
671 weights: &Array2<f64>,
672 ) -> Self {
673 let (n, k, n_rep) = self.psi.dim();
674 assert_eq!(weights.ncols(), k, "one weight per estimate");
675 let n_comb = weights.nrows();
676 let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
677 let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
678 let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
679 for r in 0..n_rep {
680 for c in 0..n_comb {
681 let mut col = Array1::<f64>::zeros(n);
682 let mut theta = 0.0;
683 for j in 0..k {
684 let w = weights[[c, j]];
685 if w != 0.0 {
686 col.scaled_add(
687 w / self.psi_deriv[[j, r]],
688 &self.psi.slice(ndarray::s![.., j, r]),
689 );
690 theta += w * self.all_coef[[j, r]];
691 }
692 }
693 all_coef[[c, r]] = theta;
694 all_se[[c, r]] = match &self.cluster {
695 Some(ci) => ci.variance(col.view(), Array1::ones(n).view(), r).sqrt(),
697 None => (col.dot(&col) / n as f64 / n as f64).sqrt(),
698 };
699 scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
700 }
701 }
702 let (coef, se) = aggregate(&all_coef, &all_se);
703 Self {
704 model,
705 treatment_names: names,
706 n_obs: n,
707 n_folds: self.n_folds,
708 coef,
709 se,
710 all_coef,
711 all_se,
712 psi: scaled,
715 psi_a: Array3::zeros((n, n_comb, n_rep)),
716 psi_b: Array3::zeros((n, n_comb, n_rep)),
717 psi_deriv: Array2::ones((n_comb, n_rep)),
718 predictions: Vec::new(),
719 sensitivity: None,
720 cate: None,
721 losses: Vec::new(),
722 cluster: self.cluster.clone(),
723 }
724 }
725
726 pub fn concat(fits: &[DmlFit]) -> Result<DmlFit> {
735 let first = fits
736 .first()
737 .ok_or_else(|| DmlError::InvalidArgument("concat needs at least one fit".into()))?;
738 let (n, n_rep) = (first.n_obs, first.n_rep());
739 for f in fits {
740 if f.n_obs != n || f.n_rep() != n_rep || f.n_folds != first.n_folds {
741 return Err(DmlError::DimensionMismatch(
742 "concat needs fits with the same observations, folds and repetitions".into(),
743 ));
744 }
745 let same_clusters = match (&f.cluster, &first.cluster) {
746 (None, None) => true,
747 (Some(a), Some(b)) => a.clusters == b.clusters && a.folds == b.folds,
748 _ => false,
749 };
750 if !same_clusters {
751 return Err(DmlError::InvalidArgument(
752 "concat needs the same clusters and cluster splits in every fit".into(),
753 ));
754 }
755 }
756 let cat1 = |f: fn(&DmlFit) -> &Array1<f64>| {
757 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
758 ndarray::concatenate(Axis(0), &v).expect("1-D")
759 };
760 let cat2 = |f: fn(&DmlFit) -> &Array2<f64>| {
761 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
762 ndarray::concatenate(Axis(0), &v).expect("same n_rep")
763 };
764 let cat3 = |f: fn(&DmlFit) -> &Array3<f64>| {
765 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
766 ndarray::concatenate(Axis(1), &v).expect("same n and n_rep")
767 };
768 let ks: Vec<usize> = fits.iter().map(DmlFit::n_treat).collect();
769 let k_total: usize = ks.iter().sum();
770
771 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
773 let mut losses: Vec<NuisanceLoss> = Vec::new();
774 let mut offset = 0;
775 for (f, &k) in fits.iter().zip(&ks) {
776 for (name, pred) in &f.predictions {
777 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
778 Some(i) => i,
779 None => {
780 predictions.push((
781 name.clone(),
782 Array3::from_elem((n, n_rep, k_total), f64::NAN),
783 ));
784 predictions.len() - 1
785 }
786 };
787 predictions[slot]
788 .1
789 .slice_mut(ndarray::s![.., .., offset..offset + k])
790 .assign(pred);
791 }
792 for l in &f.losses {
793 let slot = match losses.iter().position(|x| x.name == l.name) {
794 Some(i) => i,
795 None => {
796 losses.push(NuisanceLoss {
797 name: l.name.clone(),
798 kind: l.kind,
799 loss: Array2::from_elem((k_total, n_rep), f64::NAN),
800 targets: Array3::from_elem((n, n_rep, k_total), f64::NAN),
801 });
802 losses.len() - 1
803 }
804 };
805 losses[slot]
806 .loss
807 .slice_mut(ndarray::s![offset..offset + k, ..])
808 .assign(&l.loss);
809 losses[slot]
810 .targets
811 .slice_mut(ndarray::s![.., .., offset..offset + k])
812 .assign(&l.targets);
813 }
814 offset += k;
815 }
816 let stores: Option<Vec<&SensitivityStore>> =
817 fits.iter().map(|f| f.sensitivity.as_ref()).collect();
818 let cates: Option<Vec<&CateStore>> = fits.iter().map(|f| f.cate.as_ref()).collect();
820 let cate_store = cates.and_then(|c| {
821 let views: Vec<_> = c.iter().map(|s| s.signal.view()).collect();
822 let signal = ndarray::concatenate(Axis(1), &views).ok()?;
823 let scales: Option<Vec<_>> = c
824 .iter()
825 .map(|s| s.scale.as_ref().map(|a| a.view()))
826 .collect();
827 let is_effect = c.iter().all(|s| s.is_effect);
828 match scales {
829 Some(v) => Some(CateStore {
830 signal,
831 scale: Some(ndarray::concatenate(Axis(1), &v).ok()?),
832 is_effect,
833 }),
834 None if c.iter().all(|s| s.scale.is_none()) => Some(CateStore {
835 signal,
836 scale: None,
837 is_effect,
838 }),
839 None => None,
840 }
841 });
842 let names: Vec<&str> = fits.iter().map(|f| f.model.as_str()).collect();
843 Ok(DmlFit {
844 model: if names.iter().all(|m| *m == names[0]) {
845 names[0].to_string()
846 } else {
847 names.join(" + ")
848 },
849 treatment_names: fits
850 .iter()
851 .flat_map(|f| f.treatment_names.iter().cloned())
852 .collect(),
853 n_obs: n,
854 n_folds: first.n_folds,
855 coef: cat1(|f| &f.coef),
856 se: cat1(|f| &f.se),
857 all_coef: cat2(|f| &f.all_coef),
858 all_se: cat2(|f| &f.all_se),
859 psi: cat3(|f| &f.psi),
860 psi_a: cat3(|f| &f.psi_a),
861 psi_b: cat3(|f| &f.psi_b),
862 psi_deriv: cat2(|f| &f.psi_deriv),
863 predictions,
864 sensitivity: stores.map(|s| SensitivityStore::concat(&s)),
865 cate: cate_store,
866 losses,
867 cluster: first.cluster.clone(),
868 })
869 }
870}
871
872pub(crate) fn check_level(level: f64) -> Result<()> {
873 if level > 0.0 && level < 1.0 {
874 Ok(())
875 } else {
876 Err(DmlError::InvalidArgument(
877 "confidence level must be in (0, 1)".into(),
878 ))
879 }
880}
881
882fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
884 let m = p.len();
885 let mut order: Vec<usize> = (0..m).collect();
886 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
887 let mut out = Array1::zeros(m);
888 let mut running = 0.0_f64;
889 for (rank, &i) in order.iter().enumerate() {
890 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
891 out[i] = running;
892 }
893 out
894}
895
896impl fmt::Display for DmlFit {
897 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
898 let clustering = match &self.cluster {
899 Some(ci) if ci.n_ways() == 2 => {
900 format!(", two-way clustered ({} clusters)", ci.n_clusters())
901 }
902 Some(ci) => format!(", clustered ({} clusters)", ci.n_clusters()),
903 None => String::new(),
904 };
905 writeln!(
906 f,
907 "{} | n_obs = {}, n_folds = {}, n_rep = {}{clustering}",
908 self.model,
909 self.n_obs,
910 self.n_folds,
911 self.n_rep()
912 )?;
913 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
914 let (t, p) = (self.t_stat(), self.p_value());
915 let width = self
916 .treatment_names
917 .iter()
918 .map(String::len)
919 .max()
920 .unwrap_or(1)
921 .max(4);
922 write!(
923 f,
924 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
925 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
926 )?;
927 for j in 0..self.n_treat() {
928 write!(
929 f,
930 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
931 self.treatment_names[j],
932 self.coef[j],
933 self.se[j],
934 t[j],
935 p[j],
936 ci[[j, 0]],
937 ci[[j, 1]]
938 )?;
939 }
940 if !self.losses.is_empty() {
941 write!(
942 f,
943 "\nNuisance learners (out-of-sample, mean over repetitions):"
944 )?;
945 for l in &self.losses {
946 let metric = match l.kind {
947 LossKind::Rmse => "RMSE",
948 LossKind::LogLoss => "log loss",
949 };
950 for j in 0..self.n_treat() {
951 let row: Vec<f64> = l
952 .loss
953 .row(j)
954 .iter()
955 .copied()
956 .filter(|v| v.is_finite())
957 .collect();
958 if row.is_empty() {
959 continue;
960 }
961 let mean = row.iter().sum::<f64>() / row.len() as f64;
962 let who = if self.n_treat() > 1 {
963 format!(" ({})", self.treatment_names[j])
964 } else {
965 String::new()
966 };
967 write!(f, "\n {:<12} {metric:<8} {mean:.4}{who}", l.name)?;
968 }
969 }
970 }
971 Ok(())
972 }
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use ndarray::array;
979
980 #[test]
981 fn aggregation_matches_doubleml_definition() {
982 let coef = array![[1.0, 2.0, 4.0]];
983 let se = array![[0.5, 0.1, 0.2]];
984 let (c, s) = aggregate(&coef, &se);
985 assert_eq!(c[0], 2.0);
986 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
988 }
989
990 #[test]
991 fn holm_matches_statsmodels() {
992 let adj = holm(array![0.01, 0.04, 0.03].view());
994 assert_eq!(adj, array![0.03, 0.06, 0.06]);
995 }
996}