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::{LinearScoreModel, LossKind, ScoreInput};
13use crate::sensitivity::SensitivityStore;
14use crate::stats::{median, norm_ppf, norm_sf};
15
16/// Fit any [`LinearScoreModel`] on the given sample splits (one `Vec<Fold>`
17/// per repetition): for every repetition and treatment, cross-fit the
18/// nuisances, solve the DML2 score and estimate the standard error; then
19/// aggregate over repetitions.
20///
21/// (`?Sized` lets this accept trait objects such as `&dyn LinearScoreModel`
22/// as well as concrete models.)
23pub fn fit_model<M: LinearScoreModel + ?Sized>(
24    model: &M,
25    data: &DmlData,
26    splits: &[Vec<Fold>],
27) -> Result<DmlFit> {
28    // With clustered data, the test clusters of each fold are read off its test rows.
29    let cluster = match data.clusters() {
30        Some(c) => Some(ClusterInfo::new(
31            c.to_vec(),
32            splits
33                .iter()
34                .map(|rep| {
35                    rep.iter()
36                        .map(|f| ClusterFold {
37                            fold: f.clone(),
38                            test_clusters: derive_test_clusters(c, f),
39                        })
40                        .collect()
41                })
42                .collect(),
43        )?),
44        None => None,
45    };
46    fit_model_impl(model, data, splits, cluster)
47}
48
49/// Fit on clustered data with explicit cluster folds (one `Vec<ClusterFold>`
50/// per repetition), e.g. to reproduce another implementation exactly.
51pub fn fit_model_clustered<M: LinearScoreModel + ?Sized>(
52    model: &M,
53    data: &DmlData,
54    splits: &[Vec<ClusterFold>],
55) -> Result<DmlFit> {
56    let clusters = data.clusters().ok_or_else(|| {
57        DmlError::InvalidArgument(
58            "cluster folds need clustered data: use DmlData::with_clusters".into(),
59        )
60    })?;
61    let folds: Vec<Vec<Fold>> = splits
62        .iter()
63        .map(|rep| rep.iter().map(|cf| cf.fold.clone()).collect())
64        .collect();
65    let cluster = ClusterInfo::new(clusters.to_vec(), splits.to_vec())?;
66    fit_model_impl(model, data, &folds, Some(cluster))
67}
68
69fn fit_model_impl<M: LinearScoreModel + ?Sized>(
70    model: &M,
71    data: &DmlData,
72    splits: &[Vec<Fold>],
73    cluster: Option<ClusterInfo>,
74) -> Result<DmlFit> {
75    model.validate(data)?;
76    let n = data.n_obs();
77    check_splits(splits, n)?;
78    let (k, n_rep) = (data.n_treat(), splits.len());
79
80    let mut all_coef = Array2::<f64>::zeros((k, n_rep));
81    let mut all_se = Array2::<f64>::zeros((k, n_rep));
82    let mut psi = Array3::<f64>::zeros((n, k, n_rep));
83    let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
84    let mut psi_a = Array3::<f64>::zeros((n, k, n_rep));
85    let mut psi_b = Array3::<f64>::zeros((n, k, n_rep));
86    let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
87    // Population variance of y, for sensitivity benchmarks.
88    let y_mean = data.y().mean().expect("n > 0");
89    let var_y = data
90        .y()
91        .mapv(|v| (v - y_mean).powi(2))
92        .mean()
93        .expect("n > 0");
94    let mut sensitivity = Some(SensitivityStore::new(n, k, n_rep, var_y));
95    let mut losses: Vec<NuisanceLoss> = Vec::new();
96
97    for j in 0..k {
98        let x_j = data.covariates_for(j);
99        let input = ScoreInput {
100            y: data.y(),
101            d: data.treatment(j),
102            x: x_j.view(),
103            z: data.z(),
104            t: data.time(),
105        };
106        for (r, folds) in splits.iter().enumerate() {
107            let est = model.estimate_score(&input, folds)?;
108            let mut sol = est.score.solve()?;
109            if let Some(ci) = &cluster {
110                // Fold-weighted estimate and cluster-robust variance; ψ is
111                // scaled by the plain mean of ψ_a elsewhere, as in DoubleML.
112                let theta = ci.solve_theta(&est.score, r)?;
113                let psi_r = &est.score.psi_a * theta + &est.score.psi_b;
114                let sigma2 = ci.variance(psi_r.view(), est.score.psi_a.view(), r);
115                sol.theta = theta;
116                sol.se = sigma2.sqrt();
117                sol.psi = psi_r;
118            }
119            if !(sol.theta.is_finite() && sol.se.is_finite()) {
120                return Err(DmlError::InvalidArgument(format!(
121                    "non-finite estimate for treatment {j}, repetition {r}"
122                )));
123            }
124            all_coef[[j, r]] = sol.theta;
125            all_se[[j, r]] = sol.se;
126            psi_deriv[[j, r]] = sol.j;
127            psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
128            psi_a
129                .slice_mut(ndarray::s![.., j, r])
130                .assign(&est.score.psi_a);
131            psi_b
132                .slice_mut(ndarray::s![.., j, r])
133                .assign(&est.score.psi_b);
134            for target in &est.targets {
135                let Some((_, pred)) = est.predictions.iter().find(|(nm, _)| *nm == target.name)
136                else {
137                    continue;
138                };
139                let slot = match losses.iter().position(|l| l.name == target.name) {
140                    Some(i) => i,
141                    None => {
142                        losses.push(NuisanceLoss {
143                            name: target.name.to_string(),
144                            kind: target.kind,
145                            loss: Array2::from_elem((k, n_rep), f64::NAN),
146                            targets: Array3::from_elem((n, n_rep, k), f64::NAN),
147                        });
148                        losses.len() - 1
149                    }
150                };
151                losses[slot].loss[[j, r]] = target.loss(pred);
152                losses[slot]
153                    .targets
154                    .slice_mut(ndarray::s![.., r, j])
155                    .assign(&target.target);
156            }
157            match (&est.sensitivity, &mut sensitivity) {
158                (Some(parts), Some(store)) => store.set(j, r, &parts.elements(sol.theta)),
159                _ => sensitivity = None,
160            }
161            for (name, pred) in est.predictions {
162                let slot = match predictions.iter().position(|(nm, _)| nm == name) {
163                    Some(i) => i,
164                    None => {
165                        predictions
166                            .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
167                        predictions.len() - 1
168                    }
169                };
170                predictions[slot]
171                    .1
172                    .slice_mut(ndarray::s![.., r, j])
173                    .assign(&pred);
174            }
175        }
176    }
177
178    let (coef, se) = aggregate(&all_coef, &all_se);
179    Ok(DmlFit {
180        model: model.name(),
181        treatment_names: data.treatment_names().to_vec(),
182        n_obs: n,
183        n_folds: splits[0].len(),
184        coef,
185        se,
186        all_coef,
187        all_se,
188        psi,
189        psi_a,
190        psi_b,
191        psi_deriv,
192        predictions,
193        sensitivity,
194        losses,
195        cluster,
196    })
197}
198
199/// Out-of-sample loss of one nuisance learner, per treatment and repetition.
200#[derive(Debug, Clone)]
201pub(crate) struct NuisanceLoss {
202    pub name: String,
203    pub kind: LossKind,
204    /// Shape `(k, n_rep)`.
205    pub loss: Array2<f64>,
206    /// Targets, shape `(n, n_rep, k)`; `NaN` where the nuisance does not apply.
207    pub targets: Array3<f64>,
208}
209
210fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
211    if splits.is_empty() {
212        return Err(DmlError::InvalidArgument("no sample splits given".into()));
213    }
214    for (r, folds) in splits.iter().enumerate() {
215        let mut seen = vec![false; n];
216        for fold in folds {
217            for &i in &fold.test {
218                if i >= n || seen[i] {
219                    return Err(DmlError::InvalidArgument(format!(
220                        "repetition {r}: test sets must partition 0..{n}"
221                    )));
222                }
223                seen[i] = true;
224            }
225            if fold.train.iter().any(|&i| i >= n) {
226                return Err(DmlError::InvalidArgument(format!(
227                    "repetition {r}: training index out of range"
228                )));
229            }
230        }
231        if seen.iter().any(|s| !s) {
232            return Err(DmlError::InvalidArgument(format!(
233                "repetition {r}: test sets must partition 0..{n}"
234            )));
235        }
236    }
237    Ok(())
238}
239
240/// DoubleML's aggregation over repetitions: median coefficient, and the
241/// standard error implied by the median of the upper bounds `θ_r + 1.96·se_r`.
242pub(crate) fn aggregate(
243    all_coef: &Array2<f64>,
244    all_se: &Array2<f64>,
245) -> (Array1<f64>, Array1<f64>) {
246    const CRIT: f64 = 1.96;
247    let k = all_coef.nrows();
248    let mut coef = Array1::zeros(k);
249    let mut se = Array1::zeros(k);
250    for j in 0..k {
251        let c = all_coef.row(j);
252        coef[j] = median(&c.to_vec());
253        let upper: Vec<f64> = c
254            .iter()
255            .zip(all_se.row(j))
256            .map(|(&t, &s)| t + CRIT * s)
257            .collect();
258        se[j] = (median(&upper) - coef[j]) / CRIT;
259    }
260    (coef, se)
261}
262
263/// Multiple-testing adjustment for [`DmlFit::p_adjust`].
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum PAdjust {
266    Bonferroni,
267    /// Holm's step-down procedure.
268    Holm,
269}
270
271/// A fitted DML model: estimates, standard errors and the per-repetition
272/// scores and nuisance predictions behind them.
273///
274/// Arrays indexed by treatment have length `k = n_treat`; per-repetition
275/// arrays have shape `(k, n_rep)`.
276#[derive(Debug, Clone)]
277pub struct DmlFit {
278    pub(crate) model: String,
279    pub(crate) treatment_names: Vec<String>,
280    pub(crate) n_obs: usize,
281    pub(crate) n_folds: usize,
282    pub(crate) coef: Array1<f64>,
283    pub(crate) se: Array1<f64>,
284    pub(crate) all_coef: Array2<f64>,
285    pub(crate) all_se: Array2<f64>,
286    /// Shape `(n, k, n_rep)`.
287    pub(crate) psi: Array3<f64>,
288    /// Score elements, shape `(n, k, n_rep)`.
289    pub(crate) psi_a: Array3<f64>,
290    pub(crate) psi_b: Array3<f64>,
291    /// `J = mean(ψ_a)`, shape `(k, n_rep)`.
292    pub(crate) psi_deriv: Array2<f64>,
293    /// Each array has shape `(n, n_rep, k)`.
294    pub(crate) predictions: Vec<(String, Array3<f64>)>,
295    /// Sensitivity elements, for models that support sensitivity analysis.
296    pub(crate) sensitivity: Option<SensitivityStore>,
297    /// Out-of-sample losses of the nuisance learners.
298    pub(crate) losses: Vec<NuisanceLoss>,
299    /// Cluster structure, for clustered data.
300    pub(crate) cluster: Option<ClusterInfo>,
301}
302
303impl DmlFit {
304    pub fn model_name(&self) -> &str {
305        &self.model
306    }
307
308    pub fn treatment_names(&self) -> &[String] {
309        &self.treatment_names
310    }
311
312    pub fn n_obs(&self) -> usize {
313        self.n_obs
314    }
315
316    pub fn n_treat(&self) -> usize {
317        self.coef.len()
318    }
319
320    pub fn n_rep(&self) -> usize {
321        self.all_coef.ncols()
322    }
323
324    pub fn n_folds(&self) -> usize {
325        self.n_folds
326    }
327
328    /// Number of clusters (two-way: the smaller dimension) for a clustered
329    /// fit, `None` otherwise.
330    pub fn n_clusters(&self) -> Option<usize> {
331        self.cluster.as_ref().map(ClusterInfo::n_clusters)
332    }
333
334    /// Point estimates θ̂ (median over repetitions).
335    pub fn coef(&self) -> ArrayView1<'_, f64> {
336        self.coef.view()
337    }
338
339    /// Standard errors (aggregated over repetitions).
340    pub fn se(&self) -> ArrayView1<'_, f64> {
341        self.se.view()
342    }
343
344    pub fn t_stat(&self) -> Array1<f64> {
345        &self.coef / &self.se
346    }
347
348    /// Two-sided p-values for `H0: θ = 0` from the aggregated t-statistic,
349    /// as DoubleML's `pval`. (Multiple-testing adjustments in
350    /// [`p_adjust`](Self::p_adjust) instead take the median over repetitions.)
351    pub fn p_value(&self) -> Array1<f64> {
352        self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
353    }
354
355    fn per_rep_pvals(&self) -> Array2<f64> {
356        let mut p = &self.all_coef / &self.all_se;
357        p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
358        p
359    }
360
361    /// Pointwise Wald confidence intervals at `level`, shape `(k, 2)`
362    /// (median over repetitions of each repetition's interval).
363    pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
364        check_level(level)?;
365        let z = norm_ppf(0.5 + level / 2.0);
366        Ok(confint_from(
367            &self.all_coef,
368            &self.all_se,
369            &Array1::from_elem(self.n_rep(), z),
370        ))
371    }
372
373    /// Multiple-testing adjusted p-values across treatments (applied per
374    /// repetition, then the median is taken). For the bootstrap-based
375    /// Romano–Wolf procedure see [`Bootstrap::romano_wolf`](crate::Bootstrap::romano_wolf).
376    pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
377        let raw = self.per_rep_pvals();
378        let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
379        for (r, col) in raw.columns().into_iter().enumerate() {
380            let adj = match method {
381                PAdjust::Bonferroni => {
382                    let m = col.len() as f64;
383                    col.mapv(|p| (m * p).min(1.0))
384                }
385                PAdjust::Holm => holm(col),
386            };
387            adjusted.column_mut(r).assign(&adj);
388        }
389        adjusted
390            .rows()
391            .into_iter()
392            .map(|row| median(&row.to_vec()))
393            .collect()
394    }
395
396    /// Estimates for every repetition, shape `(k, n_rep)`.
397    pub fn all_coef(&self) -> ArrayView2<'_, f64> {
398        self.all_coef.view()
399    }
400
401    pub fn all_se(&self) -> ArrayView2<'_, f64> {
402        self.all_se.view()
403    }
404
405    /// Score values ψ at θ̂, shape `(n, k, n_rep)`.
406    pub fn psi(&self) -> ArrayView3<'_, f64> {
407        self.psi.view()
408    }
409
410    /// Score elements `ψ_a` and `ψ_b` (ψ = ψ_a·θ + ψ_b), shape `(n, k, n_rep)`.
411    pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
412        (self.psi_a.view(), self.psi_b.view())
413    }
414
415    /// Best linear predictor of the CATE on `basis` (shape `(n, p)`, include a
416    /// constant column for an intercept), using the orthogonal signal `ψ_b`.
417    /// Valid for scores with `ψ_a = −1`, i.e. [`Irm`](crate::Irm) with the ATE score.
418    pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
419        Blp::fit(self.orthogonal_signal()?.view(), basis)
420    }
421
422    /// Group average treatment effects for mutually exclusive groups given as
423    /// 0/1 dummy columns (shape `(n, n_groups)`).
424    pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
425        Blp::fit_gate(self.orthogonal_signal()?.view(), groups)
426    }
427
428    fn orthogonal_signal(&self) -> Result<Array2<f64>> {
429        if self.n_treat() != 1 || self.psi_a.iter().any(|&a| a != -1.0) {
430            return Err(DmlError::InvalidArgument(
431                "CATE / GATE need a single treatment and a score with psi_a = -1 (IRM with the ATE score)"
432                    .into(),
433            ));
434        }
435        Ok(self.psi_b.index_axis(Axis(1), 0).to_owned())
436    }
437
438    /// Out-of-sample loss of a nuisance learner, shape `(n_treat, n_rep)`:
439    /// RMSE for regression learners, log loss for classifiers (DoubleML's
440    /// `nuisance_loss`). Computed on the rows where the nuisance applies.
441    pub fn nuisance_loss(&self, name: &str) -> Option<ArrayView2<'_, f64>> {
442        self.losses
443            .iter()
444            .find(|l| l.name == name)
445            .map(|l| l.loss.view())
446    }
447
448    /// `(name, loss kind, losses)` for every evaluated nuisance learner.
449    pub fn nuisance_losses(&self) -> impl Iterator<Item = (&str, LossKind, ArrayView2<'_, f64>)> {
450        self.losses
451            .iter()
452            .map(|l| (l.name.as_str(), l.kind, l.loss.view()))
453    }
454
455    /// Targets of a nuisance learner, shape `(n, n_rep, n_treat)`, with `NaN`
456    /// where the nuisance does not apply (to evaluate custom metrics).
457    pub fn nuisance_targets(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
458        self.losses
459            .iter()
460            .find(|l| l.name == name)
461            .map(|l| l.targets.view())
462    }
463
464    /// Names of the stored nuisance predictions (e.g. `ml_l`, `ml_m`).
465    pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
466        self.predictions.iter().map(|(n, _)| n.as_str())
467    }
468
469    /// All predictions of one nuisance, shape `(n, n_rep, k)`.
470    pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
471        self.predictions
472            .iter()
473            .find(|(n, _)| n == name)
474            .map(|(_, p)| p.view())
475    }
476
477    /// Out-of-fold predictions of one nuisance for a repetition and treatment.
478    pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
479        let p = self.predictions(name)?;
480        (rep < self.n_rep() && treatment < self.n_treat())
481            .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
482    }
483}
484
485/// Per-repetition intervals `θ_r ± crit_r · se_r`, aggregated by the median
486/// over repetitions (DoubleML's convention). Shape `(k, 2)`.
487pub(crate) fn confint_from(
488    all_coef: &Array2<f64>,
489    all_se: &Array2<f64>,
490    crit: &Array1<f64>,
491) -> Array2<f64> {
492    let (k, n_rep) = all_coef.dim();
493    let mut ci = Array2::zeros((k, 2));
494    for j in 0..k {
495        let lo: Vec<f64> = (0..n_rep)
496            .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
497            .collect();
498        let hi: Vec<f64> = (0..n_rep)
499            .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
500            .collect();
501        ci[[j, 0]] = median(&lo);
502        ci[[j, 1]] = median(&hi);
503    }
504    ci
505}
506
507impl DmlFit {
508    /// Assemble a fit from per-repetition estimates and score elements that
509    /// are already laid out over all `n` units (zero for units a sub-model did
510    /// not use). Standard errors follow DoubleML: `J = mean(ψ_a)` and
511    /// `se = √(mean(ψ²) / J² / n)` over all `n` units.
512    #[allow(clippy::too_many_arguments)]
513    pub(crate) fn from_scores(
514        model: String,
515        treatment_names: Vec<String>,
516        n_folds: usize,
517        all_coef: Array2<f64>,
518        psi: Array3<f64>,
519        psi_a: Array3<f64>,
520        psi_b: Array3<f64>,
521        predictions: Vec<(String, Array3<f64>)>,
522    ) -> Self {
523        let (n, k, n_rep) = psi.dim();
524        let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
525        let mut all_se = Array2::<f64>::zeros((k, n_rep));
526        for j in 0..k {
527            for r in 0..n_rep {
528                let jac = psi_a.slice(ndarray::s![.., j, r]).sum() / n as f64;
529                let col = psi.slice(ndarray::s![.., j, r]);
530                psi_deriv[[j, r]] = jac;
531                all_se[[j, r]] = (col.dot(&col) / n as f64 / (jac * jac) / n as f64).sqrt();
532            }
533        }
534        let (coef, se) = aggregate(&all_coef, &all_se);
535        Self {
536            model,
537            treatment_names,
538            n_obs: n,
539            n_folds,
540            coef,
541            se,
542            all_coef,
543            all_se,
544            psi,
545            psi_a,
546            psi_b,
547            psi_deriv,
548            predictions,
549            sensitivity: None,
550            losses: Vec::new(),
551            cluster: None,
552        }
553    }
554
555    /// Linear combinations `Σ_j w_cj θ_j` of this fit's estimates (one row of
556    /// `weights` per combination), with influence functions combined the same
557    /// way, so correlation between the estimates is accounted for.
558    pub(crate) fn linear_combination(
559        &self,
560        model: String,
561        names: Vec<String>,
562        weights: &Array2<f64>,
563    ) -> Self {
564        let (n, k, n_rep) = self.psi.dim();
565        assert_eq!(weights.ncols(), k, "one weight per estimate");
566        let n_comb = weights.nrows();
567        let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
568        let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
569        let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
570        for r in 0..n_rep {
571            for c in 0..n_comb {
572                let mut col = Array1::<f64>::zeros(n);
573                let mut theta = 0.0;
574                for j in 0..k {
575                    let w = weights[[c, j]];
576                    if w != 0.0 {
577                        col.scaled_add(
578                            w / self.psi_deriv[[j, r]],
579                            &self.psi.slice(ndarray::s![.., j, r]),
580                        );
581                        theta += w * self.all_coef[[j, r]];
582                    }
583                }
584                all_coef[[c, r]] = theta;
585                all_se[[c, r]] = (col.dot(&col) / n as f64 / n as f64).sqrt();
586                scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
587            }
588        }
589        let (coef, se) = aggregate(&all_coef, &all_se);
590        Self {
591            model,
592            treatment_names: names,
593            n_obs: n,
594            n_folds: self.n_folds,
595            coef,
596            se,
597            all_coef,
598            all_se,
599            // Influence functions are stored already scaled (J = 1); the score
600            // elements are zero so CATE methods correctly refuse this fit.
601            psi: scaled,
602            psi_a: Array3::zeros((n, n_comb, n_rep)),
603            psi_b: Array3::zeros((n, n_comb, n_rep)),
604            psi_deriv: Array2::ones((n_comb, n_rep)),
605            predictions: Vec::new(),
606            sensitivity: None,
607            losses: Vec::new(),
608            cluster: None,
609        }
610    }
611}
612
613pub(crate) fn check_level(level: f64) -> Result<()> {
614    if level > 0.0 && level < 1.0 {
615        Ok(())
616    } else {
617        Err(DmlError::InvalidArgument(
618            "confidence level must be in (0, 1)".into(),
619        ))
620    }
621}
622
623/// Holm step-down adjustment (as `statsmodels.multipletests(method="holm")`).
624fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
625    let m = p.len();
626    let mut order: Vec<usize> = (0..m).collect();
627    order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
628    let mut out = Array1::zeros(m);
629    let mut running = 0.0_f64;
630    for (rank, &i) in order.iter().enumerate() {
631        running = running.max(((m - rank) as f64 * p[i]).min(1.0));
632        out[i] = running;
633    }
634    out
635}
636
637impl fmt::Display for DmlFit {
638    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
639        let clustering = match &self.cluster {
640            Some(ci) if ci.n_ways() == 2 => {
641                format!(", two-way clustered ({} clusters)", ci.n_clusters())
642            }
643            Some(ci) => format!(", clustered ({} clusters)", ci.n_clusters()),
644            None => String::new(),
645        };
646        writeln!(
647            f,
648            "{}  |  n_obs = {}, n_folds = {}, n_rep = {}{clustering}",
649            self.model,
650            self.n_obs,
651            self.n_folds,
652            self.n_rep()
653        )?;
654        let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
655        let (t, p) = (self.t_stat(), self.p_value());
656        let width = self
657            .treatment_names
658            .iter()
659            .map(String::len)
660            .max()
661            .unwrap_or(1)
662            .max(4);
663        write!(
664            f,
665            "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
666            "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
667        )?;
668        for j in 0..self.n_treat() {
669            write!(
670                f,
671                "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
672                self.treatment_names[j],
673                self.coef[j],
674                self.se[j],
675                t[j],
676                p[j],
677                ci[[j, 0]],
678                ci[[j, 1]]
679            )?;
680        }
681        if !self.losses.is_empty() {
682            write!(
683                f,
684                "\nNuisance learners (out-of-sample, mean over repetitions):"
685            )?;
686            for l in &self.losses {
687                let metric = match l.kind {
688                    LossKind::Rmse => "RMSE",
689                    LossKind::LogLoss => "log loss",
690                };
691                for j in 0..self.n_treat() {
692                    let row: Vec<f64> = l
693                        .loss
694                        .row(j)
695                        .iter()
696                        .copied()
697                        .filter(|v| v.is_finite())
698                        .collect();
699                    if row.is_empty() {
700                        continue;
701                    }
702                    let mean = row.iter().sum::<f64>() / row.len() as f64;
703                    let who = if self.n_treat() > 1 {
704                        format!(" ({})", self.treatment_names[j])
705                    } else {
706                        String::new()
707                    };
708                    write!(f, "\n  {:<12} {metric:<8} {mean:.4}{who}", l.name)?;
709                }
710            }
711        }
712        Ok(())
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use ndarray::array;
720
721    #[test]
722    fn aggregation_matches_doubleml_definition() {
723        let coef = array![[1.0, 2.0, 4.0]];
724        let se = array![[0.5, 0.1, 0.2]];
725        let (c, s) = aggregate(&coef, &se);
726        assert_eq!(c[0], 2.0);
727        // upper bounds: 1.98, 2.196, 4.392 → median 2.196
728        assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
729    }
730
731    #[test]
732    fn holm_matches_statsmodels() {
733        // statsmodels.multipletests([0.01, 0.04, 0.03], method="holm")[1]
734        let adj = holm(array![0.01, 0.04, 0.03].view());
735        assert_eq!(adj, array![0.03, 0.06, 0.06]);
736    }
737}