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