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                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                // Fold-weighted estimate and cluster-robust variance; ψ is
122                // scaled by the plain mean of ψ_a elsewhere, as in DoubleML.
123                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                // External predictions have no target to evaluate against.
147                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/// Signal (and basis scale) for CATE / GATE inference, per treatment and
226/// repetition, shape `(n, k, n_rep)`.
227#[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/// Out-of-sample loss of one nuisance learner, per treatment and repetition.
253#[derive(Debug, Clone)]
254pub(crate) struct NuisanceLoss {
255    pub name: String,
256    pub kind: LossKind,
257    /// Shape `(k, n_rep)`.
258    pub loss: Array2<f64>,
259    /// Targets, shape `(n, n_rep, k)`; `NaN` where the nuisance does not apply.
260    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
293/// DoubleML's aggregation over repetitions: median coefficient, and the
294/// standard error implied by the median of the upper bounds `θ_r + 1.96·se_r`.
295pub(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/// Multiple-testing adjustment for [`DmlFit::p_adjust`].
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum PAdjust {
319    Bonferroni,
320    /// Holm's step-down procedure.
321    Holm,
322}
323
324/// A fitted DML model: estimates, standard errors and the per-repetition
325/// scores and nuisance predictions behind them.
326///
327/// Arrays indexed by treatment have length `k = n_treat`; per-repetition
328/// arrays have shape `(k, n_rep)`.
329#[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    /// Shape `(n, k, n_rep)`.
340    pub(crate) psi: Array3<f64>,
341    /// Score elements, shape `(n, k, n_rep)`.
342    pub(crate) psi_a: Array3<f64>,
343    pub(crate) psi_b: Array3<f64>,
344    /// `J = mean(ψ_a)`, shape `(k, n_rep)`.
345    pub(crate) psi_deriv: Array2<f64>,
346    /// Each array has shape `(n, n_rep, k)`.
347    pub(crate) predictions: Vec<(String, Array3<f64>)>,
348    /// Sensitivity elements, for models that support sensitivity analysis.
349    pub(crate) sensitivity: Option<SensitivityStore>,
350    /// Signal for CATE / GATE inference, for models that support it.
351    pub(crate) cate: Option<CateStore>,
352    /// Out-of-sample losses of the nuisance learners.
353    pub(crate) losses: Vec<NuisanceLoss>,
354    /// Cluster structure, for clustered data.
355    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    /// Number of clusters (two-way: the smaller dimension) for a clustered
384    /// fit, `None` otherwise.
385    pub fn n_clusters(&self) -> Option<usize> {
386        self.cluster.as_ref().map(ClusterInfo::n_clusters)
387    }
388
389    /// Point estimates θ̂ (median over repetitions).
390    pub fn coef(&self) -> ArrayView1<'_, f64> {
391        self.coef.view()
392    }
393
394    /// Standard errors (aggregated over repetitions).
395    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    /// Two-sided p-values for `H0: θ = 0` from the aggregated t-statistic,
404    /// as DoubleML's `pval`. (Multiple-testing adjustments in
405    /// [`p_adjust`](Self::p_adjust) instead take the median over repetitions.)
406    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    /// Pointwise Wald confidence intervals at `level`, shape `(k, 2)`
417    /// (median over repetitions of each repetition's interval).
418    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    /// Multiple-testing adjusted p-values across treatments (applied per
429    /// repetition, then the median is taken). For the bootstrap-based
430    /// Romano–Wolf procedure see [`Bootstrap::romano_wolf`](crate::Bootstrap::romano_wolf).
431    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    /// Estimates for every repetition, shape `(k, n_rep)`.
452    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    /// Score values ψ at θ̂, shape `(n, k, n_rep)`.
461    pub fn psi(&self) -> ArrayView3<'_, f64> {
462        self.psi.view()
463    }
464
465    /// Score elements `ψ_a` and `ψ_b` (ψ = ψ_a·θ + ψ_b), shape `(n, k, n_rep)`.
466    pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
467        (self.psi_a.view(), self.psi_b.view())
468    }
469
470    /// Best linear predictor of the CATE on `basis` (shape `(n, p)`, include a
471    /// constant column for an intercept), with HC0 inference (Semenova and
472    /// Chernozhukov, 2021), as DoubleML's `cate`. Available for a single
473    /// treatment of [`Plr`](crate::Plr) (outcome residual on the basis scaled by
474    /// the treatment residual), [`Irm`](crate::Irm) with the ATE score and
475    /// [`Apo`](crate::Apo) (orthogonal signal on the basis).
476    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    /// Group average treatment effects for mutually exclusive groups given as
482    /// 0/1 dummy columns (shape `(n, n_groups)`), as DoubleML's `gate`.
483    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    /// Policy tree recommending treatment where the estimated effect is
489    /// positive, as DoubleML's `policy_tree` (for [`Irm`](crate::Irm) with the
490    /// ATE score, one treatment and one repetition). DoubleML's defaults:
491    /// `depth = 2`, `min_samples_leaf = 8`, `ccp_alpha = 0.01`.
492    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    /// The signal of every repetition and the matching (scaled) bases.
527    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    /// Out-of-sample loss of a nuisance learner, shape `(n_treat, n_rep)`:
560    /// RMSE for regression learners, log loss for classifiers (DoubleML's
561    /// `nuisance_loss`). Computed on the rows where the nuisance applies.
562    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    /// `(name, loss kind, losses)` for every evaluated nuisance learner.
570    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    /// Targets of a nuisance learner, shape `(n, n_rep, n_treat)`, with `NaN`
577    /// where the nuisance does not apply (to evaluate custom metrics).
578    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    /// Names of the stored nuisance predictions (e.g. `ml_l`, `ml_m`).
586    pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
587        self.predictions.iter().map(|(n, _)| n.as_str())
588    }
589
590    /// All predictions of one nuisance, shape `(n, n_rep, k)`.
591    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    /// Out-of-fold predictions of one nuisance for a repetition and treatment.
599    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
606/// Per-repetition intervals `θ_r ± crit_r · se_r`, aggregated by the median
607/// over repetitions (DoubleML's convention). Shape `(k, 2)`.
608pub(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    /// Assemble a fit from per-repetition estimates and score elements that
630    /// are already laid out over all `n` units (zero for units a sub-model did
631    /// not use). Standard errors follow DoubleML: `J = mean(ψ_a)` and
632    /// `se = √(mean(ψ²) / J² / n)` over all `n` units.
633    #[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    /// A fit from the elements `ψ_a`, `ψ_b` of a linear score computed
678    /// elsewhere, each of shape `(n, n_treat, n_rep)` (DoubleML's callable
679    /// scores): θ solves `mean(ψ_a)·θ + mean(ψ_b) = 0` for every estimate and
680    /// repetition, the standard errors are DoubleML's and repetitions are
681    /// aggregated by the median.
682    ///
683    /// ```
684    /// # use dml_core::DmlFit;
685    /// # use ndarray::Array3;
686    /// let psi_a = Array3::from_elem((4, 1, 1), -1.0);
687    /// let psi_b = Array3::from_shape_vec((4, 1, 1), vec![1.0, 2.0, 3.0, 2.0]).unwrap();
688    /// let fit = DmlFit::from_linear_scores("mean", vec!["y".into()], 2, psi_a, psi_b).unwrap();
689    /// assert_eq!(fit.coef()[0], 2.0);
690    /// ```
691    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    /// Linear combinations `Σ_j w_cj θ_j` of the estimates, one per row of
748    /// `weights` (shape `(n_combinations, n_treat)`), named `names`. The
749    /// influence functions are combined the same way, so standard errors,
750    /// intervals and the bootstrap account for the correlation between the
751    /// estimates (DoubleML's framework arithmetic); bounds for the sensitivity
752    /// analysis add up with absolute weights.
753    ///
754    /// ```
755    /// # use dml_core::{DmlData, Plr, learner::Ols};
756    /// # use ndarray::{array, Array1, Array2};
757    /// # let n = 200;
758    /// # let x = Array2::from_shape_fn((n, 1), |(i, _)| (i as f64 * 0.37).sin());
759    /// # let d = Array2::from_shape_fn((n, 2), |(i, j)| (i as f64 * (0.11 + j as f64)).cos());
760    /// # let y: Array1<f64> = (0..n).map(|i| d[[i, 0]] - d[[i, 1]] + x[[i, 0]] + (i as f64 * 1.7).sin()).collect();
761    /// # let data = DmlData::with_treatments(y, d, x).unwrap();
762    /// let fit = Plr::new(Ols::new(), Ols::new()).fit(&data).unwrap();
763    /// // θ_0 − θ_1 with a standard error that accounts for their correlation:
764    /// let diff = fit.linear_combination(array![[1.0, -1.0]].view(), vec!["d0 - d1".into()]).unwrap();
765    /// assert!((diff.coef()[0] - (fit.coef()[0] - fit.coef()[1])).abs() < 1e-12);
766    /// ```
767    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    /// Unchecked [`linear_combination`](Self::linear_combination) with a model name.
793    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                    // The combined influence function is already scaled (J = 1).
817                    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            // Influence functions are stored already scaled (J = 1); the score
834            // elements are zero so CATE methods correctly refuse this fit.
835            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    /// Stack fits of the same data and sample splits into one fit with all
848    /// their estimates (DoubleML's `concat`), for joint inference: the
849    /// bootstrap, simultaneous confidence bands and linear combinations then
850    /// account for the correlation between the estimates.
851    ///
852    /// Predictions and learner losses are stacked by nuisance name (`NaN` where
853    /// a fit has no such nuisance); sensitivity elements are kept if every fit
854    /// has them.
855    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        // Stack per-nuisance arrays (shape (n, n_rep, k)); NaN where absent.
893        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        // CATE signals stack if every fit has one and all or none scale the basis.
940        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
993/// `a ± b` for every estimate: both fits must have the same number of
994/// estimates, observations and repetitions (and come from the same data).
995fn 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
1030// Operators on references (`&a + &b`), so the fits are not consumed. `+` and
1031// `−` return a `Result` since the fits must be compatible.
1032
1033/// `&a + &b`: the sum of the estimates of two fits on the same data.
1034impl 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
1041/// `&a - &b`: the difference of the estimates of two fits on the same data.
1042impl 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
1049/// `&fit * c`: every estimate times the scalar `c`.
1050impl 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
1058/// `c * &fit`.
1059impl std::ops::Mul<&DmlFit> for f64 {
1060    type Output = DmlFit;
1061    fn mul(self, fit: &DmlFit) -> DmlFit {
1062        fit * self
1063    }
1064}
1065
1066/// `-&fit`.
1067impl 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
1084/// Holm step-down adjustment (as `statsmodels.multipletests(method="holm")`).
1085fn 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        // upper bounds: 1.98, 2.196, 4.392 → median 2.196
1189        assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
1190    }
1191
1192    #[test]
1193    fn holm_matches_statsmodels() {
1194        // statsmodels.multipletests([0.01, 0.04, 0.03], method="holm")[1]
1195        let adj = holm(array![0.01, 0.04, 0.03].view());
1196        assert_eq!(adj, array![0.03, 0.06, 0.06]);
1197    }
1198}