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