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 from_linear_scores(
692 model: impl Into<String>,
693 treatment_names: Vec<String>,
694 n_folds: usize,
695 psi_a: Array3<f64>,
696 psi_b: Array3<f64>,
697 ) -> Result<DmlFit> {
698 let (n, k, n_rep) = psi_a.dim();
699 if psi_b.dim() != psi_a.dim() || n == 0 || k == 0 || n_rep == 0 {
700 return Err(DmlError::DimensionMismatch(format!(
701 "psi_a {:?} and psi_b {:?} need the same non-empty shape (n, n_treat, n_rep)",
702 psi_a.dim(),
703 psi_b.dim()
704 )));
705 }
706 if treatment_names.len() != k {
707 return Err(DmlError::DimensionMismatch(format!(
708 "{} names for {k} estimates",
709 treatment_names.len()
710 )));
711 }
712 if !psi_a.iter().chain(psi_b.iter()).all(|v| v.is_finite()) {
713 return Err(DmlError::InvalidArgument(
714 "score elements must be finite".into(),
715 ));
716 }
717 let mut all_coef = Array2::<f64>::zeros((k, n_rep));
718 let mut psi = Array3::<f64>::zeros((n, k, n_rep));
719 for j in 0..k {
720 for r in 0..n_rep {
721 let a = psi_a.slice(ndarray::s![.., j, r]);
722 let b = psi_b.slice(ndarray::s![.., j, r]);
723 let mean_a = a.mean().expect("n > 0");
724 if mean_a == 0.0 {
725 return Err(DmlError::InvalidArgument(
726 "mean(psi_a) is zero: the score does not identify theta".into(),
727 ));
728 }
729 let theta = -b.mean().expect("n > 0") / mean_a;
730 all_coef[[j, r]] = theta;
731 psi.slice_mut(ndarray::s![.., j, r])
732 .assign(&(&a * theta + b));
733 }
734 }
735 Ok(Self::from_scores(
736 model.into(),
737 treatment_names,
738 n_folds,
739 all_coef,
740 psi,
741 psi_a,
742 psi_b,
743 Vec::new(),
744 ))
745 }
746
747 pub fn linear_combination(
768 &self,
769 weights: ArrayView2<'_, f64>,
770 names: Vec<String>,
771 ) -> Result<DmlFit> {
772 if weights.ncols() != self.n_treat() || weights.nrows() == 0 {
773 return Err(DmlError::DimensionMismatch(format!(
774 "weights need {} columns (one per estimate) and at least one row, got {:?}",
775 self.n_treat(),
776 weights.dim()
777 )));
778 }
779 if names.len() != weights.nrows() {
780 return Err(DmlError::DimensionMismatch(format!(
781 "{} names for {} combinations",
782 names.len(),
783 weights.nrows()
784 )));
785 }
786 if !weights.iter().all(|w| w.is_finite()) {
787 return Err(DmlError::InvalidArgument("weights must be finite".into()));
788 }
789 Ok(self.combine(self.model.clone(), names, &weights.to_owned()))
790 }
791
792 pub(crate) fn combine(&self, model: String, names: Vec<String>, weights: &Array2<f64>) -> Self {
794 let (n, k, n_rep) = self.psi.dim();
795 assert_eq!(weights.ncols(), k, "one weight per estimate");
796 let n_comb = weights.nrows();
797 let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
798 let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
799 let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
800 for r in 0..n_rep {
801 for c in 0..n_comb {
802 let mut col = Array1::<f64>::zeros(n);
803 let mut theta = 0.0;
804 for j in 0..k {
805 let w = weights[[c, j]];
806 if w != 0.0 {
807 col.scaled_add(
808 w / self.psi_deriv[[j, r]],
809 &self.psi.slice(ndarray::s![.., j, r]),
810 );
811 theta += w * self.all_coef[[j, r]];
812 }
813 }
814 all_coef[[c, r]] = theta;
815 all_se[[c, r]] = match &self.cluster {
816 Some(ci) => ci.variance(col.view(), Array1::ones(n).view(), r).sqrt(),
818 None => (col.dot(&col) / n as f64 / n as f64).sqrt(),
819 };
820 scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
821 }
822 }
823 let (coef, se) = aggregate(&all_coef, &all_se);
824 Self {
825 model,
826 treatment_names: names,
827 n_obs: n,
828 n_folds: self.n_folds,
829 coef,
830 se,
831 all_coef,
832 all_se,
833 psi: scaled,
836 psi_a: Array3::zeros((n, n_comb, n_rep)),
837 psi_b: Array3::zeros((n, n_comb, n_rep)),
838 psi_deriv: Array2::ones((n_comb, n_rep)),
839 predictions: Vec::new(),
840 sensitivity: self.sensitivity.as_ref().map(|s| s.combine(weights)),
841 cate: None,
842 losses: Vec::new(),
843 cluster: self.cluster.clone(),
844 }
845 }
846
847 pub fn concat(fits: &[DmlFit]) -> Result<DmlFit> {
856 let first = fits
857 .first()
858 .ok_or_else(|| DmlError::InvalidArgument("concat needs at least one fit".into()))?;
859 let (n, n_rep) = (first.n_obs, first.n_rep());
860 for f in fits {
861 if f.n_obs != n || f.n_rep() != n_rep {
862 return Err(DmlError::DimensionMismatch(
863 "concat needs fits with the same observations and repetitions".into(),
864 ));
865 }
866 let same_clusters = match (&f.cluster, &first.cluster) {
867 (None, None) => true,
868 (Some(a), Some(b)) => a.clusters == b.clusters && a.folds == b.folds,
869 _ => false,
870 };
871 if !same_clusters {
872 return Err(DmlError::InvalidArgument(
873 "concat needs the same clusters and cluster splits in every fit".into(),
874 ));
875 }
876 }
877 let cat1 = |f: fn(&DmlFit) -> &Array1<f64>| {
878 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
879 ndarray::concatenate(Axis(0), &v).expect("1-D")
880 };
881 let cat2 = |f: fn(&DmlFit) -> &Array2<f64>| {
882 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
883 ndarray::concatenate(Axis(0), &v).expect("same n_rep")
884 };
885 let cat3 = |f: fn(&DmlFit) -> &Array3<f64>| {
886 let v: Vec<_> = fits.iter().map(|x| f(x).view()).collect();
887 ndarray::concatenate(Axis(1), &v).expect("same n and n_rep")
888 };
889 let ks: Vec<usize> = fits.iter().map(DmlFit::n_treat).collect();
890 let k_total: usize = ks.iter().sum();
891
892 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
894 let mut losses: Vec<NuisanceLoss> = Vec::new();
895 let mut offset = 0;
896 for (f, &k) in fits.iter().zip(&ks) {
897 for (name, pred) in &f.predictions {
898 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
899 Some(i) => i,
900 None => {
901 predictions.push((
902 name.clone(),
903 Array3::from_elem((n, n_rep, k_total), f64::NAN),
904 ));
905 predictions.len() - 1
906 }
907 };
908 predictions[slot]
909 .1
910 .slice_mut(ndarray::s![.., .., offset..offset + k])
911 .assign(pred);
912 }
913 for l in &f.losses {
914 let slot = match losses.iter().position(|x| x.name == l.name) {
915 Some(i) => i,
916 None => {
917 losses.push(NuisanceLoss {
918 name: l.name.clone(),
919 kind: l.kind,
920 loss: Array2::from_elem((k_total, n_rep), f64::NAN),
921 targets: Array3::from_elem((n, n_rep, k_total), f64::NAN),
922 });
923 losses.len() - 1
924 }
925 };
926 losses[slot]
927 .loss
928 .slice_mut(ndarray::s![offset..offset + k, ..])
929 .assign(&l.loss);
930 losses[slot]
931 .targets
932 .slice_mut(ndarray::s![.., .., offset..offset + k])
933 .assign(&l.targets);
934 }
935 offset += k;
936 }
937 let stores: Option<Vec<&SensitivityStore>> =
938 fits.iter().map(|f| f.sensitivity.as_ref()).collect();
939 let cates: Option<Vec<&CateStore>> = fits.iter().map(|f| f.cate.as_ref()).collect();
941 let cate_store = cates.and_then(|c| {
942 let views: Vec<_> = c.iter().map(|s| s.signal.view()).collect();
943 let signal = ndarray::concatenate(Axis(1), &views).ok()?;
944 let scales: Option<Vec<_>> = c
945 .iter()
946 .map(|s| s.scale.as_ref().map(|a| a.view()))
947 .collect();
948 let is_effect = c.iter().all(|s| s.is_effect);
949 match scales {
950 Some(v) => Some(CateStore {
951 signal,
952 scale: Some(ndarray::concatenate(Axis(1), &v).ok()?),
953 is_effect,
954 }),
955 None if c.iter().all(|s| s.scale.is_none()) => Some(CateStore {
956 signal,
957 scale: None,
958 is_effect,
959 }),
960 None => None,
961 }
962 });
963 let names: Vec<&str> = fits.iter().map(|f| f.model.as_str()).collect();
964 Ok(DmlFit {
965 model: if names.iter().all(|m| *m == names[0]) {
966 names[0].to_string()
967 } else {
968 names.join(" + ")
969 },
970 treatment_names: fits
971 .iter()
972 .flat_map(|f| f.treatment_names.iter().cloned())
973 .collect(),
974 n_obs: n,
975 n_folds: first.n_folds,
976 coef: cat1(|f| &f.coef),
977 se: cat1(|f| &f.se),
978 all_coef: cat2(|f| &f.all_coef),
979 all_se: cat2(|f| &f.all_se),
980 psi: cat3(|f| &f.psi),
981 psi_a: cat3(|f| &f.psi_a),
982 psi_b: cat3(|f| &f.psi_b),
983 psi_deriv: cat2(|f| &f.psi_deriv),
984 predictions,
985 sensitivity: stores.map(|s| SensitivityStore::concat(&s)),
986 cate: cate_store,
987 losses,
988 cluster: first.cluster.clone(),
989 })
990 }
991}
992
993fn elementwise(a: &DmlFit, b: &DmlFit, sign: f64) -> Result<DmlFit> {
996 let k = a.n_treat();
997 if b.n_treat() != k {
998 return Err(DmlError::DimensionMismatch(format!(
999 "cannot combine fits with {k} and {} estimates",
1000 b.n_treat()
1001 )));
1002 }
1003 let both = DmlFit::concat(&[a.clone(), b.clone()])?;
1004 let mut weights = Array2::<f64>::zeros((k, 2 * k));
1005 for j in 0..k {
1006 weights[[j, j]] = 1.0;
1007 weights[[j, k + j]] = sign;
1008 }
1009 let op = if sign > 0.0 { "+" } else { "-" };
1010 let names = a
1011 .treatment_names
1012 .iter()
1013 .zip(&b.treatment_names)
1014 .map(|(x, y)| {
1015 if x == y {
1016 x.clone()
1017 } else {
1018 format!("{x} {op} {y}")
1019 }
1020 })
1021 .collect();
1022 let model = if a.model == b.model {
1023 a.model.clone()
1024 } else {
1025 format!("{} {op} {}", a.model, b.model)
1026 };
1027 Ok(both.combine(model, names, &weights))
1028}
1029
1030impl std::ops::Add for &DmlFit {
1035 type Output = Result<DmlFit>;
1036 fn add(self, other: &DmlFit) -> Result<DmlFit> {
1037 elementwise(self, other, 1.0)
1038 }
1039}
1040
1041impl std::ops::Sub for &DmlFit {
1043 type Output = Result<DmlFit>;
1044 fn sub(self, other: &DmlFit) -> Result<DmlFit> {
1045 elementwise(self, other, -1.0)
1046 }
1047}
1048
1049impl std::ops::Mul<f64> for &DmlFit {
1051 type Output = DmlFit;
1052 fn mul(self, c: f64) -> DmlFit {
1053 let weights = Array2::from_diag(&Array1::from_elem(self.n_treat(), c));
1054 self.combine(self.model.clone(), self.treatment_names.clone(), &weights)
1055 }
1056}
1057
1058impl std::ops::Mul<&DmlFit> for f64 {
1060 type Output = DmlFit;
1061 fn mul(self, fit: &DmlFit) -> DmlFit {
1062 fit * self
1063 }
1064}
1065
1066impl std::ops::Neg for &DmlFit {
1068 type Output = DmlFit;
1069 fn neg(self) -> DmlFit {
1070 self * -1.0
1071 }
1072}
1073
1074pub(crate) fn check_level(level: f64) -> Result<()> {
1075 if level > 0.0 && level < 1.0 {
1076 Ok(())
1077 } else {
1078 Err(DmlError::InvalidArgument(
1079 "confidence level must be in (0, 1)".into(),
1080 ))
1081 }
1082}
1083
1084fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
1086 let m = p.len();
1087 let mut order: Vec<usize> = (0..m).collect();
1088 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
1089 let mut out = Array1::zeros(m);
1090 let mut running = 0.0_f64;
1091 for (rank, &i) in order.iter().enumerate() {
1092 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
1093 out[i] = running;
1094 }
1095 out
1096}
1097
1098impl fmt::Display for DmlFit {
1099 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1100 let clustering = match &self.cluster {
1101 Some(ci) if ci.n_ways() == 2 => {
1102 format!(", two-way clustered ({} clusters)", ci.n_clusters())
1103 }
1104 Some(ci) => format!(", clustered ({} clusters)", ci.n_clusters()),
1105 None => String::new(),
1106 };
1107 writeln!(
1108 f,
1109 "{} | n_obs = {}, n_folds = {}, n_rep = {}{clustering}",
1110 self.model,
1111 self.n_obs,
1112 self.n_folds,
1113 self.n_rep()
1114 )?;
1115 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
1116 let (t, p) = (self.t_stat(), self.p_value());
1117 let width = self
1118 .treatment_names
1119 .iter()
1120 .map(String::len)
1121 .max()
1122 .unwrap_or(1)
1123 .max(4);
1124 write!(
1125 f,
1126 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
1127 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
1128 )?;
1129 for j in 0..self.n_treat() {
1130 write!(
1131 f,
1132 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
1133 self.treatment_names[j],
1134 self.coef[j],
1135 self.se[j],
1136 t[j],
1137 p[j],
1138 ci[[j, 0]],
1139 ci[[j, 1]]
1140 )?;
1141 }
1142 if !self.losses.is_empty() {
1143 write!(
1144 f,
1145 "\nNuisance learners (out-of-sample, mean over repetitions):"
1146 )?;
1147 for l in &self.losses {
1148 let metric = match l.kind {
1149 LossKind::Rmse => "RMSE",
1150 LossKind::LogLoss => "log loss",
1151 };
1152 for j in 0..self.n_treat() {
1153 let row: Vec<f64> = l
1154 .loss
1155 .row(j)
1156 .iter()
1157 .copied()
1158 .filter(|v| v.is_finite())
1159 .collect();
1160 if row.is_empty() {
1161 continue;
1162 }
1163 let mean = row.iter().sum::<f64>() / row.len() as f64;
1164 let who = if self.n_treat() > 1 {
1165 format!(" ({})", self.treatment_names[j])
1166 } else {
1167 String::new()
1168 };
1169 write!(f, "\n {:<12} {metric:<8} {mean:.4}{who}", l.name)?;
1170 }
1171 }
1172 }
1173 Ok(())
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180 use ndarray::array;
1181
1182 #[test]
1183 fn aggregation_matches_doubleml_definition() {
1184 let coef = array![[1.0, 2.0, 4.0]];
1185 let se = array![[0.5, 0.1, 0.2]];
1186 let (c, s) = aggregate(&coef, &se);
1187 assert_eq!(c[0], 2.0);
1188 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
1190 }
1191
1192 #[test]
1193 fn holm_matches_statsmodels() {
1194 let adj = holm(array![0.01, 0.04, 0.03].view());
1196 assert_eq!(adj, array![0.03, 0.06, 0.06]);
1197 }
1198}