1use ndarray::{concatenate, Array1, Array2, Axis};
13
14use super::{dml_model_api, LinearScoreModel, Name, NuisanceTarget, ScoreEstimate, ScoreInput};
15use crate::compat::np_mean;
16use crate::crossfit::{cross_fit_predict, Fold, SampleSplitting};
17use crate::data::{gather, DmlData};
18use crate::error::{DmlError, Result};
19use crate::learner::{Ols, Regressor};
20use crate::score::LinearScore;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum PlivScore {
25 #[default]
27 PartiallingOut,
28 IvType,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum PlivVariant {
35 #[default]
40 PartialX,
41 PartialZ,
43 PartialXZ,
46}
47
48pub struct Pliv {
50 ml_l: Option<Box<dyn Regressor>>,
51 ml_m: Option<Box<dyn Regressor>>,
52 ml_r: Box<dyn Regressor>,
53 ml_g: Option<Box<dyn Regressor>>,
54 score: PlivScore,
55 variant: PlivVariant,
56 splitting: SampleSplitting,
57}
58
59impl Pliv {
60 pub fn new(
63 ml_l: impl Regressor + 'static,
64 ml_m: impl Regressor + 'static,
65 ml_r: impl Regressor + 'static,
66 ) -> Self {
67 Self::from_boxed(Box::new(ml_l), Box::new(ml_m), Box::new(ml_r))
68 }
69
70 pub fn from_boxed(
71 ml_l: Box<dyn Regressor>,
72 ml_m: Box<dyn Regressor>,
73 ml_r: Box<dyn Regressor>,
74 ) -> Self {
75 Self {
76 ml_l: Some(ml_l),
77 ml_m: Some(ml_m),
78 ml_r,
79 ml_g: None,
80 score: PlivScore::default(),
81 variant: PlivVariant::PartialX,
82 splitting: SampleSplitting::default(),
83 }
84 }
85
86 pub fn partial_z(ml_r: impl Regressor + 'static) -> Self {
88 Self::partial_z_boxed(Box::new(ml_r))
89 }
90
91 pub fn partial_z_boxed(ml_r: Box<dyn Regressor>) -> Self {
92 Self {
93 ml_l: None,
94 ml_m: None,
95 ml_r,
96 ml_g: None,
97 score: PlivScore::default(),
98 variant: PlivVariant::PartialZ,
99 splitting: SampleSplitting::default(),
100 }
101 }
102
103 pub fn partial_xz(
107 ml_l: impl Regressor + 'static,
108 ml_m: impl Regressor + 'static,
109 ml_r: impl Regressor + 'static,
110 ) -> Self {
111 Self::from_boxed(Box::new(ml_l), Box::new(ml_m), Box::new(ml_r))
112 .variant(PlivVariant::PartialXZ)
113 }
114
115 pub fn variant(mut self, variant: PlivVariant) -> Self {
118 self.variant = variant;
119 self
120 }
121
122 pub fn score(mut self, score: PlivScore) -> Self {
123 self.score = score;
124 self
125 }
126
127 pub fn ml_g(self, ml_g: impl Regressor + 'static) -> Self {
129 self.ml_g_boxed(Box::new(ml_g))
130 }
131
132 pub fn ml_g_boxed(mut self, ml_g: Box<dyn Regressor>) -> Self {
133 self.ml_g = Some(ml_g);
134 self
135 }
136
137 dml_model_api!();
138
139 fn learner(&self, l: &Option<Box<dyn Regressor>>, name: &str) -> Result<Box<dyn Regressor>> {
140 l.as_ref().map(|l| l.fresh()).ok_or_else(|| {
141 DmlError::InvalidArgument(format!("this PLIV variant needs a learner {name}"))
142 })
143 }
144}
145
146impl LinearScoreModel for Pliv {
147 fn name(&self) -> String {
148 let variant = match self.variant {
149 PlivVariant::PartialX => "",
150 PlivVariant::PartialZ => ", partial Z",
151 PlivVariant::PartialXZ => ", partial XZ",
152 };
153 let score = match self.score {
154 PlivScore::PartiallingOut => "partialling out",
155 PlivScore::IvType => "IV-type",
156 };
157 format!("PLIV ({score}{variant})")
158 }
159
160 fn validate(&self, data: &DmlData) -> Result<()> {
161 let k = data.n_instruments();
162 if k == 0 {
163 return Err(DmlError::InvalidArgument(
164 "PLIV requires an instrument: use DmlData::with_instrument".into(),
165 ));
166 }
167 if self.score == PlivScore::IvType && (k > 1 || self.variant != PlivVariant::PartialX) {
168 return Err(DmlError::InvalidArgument(
169 "the IV-type score needs one instrument and the partial X variant".into(),
170 ));
171 }
172 Ok(())
173 }
174
175 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
176 let ScoreInput { y, d, x, .. } = *input;
177 let z = input
178 .instruments
179 .ok_or_else(|| DmlError::InvalidArgument("PLIV requires an instrument".into()))?;
180 match self.variant {
181 PlivVariant::PartialX => {
182 let ml_l = self.learner(&self.ml_l, "ml_l")?;
183 let ml_m = self.learner(&self.ml_m, "ml_m")?;
184 let l_hat = input
185 .predictions_or("ml_l", || cross_fit_predict(ml_l.as_ref(), x, y, folds))?;
186 let r_hat = input.predictions_or("ml_r", || {
187 cross_fit_predict(self.ml_r.as_ref(), x, d, folds)
188 })?;
189 let u: Array1<f64> = &y - &l_hat;
190 let w: Array1<f64> = &d - &r_hat;
191 let mut predictions: Vec<(Name, Array1<f64>)> = vec![("ml_l".into(), l_hat)];
192 let mut targets = vec![NuisanceTarget::regression("ml_l", y.to_owned())];
193 if z.ncols() == 1 {
194 let z = z.column(0);
195 let m_hat = input
196 .predictions_or("ml_m", || cross_fit_predict(ml_m.as_ref(), x, z, folds))?;
197 let v: Array1<f64> = &z - &m_hat;
198 predictions.push(("ml_m".into(), m_hat));
199 targets.push(NuisanceTarget::regression("ml_m", z.to_owned()));
200 let score = match self.score {
201 PlivScore::PartiallingOut => LinearScore::new(-(&w * &v), &v * &u)?,
202 PlivScore::IvType => {
203 let psi_a: Vec<f64> = w.iter().zip(&v).map(|(w, v)| -(w * v)).collect();
205 let psi_b: Vec<f64> = v.iter().zip(&u).map(|(v, u)| v * u).collect();
206 let theta_init = -np_mean(&psi_b) / np_mean(&psi_a);
207 let target: Array1<f64> = &y - &(theta_init * &d);
208 let ml_g = match &self.ml_g {
209 Some(g) => g.fresh(),
210 None => ml_l.fresh(),
211 };
212 let g_hat = input.predictions_or("ml_g", || {
213 cross_fit_predict(ml_g.as_ref(), x, target.view(), folds)
214 })?;
215 let score = LinearScore::new(-(&v * &d), &v * &(&y - &g_hat))?;
216 predictions.push(("ml_g".into(), g_hat));
217 targets.push(NuisanceTarget::regression("ml_g", target));
218 score
219 }
220 };
221 predictions.push(("ml_r".into(), r_hat.clone()));
222 targets.push(NuisanceTarget::regression("ml_r", d.to_owned()));
223 return Ok(ScoreEstimate::new(score, predictions).with_targets(targets));
224 }
225 let mut v = Array2::zeros(z.dim());
228 for (j, col) in z.columns().into_iter().enumerate() {
229 let name = format!("ml_m_z{}", j + 1);
230 let m_hat = input.predictions_or(&name, || {
231 cross_fit_predict(ml_m.fresh().as_ref(), x, col, folds)
232 })?;
233 v.column_mut(j).assign(&(&col - &m_hat));
234 targets.push(NuisanceTarget::regression(name.clone(), col.to_owned()));
235 predictions.push((name.into(), m_hat));
236 }
237 let mut proj = Ols::new();
238 proj.fit(v.view(), w.view())?;
239 let r_tilde = proj.predict(v.view())?;
240 predictions.push(("ml_r".into(), r_hat.clone()));
241 targets.push(NuisanceTarget::regression("ml_r", d.to_owned()));
242 Ok(ScoreEstimate::new(
243 LinearScore::new(-(&w * &r_tilde), &r_tilde * &u)?,
244 predictions,
245 )
246 .with_targets(targets))
247 }
248 PlivVariant::PartialZ => {
249 let xz = concatenate![Axis(1), x, z];
250 let r_hat = cross_fit_predict(self.ml_r.as_ref(), xz.view(), d, folds)?;
251 Ok(ScoreEstimate::new(
252 LinearScore::new(-(&r_hat * &d), &r_hat * &y)?,
253 vec![("ml_r", r_hat)],
254 )
255 .with_targets(vec![NuisanceTarget::regression("ml_r", d.to_owned())]))
256 }
257 PlivVariant::PartialXZ => {
258 let ml_l = self.learner(&self.ml_l, "ml_l")?;
259 let ml_m = self.learner(&self.ml_m, "ml_m")?;
260 let xz = concatenate![Axis(1), x, z];
261 let l_hat = cross_fit_predict(ml_l.as_ref(), x, y, folds)?;
262 let n = y.len();
265 let fold_fit = |fold: &Fold| -> Result<(Array1<f64>, Array1<f64>)> {
266 let mut m = ml_m.fresh();
267 let x_train = xz.select(Axis(0), &fold.train);
268 m.fit(x_train.view(), gather(d, &fold.train).view())?;
269 let train_pred = m.predict(x_train.view())?;
270 let test_pred = m.predict(xz.select(Axis(0), &fold.test).view())?;
271 let mut r = self.ml_r.fresh();
272 r.fit(x.select(Axis(0), &fold.train).view(), train_pred.view())?;
273 let tilde = r.predict(x.select(Axis(0), &fold.test).view())?;
274 Ok((test_pred, tilde))
275 };
276 let per_fold: Vec<(Array1<f64>, Array1<f64>)> =
277 folds.iter().map(fold_fit).collect::<Result<_>>()?;
278 let mut m_hat = Array1::from_elem(n, f64::NAN);
279 let mut m_tilde = Array1::from_elem(n, f64::NAN);
280 for (fold, (pred, tilde)) in folds.iter().zip(&per_fold) {
281 for (k, &i) in fold.test.iter().enumerate() {
282 m_hat[i] = pred[k];
283 m_tilde[i] = tilde[k];
284 }
285 }
286 let u: Array1<f64> = &y - &l_hat;
287 let w: Array1<f64> = &d - &m_tilde;
288 let instrument: Array1<f64> = &m_hat - &m_tilde;
289 Ok(ScoreEstimate::new(
290 LinearScore::new(-(&w * &instrument), &instrument * &u)?,
291 vec![("ml_l", l_hat), ("ml_m", m_hat), ("ml_r", m_tilde)],
292 )
293 .with_targets(vec![
294 NuisanceTarget::regression("ml_l", y.to_owned()),
295 NuisanceTarget::regression("ml_m", d.to_owned()),
296 ]))
297 }
298 }
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::learner::Ols;
306 use ndarray::array;
307
308 #[test]
309 fn requires_instrument() {
310 let data = DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
311 assert!(Pliv::new(Ols::new(), Ols::new(), Ols::new())
312 .fit(&data)
313 .is_err());
314 }
315}