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