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        };
47        for (r, folds) in splits.iter().enumerate() {
48            let est = model.estimate_score(&input, folds)?;
49            let sol = est.score.solve()?;
50            if !(sol.theta.is_finite() && sol.se.is_finite()) {
51                return Err(DmlError::InvalidArgument(format!(
52                    "non-finite estimate for treatment {j}, repetition {r}"
53                )));
54            }
55            all_coef[[j, r]] = sol.theta;
56            all_se[[j, r]] = sol.se;
57            psi_deriv[[j, r]] = sol.j;
58            psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
59            psi_a
60                .slice_mut(ndarray::s![.., j, r])
61                .assign(&est.score.psi_a);
62            psi_b
63                .slice_mut(ndarray::s![.., j, r])
64                .assign(&est.score.psi_b);
65            for (name, pred) in est.predictions {
66                let slot = match predictions.iter().position(|(nm, _)| nm == name) {
67                    Some(i) => i,
68                    None => {
69                        predictions
70                            .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
71                        predictions.len() - 1
72                    }
73                };
74                predictions[slot]
75                    .1
76                    .slice_mut(ndarray::s![.., r, j])
77                    .assign(&pred);
78            }
79        }
80    }
81
82    let (coef, se) = aggregate(&all_coef, &all_se);
83    Ok(DmlFit {
84        model: model.name(),
85        treatment_names: data.treatment_names().to_vec(),
86        n_obs: n,
87        n_folds: splits[0].len(),
88        coef,
89        se,
90        all_coef,
91        all_se,
92        psi,
93        psi_a,
94        psi_b,
95        psi_deriv,
96        predictions,
97    })
98}
99
100fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
101    if splits.is_empty() {
102        return Err(DmlError::InvalidArgument("no sample splits given".into()));
103    }
104    for (r, folds) in splits.iter().enumerate() {
105        let mut seen = vec![false; n];
106        for fold in folds {
107            for &i in &fold.test {
108                if i >= n || seen[i] {
109                    return Err(DmlError::InvalidArgument(format!(
110                        "repetition {r}: test sets must partition 0..{n}"
111                    )));
112                }
113                seen[i] = true;
114            }
115            if fold.train.iter().any(|&i| i >= n) {
116                return Err(DmlError::InvalidArgument(format!(
117                    "repetition {r}: training index out of range"
118                )));
119            }
120        }
121        if seen.iter().any(|s| !s) {
122            return Err(DmlError::InvalidArgument(format!(
123                "repetition {r}: test sets must partition 0..{n}"
124            )));
125        }
126    }
127    Ok(())
128}
129
130/// DoubleML's aggregation over repetitions: median coefficient, and the
131/// standard error implied by the median of the upper bounds `θ_r + 1.96·se_r`.
132pub(crate) fn aggregate(
133    all_coef: &Array2<f64>,
134    all_se: &Array2<f64>,
135) -> (Array1<f64>, Array1<f64>) {
136    const CRIT: f64 = 1.96;
137    let k = all_coef.nrows();
138    let mut coef = Array1::zeros(k);
139    let mut se = Array1::zeros(k);
140    for j in 0..k {
141        let c = all_coef.row(j);
142        coef[j] = median(&c.to_vec());
143        let upper: Vec<f64> = c
144            .iter()
145            .zip(all_se.row(j))
146            .map(|(&t, &s)| t + CRIT * s)
147            .collect();
148        se[j] = (median(&upper) - coef[j]) / CRIT;
149    }
150    (coef, se)
151}
152
153/// Multiple-testing adjustment for [`DmlFit::p_adjust`].
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum PAdjust {
156    Bonferroni,
157    /// Holm's step-down procedure.
158    Holm,
159}
160
161/// A fitted DML model: estimates, standard errors and the per-repetition
162/// scores and nuisance predictions behind them.
163///
164/// Arrays indexed by treatment have length `k = n_treat`; per-repetition
165/// arrays have shape `(k, n_rep)`.
166#[derive(Debug, Clone)]
167pub struct DmlFit {
168    pub(crate) model: String,
169    pub(crate) treatment_names: Vec<String>,
170    pub(crate) n_obs: usize,
171    pub(crate) n_folds: usize,
172    pub(crate) coef: Array1<f64>,
173    pub(crate) se: Array1<f64>,
174    pub(crate) all_coef: Array2<f64>,
175    pub(crate) all_se: Array2<f64>,
176    /// Shape `(n, k, n_rep)`.
177    pub(crate) psi: Array3<f64>,
178    /// Score elements, shape `(n, k, n_rep)`.
179    pub(crate) psi_a: Array3<f64>,
180    pub(crate) psi_b: Array3<f64>,
181    /// `J = mean(ψ_a)`, shape `(k, n_rep)`.
182    pub(crate) psi_deriv: Array2<f64>,
183    /// Each array has shape `(n, n_rep, k)`.
184    pub(crate) predictions: Vec<(String, Array3<f64>)>,
185}
186
187impl DmlFit {
188    pub fn model_name(&self) -> &str {
189        &self.model
190    }
191
192    pub fn treatment_names(&self) -> &[String] {
193        &self.treatment_names
194    }
195
196    pub fn n_obs(&self) -> usize {
197        self.n_obs
198    }
199
200    pub fn n_treat(&self) -> usize {
201        self.coef.len()
202    }
203
204    pub fn n_rep(&self) -> usize {
205        self.all_coef.ncols()
206    }
207
208    pub fn n_folds(&self) -> usize {
209        self.n_folds
210    }
211
212    /// Point estimates θ̂ (median over repetitions).
213    pub fn coef(&self) -> ArrayView1<'_, f64> {
214        self.coef.view()
215    }
216
217    /// Standard errors (aggregated over repetitions).
218    pub fn se(&self) -> ArrayView1<'_, f64> {
219        self.se.view()
220    }
221
222    pub fn t_stat(&self) -> Array1<f64> {
223        &self.coef / &self.se
224    }
225
226    /// Two-sided p-values for `H0: θ = 0` from the aggregated t-statistic,
227    /// as DoubleML's `pval`. (Multiple-testing adjustments in
228    /// [`p_adjust`](Self::p_adjust) instead take the median over repetitions.)
229    pub fn p_value(&self) -> Array1<f64> {
230        self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
231    }
232
233    fn per_rep_pvals(&self) -> Array2<f64> {
234        let mut p = &self.all_coef / &self.all_se;
235        p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
236        p
237    }
238
239    /// Pointwise Wald confidence intervals at `level`, shape `(k, 2)`
240    /// (median over repetitions of each repetition's interval).
241    pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
242        check_level(level)?;
243        let z = norm_ppf(0.5 + level / 2.0);
244        Ok(confint_from(
245            &self.all_coef,
246            &self.all_se,
247            &Array1::from_elem(self.n_rep(), z),
248        ))
249    }
250
251    /// Multiple-testing adjusted p-values across treatments (applied per
252    /// repetition, then the median is taken). For the bootstrap-based
253    /// Romano–Wolf procedure see [`Bootstrap::romano_wolf`](crate::Bootstrap::romano_wolf).
254    pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
255        let raw = self.per_rep_pvals();
256        let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
257        for (r, col) in raw.columns().into_iter().enumerate() {
258            let adj = match method {
259                PAdjust::Bonferroni => {
260                    let m = col.len() as f64;
261                    col.mapv(|p| (m * p).min(1.0))
262                }
263                PAdjust::Holm => holm(col),
264            };
265            adjusted.column_mut(r).assign(&adj);
266        }
267        adjusted
268            .rows()
269            .into_iter()
270            .map(|row| median(&row.to_vec()))
271            .collect()
272    }
273
274    /// Estimates for every repetition, shape `(k, n_rep)`.
275    pub fn all_coef(&self) -> ArrayView2<'_, f64> {
276        self.all_coef.view()
277    }
278
279    pub fn all_se(&self) -> ArrayView2<'_, f64> {
280        self.all_se.view()
281    }
282
283    /// Score values ψ at θ̂, shape `(n, k, n_rep)`.
284    pub fn psi(&self) -> ArrayView3<'_, f64> {
285        self.psi.view()
286    }
287
288    /// Score elements `ψ_a` and `ψ_b` (ψ = ψ_a·θ + ψ_b), shape `(n, k, n_rep)`.
289    pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
290        (self.psi_a.view(), self.psi_b.view())
291    }
292
293    /// Best linear predictor of the CATE on `basis` (shape `(n, p)`, include a
294    /// constant column for an intercept), using the orthogonal signal `ψ_b`.
295    /// Valid for scores with `ψ_a = −1`, i.e. [`Irm`](crate::Irm) with the ATE score.
296    pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
297        Blp::fit(self.orthogonal_signal()?.view(), basis)
298    }
299
300    /// Group average treatment effects for mutually exclusive groups given as
301    /// 0/1 dummy columns (shape `(n, n_groups)`).
302    pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
303        Blp::fit_gate(self.orthogonal_signal()?.view(), groups)
304    }
305
306    fn orthogonal_signal(&self) -> Result<Array2<f64>> {
307        if self.n_treat() != 1 || self.psi_a.iter().any(|&a| a != -1.0) {
308            return Err(DmlError::InvalidArgument(
309                "CATE / GATE need a single treatment and a score with psi_a = -1 (IRM with the ATE score)"
310                    .into(),
311            ));
312        }
313        Ok(self.psi_b.index_axis(Axis(1), 0).to_owned())
314    }
315
316    /// Names of the stored nuisance predictions (e.g. `ml_l`, `ml_m`).
317    pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
318        self.predictions.iter().map(|(n, _)| n.as_str())
319    }
320
321    /// All predictions of one nuisance, shape `(n, n_rep, k)`.
322    pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
323        self.predictions
324            .iter()
325            .find(|(n, _)| n == name)
326            .map(|(_, p)| p.view())
327    }
328
329    /// Out-of-fold predictions of one nuisance for a repetition and treatment.
330    pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
331        let p = self.predictions(name)?;
332        (rep < self.n_rep() && treatment < self.n_treat())
333            .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
334    }
335}
336
337/// Per-repetition intervals `θ_r ± crit_r · se_r`, aggregated by the median
338/// over repetitions (DoubleML's convention). Shape `(k, 2)`.
339pub(crate) fn confint_from(
340    all_coef: &Array2<f64>,
341    all_se: &Array2<f64>,
342    crit: &Array1<f64>,
343) -> Array2<f64> {
344    let (k, n_rep) = all_coef.dim();
345    let mut ci = Array2::zeros((k, 2));
346    for j in 0..k {
347        let lo: Vec<f64> = (0..n_rep)
348            .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
349            .collect();
350        let hi: Vec<f64> = (0..n_rep)
351            .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
352            .collect();
353        ci[[j, 0]] = median(&lo);
354        ci[[j, 1]] = median(&hi);
355    }
356    ci
357}
358
359pub(crate) fn check_level(level: f64) -> Result<()> {
360    if level > 0.0 && level < 1.0 {
361        Ok(())
362    } else {
363        Err(DmlError::InvalidArgument(
364            "confidence level must be in (0, 1)".into(),
365        ))
366    }
367}
368
369/// Holm step-down adjustment (as `statsmodels.multipletests(method="holm")`).
370fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
371    let m = p.len();
372    let mut order: Vec<usize> = (0..m).collect();
373    order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
374    let mut out = Array1::zeros(m);
375    let mut running = 0.0_f64;
376    for (rank, &i) in order.iter().enumerate() {
377        running = running.max(((m - rank) as f64 * p[i]).min(1.0));
378        out[i] = running;
379    }
380    out
381}
382
383impl fmt::Display for DmlFit {
384    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385        writeln!(
386            f,
387            "{}  |  n_obs = {}, n_folds = {}, n_rep = {}",
388            self.model,
389            self.n_obs,
390            self.n_folds,
391            self.n_rep()
392        )?;
393        let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
394        let (t, p) = (self.t_stat(), self.p_value());
395        let width = self
396            .treatment_names
397            .iter()
398            .map(String::len)
399            .max()
400            .unwrap_or(1)
401            .max(4);
402        write!(
403            f,
404            "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
405            "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
406        )?;
407        for j in 0..self.n_treat() {
408            write!(
409                f,
410                "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
411                self.treatment_names[j],
412                self.coef[j],
413                self.se[j],
414                t[j],
415                p[j],
416                ci[[j, 0]],
417                ci[[j, 1]]
418            )?;
419        }
420        Ok(())
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use ndarray::array;
428
429    #[test]
430    fn aggregation_matches_doubleml_definition() {
431        let coef = array![[1.0, 2.0, 4.0]];
432        let se = array![[0.5, 0.1, 0.2]];
433        let (c, s) = aggregate(&coef, &se);
434        assert_eq!(c[0], 2.0);
435        // upper bounds: 1.98, 2.196, 4.392 → median 2.196
436        assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
437    }
438
439    #[test]
440    fn holm_matches_statsmodels() {
441        // statsmodels.multipletests([0.01, 0.04, 0.03], method="holm")[1]
442        let adj = holm(array![0.01, 0.04, 0.03].view());
443        assert_eq!(adj, array![0.03, 0.06, 0.06]);
444    }
445}