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