1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum PlrScore {
22 #[default]
24 PartiallingOut,
25 IvType,
28}
29
30pub 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 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 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 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 .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 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 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 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}