1use ndarray::Array1;
9
10use super::{dml_model_api, LinearScoreModel, ScoreEstimate, ScoreInput};
11use crate::crossfit::{cross_fit_predict, Fold, SampleSplitting};
12use crate::error::{DmlError, Result};
13use crate::learner::Regressor;
14use crate::score::LinearScore;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum PlrScore {
19 #[default]
21 PartiallingOut,
22 IvType,
25}
26
27pub struct Plr {
30 ml_l: Box<dyn Regressor>,
31 ml_m: Box<dyn Regressor>,
32 ml_g: Option<Box<dyn Regressor>>,
33 score: PlrScore,
34 splitting: SampleSplitting,
35}
36
37impl Plr {
38 pub fn new(ml_l: impl Regressor + 'static, ml_m: impl Regressor + 'static) -> Self {
39 Self::from_boxed(Box::new(ml_l), Box::new(ml_m))
40 }
41
42 pub fn from_boxed(ml_l: Box<dyn Regressor>, ml_m: Box<dyn Regressor>) -> Self {
44 Self {
45 ml_l,
46 ml_m,
47 ml_g: None,
48 score: PlrScore::default(),
49 splitting: SampleSplitting::default(),
50 }
51 }
52
53 pub fn score(mut self, score: PlrScore) -> Self {
54 self.score = score;
55 self
56 }
57
58 pub fn ml_g(mut self, ml_g: impl Regressor + 'static) -> Self {
60 self.ml_g = Some(Box::new(ml_g));
61 self
62 }
63
64 pub fn ml_g_boxed(mut self, ml_g: Box<dyn Regressor>) -> Self {
65 self.ml_g = Some(ml_g);
66 self
67 }
68
69 dml_model_api!();
70}
71
72impl LinearScoreModel for Plr {
73 fn name(&self) -> String {
74 match self.score {
75 PlrScore::PartiallingOut => "PLR (partialling out)".into(),
76 PlrScore::IvType => "PLR (IV-type)".into(),
77 }
78 }
79
80 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
81 let ScoreInput { y, d, x, .. } = *input;
82 let l_hat = cross_fit_predict(self.ml_l.as_ref(), x, y, folds)?;
83 let m_hat = cross_fit_predict(self.ml_m.as_ref(), x, d, folds)?;
84 let v: Array1<f64> = &d - &m_hat;
85 if v.dot(&v) <= f64::EPSILON * d.dot(&d).max(1.0) {
86 return Err(DmlError::InvalidArgument(
87 "treatment residuals are zero: D is fully explained by X".into(),
88 ));
89 }
90 let u: Array1<f64> = &y - &l_hat;
91
92 match self.score {
93 PlrScore::PartiallingOut => Ok(ScoreEstimate {
94 score: LinearScore::new(-(&v * &v), &v * &u)?,
95 predictions: vec![("ml_l", l_hat), ("ml_m", m_hat)],
96 }),
97 PlrScore::IvType => {
98 let theta_init = u.dot(&v) / v.dot(&v);
99 let target: Array1<f64> = &y - &(theta_init * &d);
100 let fresh_l;
101 let ml_g = match &self.ml_g {
102 Some(g) => g.as_ref(),
103 None => {
104 fresh_l = self.ml_l.fresh();
105 fresh_l.as_ref()
106 }
107 };
108 let g_hat = cross_fit_predict(ml_g, x, target.view(), folds)?;
109 let resid_g: Array1<f64> = &y - &g_hat;
110 Ok(ScoreEstimate {
111 score: LinearScore::new(-(&v * &d), &v * &resid_g)?,
112 predictions: vec![("ml_l", l_hat), ("ml_m", m_hat), ("ml_g", g_hat)],
113 })
114 }
115 }
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::data::DmlData;
123 use crate::learner::Ols;
124 use ndarray::{Array1, Array2};
125
126 fn toy_data() -> DmlData {
127 let n = 60;
128 let x = Array2::from_shape_fn((n, 2), |(i, j)| ((i * 7 + j * 3) % 11) as f64);
129 let d: Array1<f64> = (0..n)
130 .map(|i| 0.3 * x[[i, 0]] + ((i * 13) % 5) as f64 - 2.0)
131 .collect();
132 let y: Array1<f64> = (0..n)
133 .map(|i| 0.5 * d[i] + x[[i, 1]] + ((i * 17) % 3) as f64 - 1.0)
134 .collect();
135 DmlData::new(y, d, x).unwrap()
136 }
137
138 #[test]
139 fn coef_matches_residual_formula() {
140 let data = toy_data();
141 let fit = Plr::new(Ols::new(), Ols::new()).seed(3).fit(&data).unwrap();
142 let u = &data.y() - &fit.prediction("ml_l", 0, 0).unwrap();
143 let v = &data.treatment(0) - &fit.prediction("ml_m", 0, 0).unwrap();
144 assert!((fit.coef()[0] - u.dot(&v) / v.dot(&v)).abs() < 1e-12);
145 assert!(fit.psi().sum().abs() < 1e-10);
147 }
148
149 #[test]
150 fn iv_type_with_linear_learners_is_close_to_partialling_out() {
151 let data = toy_data();
152 let po = Plr::new(Ols::new(), Ols::new()).fit(&data).unwrap();
153 let iv = Plr::new(Ols::new(), Ols::new())
154 .score(PlrScore::IvType)
155 .fit(&data)
156 .unwrap();
157 assert!(iv.prediction("ml_g", 0, 0).is_some());
158 assert!((po.coef()[0] - iv.coef()[0]).abs() < 0.05);
159 }
160
161 #[test]
162 fn rejects_treatment_explained_by_x() {
163 let x = Array2::from_shape_fn((20, 1), |(i, _)| i as f64);
164 let d = x.column(0).mapv(|v| 2.0 * v + 1.0);
165 let y = d.mapv(|v| v * 0.5);
166 let data = DmlData::new(y, d, x).unwrap();
167 assert!(Plr::new(Ols::new(), Ols::new()).fit(&data).is_err());
168 }
169}