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;