Skip to main content

dml_core/
fit.rs

1//! The shared DML driver and its result type, [`DmlFit`].
2
3use 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
17/// Fit any [`LinearScoreModel`] on the given sample splits (one `Vec<Fold>`
18/// per repetition): for every repetition and treatment, cross-fit the
19/// nuisances, solve the DML2 score and estimate the standard error; then
20/// aggregate over repetitions.
21///
22/// (`?Sized` lets this accept trait objects such as `&dyn LinearScoreModel`
23/// as well as concrete models.)
24pub fn fit_model<M: LinearScoreModel + ?Sized>(
25    model: &M,
26    data: &DmlData,
27    splits: &[Vec<Fold>],
28) -> Result<DmlFit> {
29    // With clustered data, the test clusters of each fold are read off its test rows.
30    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
50/// Fit on clustered data with explicit cluster folds (one `Vec<ClusterFold>`
51/// per repetition), e.g. to reproduce another implementation exactly.
52pub 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    // Population variance of y, for sensitivity benchmarks.
89    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                // Fold-weighted estimate and cluster-robust variance; ψ is
119                // scaled by the plain mean of ψ_a elsewhere, as in DoubleML.
120                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/// Signal (and basis scale) for CATE / GATE inference, per treatment and
213/// repetition, shape `(n, k, n_rep)`.
214#[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/// Out-of-sample loss of one nuisance learner, per treatment and repetition.
240#[derive(Debug, Clone)]
241pub(crate) struct NuisanceLoss {
242    pub name: String,
243    pub kind: LossKind,
244    /// Shape `(k, n_rep)`.
245    pub loss: Array2<f64>,
246    /// Targets, shape `(n, n_rep, k)`; `NaN` where the nuisance does not apply.
247    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
280/// DoubleML's aggregation over repetitions: median coefficient, and the
281/// standard error implied by the median of the upper bounds `θ_r + 1.96·se_r`.
282pub(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/// Multiple-testing adjustment for [`DmlFit::p_adjust`].
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum PAdjust {
306    Bonferroni,
307    /// Holm's step-down procedure.
308    Holm,
309}
310
311/// A fitted DML model: estimates, standard errors and the per-repetition
312/// scores and nuisance predictions behind them.
313///
314/// Arrays indexed by treatment have length `k = n_treat`; per-repetition
315/// arrays have shape `(k, n_rep)`.
316#[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    /// Shape `(n, k, n_rep)`.
327    pub(crate) psi: Array3<f64>,
328    /// Score elements, shape `(n, k, n_rep)`.
329    pub(crate) psi_a: Array3<f64>,
330    pub(crate) psi_b: Array3<f64>,
331    /// `J = mean(ψ_a)`, shape `(k, n_rep)`.
332    pub(crate) psi_deriv: Array2<f64>,
333    /// Each array has shape `(n, n_rep, k)`.
334    pub(crate) predictions: Vec<(String, Array3<f64>)>,
335    /// Sensitivity elements, for models that support sensitivity analysis.
336    pub(crate) sensitivity: Option<SensitivityStore>,
337    /// Signal for CATE / GATE inference, for models that support it.
338    pub(crate) cate: Option<CateStore>,
339    /// Out-of-sample losses of the nuisance learners.
340    pub(crate) losses: Vec<NuisanceLoss>,
341    /// Cluster structure, for clustered data.
342    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    /// Number of clusters (two-way: the smaller dimension) for a clustered
371    /// fit, `None` otherwise.
372    pub fn n_clusters(&self) -> Option<usize> {
373        self.cluster.as_ref().map(ClusterInfo::n_clusters)
374    }
375
376    /// Point estimates θ̂ (median over repetitions).
377    pub fn coef(&self) -> ArrayView1<'_, f64> {
378        self.coef.view()
379    }
380
381    /// Standard errors (aggregated over repetitions).
382    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    /// Two-sided p-values for `H0: θ = 0` from the aggregated t-statistic,
391    /// as DoubleML's `pval`. (Multiple-testing adjustments in
392    /// [`p_adjust`](Self::p_adjust) instead take the median over repetitions.)
393    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    /// Pointwise Wald confidence intervals at `level`, shape `(k, 2)`
404    /// (median over repetitions of each repetition's interval).
405    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    /// Multiple-testing adjusted p-values across treatments (applied per
416    /// repetition, then the median is taken). For the bootstrap-based
417    /// Romano–Wolf procedure see [`Bootstrap::romano_wolf`](crate::Bootstrap::romano_wolf).
418    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    /// Estimates for every repetition, shape `(k, n_rep)`.
439    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    /// Score values ψ at θ̂, shape `(n, k, n_rep)`.
448    pub fn psi(&self) -> ArrayView3<'_, f64> {
449        self.psi.view()
450    }
451
452    /// Score elements `ψ_a` and `ψ_b` (ψ = ψ_a·θ + ψ_b), shape `(n, k, n_rep)`.
453    pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
454        (self.psi_a.view(), self.psi_b.view())
455    }
456
457    /// Best linear predictor of the CATE on `basis` (shape `(n, p)`, include a
458    /// constant column for an intercept), with HC0 inference (Semenova and
459    /// Chernozhukov, 2021), as DoubleML's `cate`. Available for a single
460    /// treatment of [`Plr`](crate::Plr) (outcome residual on the basis scaled by
461    /// the treatment residual), [`Irm`](crate::Irm) with the ATE score and
462    /// [`Apo`](crate::Apo) (orthogonal signal on the basis).
463    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    /// Group average treatment effects for mutually exclusive groups given as
469    /// 0/1 dummy columns (shape `(n, n_groups)`), as DoubleML's `gate`.
470    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    /// Policy tree recommending treatment where the estimated effect is
476    /// positive, as DoubleML's `policy_tree` (for [`Irm`](crate::Irm) with the
477    /// ATE score, one treatment and one repetition). DoubleML's defaults:
478    /// `depth = 2`, `min_samples_leaf = 8`, `ccp_alpha = 0.01`.
479    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    /// The signal of every repetition and the matching (scaled) bases.
514    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    /// Out-of-sample loss of a nuisance learner, shape `(n_treat, n_rep)`:
547    /// RMSE for regression learners, log loss for classifiers (DoubleML's
548    /// `nuisance_loss`). Computed on the rows where the nuisance applies.
549    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    /// `(name, loss kind, losses)` for every evaluated nuisance learner.
557    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    /// Targets of a nuisance learner, shape `(n, n_rep, n_treat)`, with `NaN`
564    /// where the nuisance does not apply (to evaluate custom metrics).
565    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    /// Names of the stored nuisance predictions (e.g. `ml_l`, `ml_m`).
573    pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
574        self.predictions.iter().map(|(n, _)| n.as_str())
575    }
576
577    /// All predictions of one nuisance, shape `(n, n_rep, k)`.
578    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    /// Out-of-fold predictions of one nuisance for a repetition and treatment.
586    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
593/// Per-repetition intervals `θ_r ± crit_r · se_r`, aggregated by the median
594/// over repetitions (DoubleML's convention). Shape `(k, 2)`.
595pub(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    /// Assemble a fit from per-repetition estimates and score elements that
617    /// are already laid out over all `n` units (zero for units a sub-model did
618    /// not use). Standard errors follow DoubleML: `J = mean(ψ_a)` and
619    /// `se = √(mean(ψ²) / J² / n)` over all `n` units.
620    #[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    /// Linear combinations `Σ_j w_cj θ_j` of this fit's estimates (one row of
665    /// `weights` per combination), with influence functions combined the same
666    /// way, so correlation between the estimates is accounted for.
667    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                    // The combined influence function is already scaled (J = 1).
696                    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            // Influence functions are stored already scaled (J = 1); the score
713            // elements are zero so CATE methods correctly refuse this fit.
714            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    /// Stack fits of the same data and sample splits into one fit with all
727    /// their estimates (DoubleML's `concat`), for joint inference: the
728    /// bootstrap, simultaneous confidence bands and linear combinations then
729    /// account for the correlation between the estimates.
730    ///
731    /// Predictions and learner losses are stacked by nuisance name (`NaN` where
732    /// a fit has no such nuisance); sensitivity elements are kept if every fit
733    /// has them.
734    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        // Stack per-nuisance arrays (shape (n, n_rep, k)); NaN where absent.
772        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        // CATE signals stack if every fit has one and all or none scale the basis.
819        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
882/// Holm step-down adjustment (as `statsmodels.multipletests(method="holm")`).
883fn 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        // upper bounds: 1.98, 2.196, 4.392 → median 2.196
987        assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
988    }
989
990    #[test]
991    fn holm_matches_statsmodels() {
992        // statsmodels.multipletests([0.01, 0.04, 0.03], method="holm")[1]
993        let adj = holm(array![0.01, 0.04, 0.03].view());
994        assert_eq!(adj, array![0.03, 0.06, 0.06]);
995    }
996}