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