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;