Skip to main content

dml_core/model/
plr.rs

1//! Partially linear regression (PLR):
2//!
3//! ```text
4//! Y = θ·D + g(X) + ε,   E[ε | D, X] = 0
5//! D = m(X) + v,         E[v | X] = 0
6//! ```
7
8use ndarray::Array1;
9
10use super::{
11    dml_model_api, CateSignal, LinearScoreModel, NuisanceTarget, ScoreEstimate, ScoreInput,
12};
13use crate::crossfit::{cross_fit_predict, Fold, SampleSplitting};
14use crate::error::{DmlError, Result};
15use crate::learner::Regressor;
16use crate::score::LinearScore;
17use crate::sensitivity::{Nu2Parts, SensitivityParts};
18
19/// Orthogonal score used by [`Plr`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum PlrScore {
22    /// `ψ = (Y − ℓ(X) − θ(D − m(X)))·(D − m(X))` with `ℓ(X) = E[Y | X]`.
23    #[default]
24    PartiallingOut,
25    /// `ψ = (Y − θD − g(X))·(D − m(X))`, with `g` fitted on `Y − θ̃D` for a
26    /// preliminary partialling-out estimate `θ̃`.
27    IvType,
28}
29
30/// PLR estimator: `ml_l` learns `E[Y | X]`, `ml_m` learns `E[D | X]`, and
31/// the optional `ml_g` learns `g(X)` for the IV-type score.
32pub struct Plr {
33    ml_l: Box<dyn Regressor>,
34    ml_m: Box<dyn Regressor>,
35    ml_g: Option<Box<dyn Regressor>>,
36    score: PlrScore,
37    splitting: SampleSplitting,
38}
39
40impl Plr {
41    pub fn new(ml_l: impl Regressor + 'static, ml_m: impl Regressor + 'static) -> Self {
42        Self::from_boxed(Box::new(ml_l), Box::new(ml_m))
43    }
44
45    /// Like [`Plr::new`] for learners that are already boxed trait objects.
46    pub fn from_boxed(ml_l: Box<dyn Regressor>, ml_m: Box<dyn Regressor>) -> Self {
47        Self {
48            ml_l,
49            ml_m,
50            ml_g: None,
51            score: PlrScore::default(),
52            splitting: SampleSplitting::default(),
53        }
54    }
55
56    pub fn score(mut self, score: PlrScore) -> Self {
57        self.score = score;
58        self
59    }
60
61    /// Learner for `g(X)` in the IV-type score (default: a fresh copy of `ml_l`).
62    pub fn ml_g(mut self, ml_g: impl Regressor + 'static) -> Self {
63        self.ml_g = Some(Box::new(ml_g));
64        self
65    }
66
67    pub fn ml_g_boxed(mut self, ml_g: Box<dyn Regressor>) -> Self {
68        self.ml_g = Some(ml_g);
69        self
70    }
71
72    dml_model_api!();
73}
74
75impl LinearScoreModel for Plr {
76    fn name(&self) -> String {
77        match self.score {
78            PlrScore::PartiallingOut => "PLR (partialling out)".into(),
79            PlrScore::IvType => "PLR (IV-type)".into(),
80        }
81    }
82
83    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
84        let ScoreInput { y, d, x, .. } = *input;
85        let l_hat = input.predictions_or("ml_l", || {
86            cross_fit_predict(self.ml_l.as_ref(), x, y, folds)
87        })?;
88        let m_hat = input.predictions_or("ml_m", || {
89            cross_fit_predict(self.ml_m.as_ref(), x, d, folds)
90        })?;
91        let v: Array1<f64> = &d - &m_hat;
92        if v.dot(&v) <= f64::EPSILON * d.dot(&d).max(1.0) {
93            return Err(DmlError::InvalidArgument(
94                "treatment residuals are zero: D is fully explained by X".into(),
95            ));
96        }
97        let u: Array1<f64> = &y - &l_hat;
98
99        match self.score {
100            PlrScore::PartiallingOut => {
101                // Outcome residual y − ℓ̂ − θ(d − m̂); ν² = 1 / mean((d − m̂)²).
102                let parts = SensitivityParts {
103                    outcome_residual: u.clone(),
104                    theta_factor: Some(v.clone()),
105                    riesz: Array1::zeros(v.len()),
106                    nu2: Nu2Parts::InverseMeanSquare(v.clone()),
107                };
108                Ok(ScoreEstimate::new(
109                    LinearScore::new(-(&v * &v), &v * &u)?,
110                    vec![("ml_l", l_hat), ("ml_m", m_hat)],
111                )
112                .with_sensitivity(parts)
113                .with_targets(vec![
114                    NuisanceTarget::regression("ml_l", y.to_owned()),
115                    NuisanceTarget::regression("ml_m", d.to_owned()),
116                ])
117                // CATE: regress y − ℓ̂ on the basis scaled by d − m̂ (as DoubleML).
118                .with_cate_signal(CateSignal {
119                    signal: u,
120                    theta_factor: None,
121                    basis_scale: Some(v),
122                    is_effect: false,
123                }))
124            }
125            PlrScore::IvType => {
126                let theta_init = u.dot(&v) / v.dot(&v);
127                let target: Array1<f64> = &y - &(theta_init * &d);
128                let fresh_l;
129                let ml_g = match &self.ml_g {
130                    Some(g) => g.as_ref(),
131                    None => {
132                        fresh_l = self.ml_l.fresh();
133                        fresh_l.as_ref()
134                    }
135                };
136                let g_hat = input
137                    .predictions_or("ml_g", || cross_fit_predict(ml_g, x, target.view(), folds))?;
138                let resid_g: Array1<f64> = &y - &g_hat;
139                // Outcome residual y − ĝ − θd.
140                let parts = SensitivityParts {
141                    outcome_residual: resid_g.clone(),
142                    theta_factor: Some(d.to_owned()),
143                    riesz: Array1::zeros(v.len()),
144                    nu2: Nu2Parts::InverseMeanSquare(v.clone()),
145                };
146                // CATE: regress y − ĝ − θ·m̂ on the basis scaled by d − m̂ (as DoubleML).
147                let cate = CateSignal {
148                    signal: resid_g.clone(),
149                    theta_factor: Some(m_hat.clone()),
150                    basis_scale: Some(v.clone()),
151                    is_effect: false,
152                };
153                Ok(ScoreEstimate::new(
154                    LinearScore::new(-(&v * &d), &v * &resid_g)?,
155                    vec![("ml_l", l_hat), ("ml_m", m_hat), ("ml_g", g_hat)],
156                )
157                .with_sensitivity(parts)
158                .with_targets(vec![
159                    NuisanceTarget::regression("ml_l", y.to_owned()),
160                    NuisanceTarget::regression("ml_m", d.to_owned()),
161                    NuisanceTarget::regression("ml_g", target),
162                ])
163                .with_cate_signal(cate))
164            }
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::data::DmlData;
173    use crate::learner::Ols;
174    use ndarray::{Array1, Array2};
175
176    fn toy_data() -> DmlData {
177        let n = 60;
178        let x = Array2::from_shape_fn((n, 2), |(i, j)| ((i * 7 + j * 3) % 11) as f64);
179        let d: Array1<f64> = (0..n)
180            .map(|i| 0.3 * x[[i, 0]] + ((i * 13) % 5) as f64 - 2.0)
181            .collect();
182        let y: Array1<f64> = (0..n)
183            .map(|i| 0.5 * d[i] + x[[i, 1]] + ((i * 17) % 3) as f64 - 1.0)
184            .collect();
185        DmlData::new(y, d, x).unwrap()
186    }
187
188    #[test]
189    fn coef_matches_residual_formula() {
190        let data = toy_data();
191        let fit = Plr::new(Ols::new(), Ols::new()).seed(3).fit(&data).unwrap();
192        let u = &data.y() - &fit.prediction("ml_l", 0, 0).unwrap();
193        let v = &data.treatment(0) - &fit.prediction("ml_m", 0, 0).unwrap();
194        assert!((fit.coef()[0] - u.dot(&v) / v.dot(&v)).abs() < 1e-12);
195        // The score is zero on average at the DML2 solution.
196        assert!(fit.psi().sum().abs() < 1e-10);
197    }
198
199    #[test]
200    fn iv_type_with_linear_learners_is_close_to_partialling_out() {
201        let data = toy_data();
202        let po = Plr::new(Ols::new(), Ols::new()).fit(&data).unwrap();
203        let iv = Plr::new(Ols::new(), Ols::new())
204            .score(PlrScore::IvType)
205            .fit(&data)
206            .unwrap();
207        assert!(iv.prediction("ml_g", 0, 0).is_some());
208        assert!((po.coef()[0] - iv.coef()[0]).abs() < 0.05);
209    }
210
211    #[test]
212    fn rejects_treatment_explained_by_x() {
213        let x = Array2::from_shape_fn((20, 1), |(i, _)| i as f64);
214        let d = x.column(0).mapv(|v| 2.0 * v + 1.0);
215        let y = d.mapv(|v| v * 0.5);
216        let data = DmlData::new(y, d, x).unwrap();
217        assert!(Plr::new(Ols::new(), Ols::new()).fit(&data).is_err());
218    }
219}