Skip to main content

dml_core/model/
mod.rs

1//! DML models with linear scores: [`Plr`], [`Irm`], [`Pliv`], and the
2//! [`LinearScoreModel`] trait for adding your own.
3
4use ndarray::{Array1, ArrayView1, ArrayView2};
5
6use crate::crossfit::Fold;
7use crate::data::DmlData;
8use crate::error::Result;
9use crate::score::LinearScore;
10use crate::sensitivity::SensitivityParts;
11
12pub(crate) mod did;
13mod iivm;
14pub(crate) mod irm;
15mod pliv;
16mod plr;
17
18pub use did::{Did, DidCs, DidScore};
19pub use iivm::Iivm;
20pub use irm::{Irm, IrmScore};
21pub use pliv::Pliv;
22pub use plr::{Plr, PlrScore};
23
24/// The data a model sees when estimating the effect of one treatment.
25///
26/// `#[non_exhaustive]`: fields may be added in future versions, so code outside
27/// this crate reads the fields but cannot construct the struct.
28#[derive(Debug, Clone, Copy)]
29#[non_exhaustive]
30pub struct ScoreInput<'a> {
31    pub y: ArrayView1<'a, f64>,
32    /// The treatment being estimated.
33    pub d: ArrayView1<'a, f64>,
34    /// Covariates, including any other treatments.
35    pub x: ArrayView2<'a, f64>,
36    pub z: Option<ArrayView1<'a, f64>>,
37    /// 0/1 time-period indicator (repeated cross-sections).
38    pub t: Option<ArrayView1<'a, f64>>,
39}
40
41/// Score elements plus the cross-fitted nuisance predictions behind them.
42///
43/// Build it with [`ScoreEstimate::new`] (the struct is `#[non_exhaustive]`, so
44/// fields can be added without breaking custom models).
45#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub struct ScoreEstimate {
48    pub score: LinearScore,
49    /// `(name, out-of-fold predictions)` — e.g. `("ml_l", ℓ̂)`.
50    pub predictions: Vec<(&'static str, Array1<f64>)>,
51    /// Ingredients for [sensitivity analysis](crate::sensitivity), if the
52    /// model supports it.
53    pub sensitivity: Option<SensitivityParts>,
54    /// What each nuisance learner predicts, for out-of-sample diagnostics.
55    pub targets: Vec<NuisanceTarget>,
56}
57
58/// Loss used to evaluate a nuisance learner out of sample.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LossKind {
61    /// Root mean squared error (regression learners).
62    Rmse,
63    /// Log loss with predictions clipped to `[1e-15, 1 − 1e-15]` (classifiers).
64    LogLoss,
65}
66
67/// The target a nuisance learner predicts; `NaN` marks rows where the
68/// nuisance does not apply (e.g. `g₀` on treated rows).
69#[derive(Debug, Clone)]
70pub struct NuisanceTarget {
71    pub name: &'static str,
72    pub target: Array1<f64>,
73    pub kind: LossKind,
74}
75
76impl NuisanceTarget {
77    pub fn regression(name: &'static str, target: Array1<f64>) -> Self {
78        Self {
79            name,
80            target,
81            kind: LossKind::Rmse,
82        }
83    }
84
85    pub fn classification(name: &'static str, target: Array1<f64>) -> Self {
86        Self {
87            name,
88            target,
89            kind: LossKind::LogLoss,
90        }
91    }
92
93    /// Keep the target only where `keep` is true (`NaN` elsewhere).
94    pub fn only_where(mut self, keep: impl Fn(usize) -> bool) -> Self {
95        for (i, v) in self.target.iter_mut().enumerate() {
96            if !keep(i) {
97                *v = f64::NAN;
98            }
99        }
100        self
101    }
102
103    /// Out-of-sample loss of `prediction` over the rows with a target.
104    pub fn loss(&self, prediction: &Array1<f64>) -> f64 {
105        let mut total = 0.0;
106        let mut count = 0usize;
107        for (&t, &p) in self.target.iter().zip(prediction) {
108            if t.is_nan() {
109                continue;
110            }
111            total += match self.kind {
112                LossKind::Rmse => (t - p).powi(2),
113                LossKind::LogLoss => {
114                    let p = p.clamp(1e-15, 1.0 - 1e-15);
115                    -(t * p.ln() + (1.0 - t) * (1.0 - p).ln())
116                }
117            };
118            count += 1;
119        }
120        if count == 0 {
121            return f64::NAN;
122        }
123        let mean = total / count as f64;
124        match self.kind {
125            LossKind::Rmse => mean.sqrt(),
126            LossKind::LogLoss => mean,
127        }
128    }
129}
130
131impl ScoreEstimate {
132    pub fn new(score: LinearScore, predictions: Vec<(&'static str, Array1<f64>)>) -> Self {
133        Self {
134            score,
135            predictions,
136            sensitivity: None,
137            targets: Vec::new(),
138        }
139    }
140
141    /// Attach the nuisance targets used for learner diagnostics.
142    pub fn with_targets(mut self, targets: Vec<NuisanceTarget>) -> Self {
143        self.targets = targets;
144        self
145    }
146
147    /// Attach the model's sensitivity ingredients.
148    pub fn with_sensitivity(mut self, parts: SensitivityParts) -> Self {
149        self.sensitivity = Some(parts);
150        self
151    }
152}
153
154/// A DML model whose orthogonal score is linear in θ.
155///
156/// Implement this to plug a new score into the shared machinery (repeated
157/// cross-fitting, aggregation, inference, bootstrap) via [`fit_model`](crate::fit_model).
158pub trait LinearScoreModel {
159    /// Label for summaries, e.g. `"PLR (partialling out)"`.
160    fn name(&self) -> String;
161
162    /// Reject data the model cannot handle (called once before fitting).
163    fn validate(&self, _data: &DmlData) -> Result<()> {
164        Ok(())
165    }
166
167    /// Cross-fit the nuisances on `folds` and return the score elements.
168    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate>;
169}
170
171/// Builder methods shared by every model: sample-splitting settings and `fit`.
172///
173/// (`macro_rules!` generates code at compile time; here it pastes the same
174/// methods into each model's `impl` block instead of repeating them.)
175macro_rules! dml_model_api {
176    () => {
177        /// Number of cross-fitting folds (default 5).
178        pub fn n_folds(mut self, n_folds: usize) -> Self {
179            self.splitting.n_folds = n_folds;
180            self
181        }
182
183        /// Number of independent sample splits (default 1). Estimates are
184        /// aggregated across repetitions by the median, as in DoubleML.
185        pub fn n_rep(mut self, n_rep: usize) -> Self {
186            self.splitting.n_rep = n_rep;
187            self
188        }
189
190        /// Seed for the sample splits (default 0).
191        pub fn seed(mut self, seed: u64) -> Self {
192            self.splitting.seed = seed;
193            self
194        }
195
196        /// Draw seeded sample splits and fit. With clustered data
197        /// ([`DmlData::with_clusters`](crate::DmlData::with_clusters)) whole clusters are assigned to folds
198        /// and standard errors are cluster-robust.
199        pub fn fit(&self, data: &$crate::DmlData) -> $crate::Result<$crate::DmlFit> {
200            match data.clusters() {
201                Some(clusters) => {
202                    let splits = self.splitting.draw_clustered(clusters)?;
203                    $crate::fit_model_clustered(self, data, &splits)
204                }
205                None => {
206                    let splits = self.splitting.draw(data.n_obs())?;
207                    $crate::fit_model(self, data, &splits)
208                }
209            }
210        }
211
212        /// Fit clustered data with explicit cluster folds, one
213        /// `Vec<ClusterFold>` per repetition.
214        pub fn fit_with_cluster_folds(
215            &self,
216            data: &$crate::DmlData,
217            splits: &[Vec<$crate::ClusterFold>],
218        ) -> $crate::Result<$crate::DmlFit> {
219            $crate::fit_model_clustered(self, data, splits)
220        }
221
222        /// Sensitivity benchmark for the covariate columns `benchmarking_set`:
223        /// refits this model without them (with the same sample splitting, so
224        /// `long` must come from [`fit`](Self::fit) with the current settings)
225        /// and compares it with `long`.
226        pub fn sensitivity_benchmark(
227            &self,
228            data: &$crate::DmlData,
229            long: &$crate::DmlFit,
230            benchmarking_set: &[usize],
231        ) -> $crate::Result<$crate::SensitivityBenchmark> {
232            let short = self.fit(&data.without_covariates(benchmarking_set)?)?;
233            long.sensitivity_benchmark(&short)
234        }
235
236        /// Fit with caller-supplied splits, one `Vec<Fold>` per repetition
237        /// (e.g. to reproduce another implementation exactly).
238        pub fn fit_with_folds(
239            &self,
240            data: &$crate::DmlData,
241            splits: &[Vec<$crate::Fold>],
242        ) -> $crate::Result<$crate::DmlFit> {
243            $crate::fit_model(self, data, splits)
244        }
245    };
246}
247pub(crate) use dml_model_api;