1use ndarray::Array1;
15
16use super::irm::{ipw_residual, normalize_propensity};
17use super::{dml_model_api, LinearScoreModel, ScoreEstimate, ScoreInput};
18use crate::crossfit::{
19 cross_fit_predict_proba, cross_fit_predict_proba_subset, cross_fit_predict_subset, Fold,
20 SampleSplitting,
21};
22use crate::data::{is_binary, DmlData};
23use crate::error::{DmlError, Result};
24use crate::learner::{Classifier, Regressor};
25use crate::score::LinearScore;
26
27pub struct Iivm {
32 ml_g: Box<dyn Regressor>,
33 ml_m: Box<dyn Classifier>,
34 ml_r: Box<dyn Classifier>,
35 always_takers: bool,
36 never_takers: bool,
37 clipping_threshold: f64,
38 normalize_ipw: bool,
39 splitting: SampleSplitting,
40}
41
42impl Iivm {
43 pub fn new(
44 ml_g: impl Regressor + 'static,
45 ml_m: impl Classifier + 'static,
46 ml_r: impl Classifier + 'static,
47 ) -> Self {
48 Self::from_boxed(Box::new(ml_g), Box::new(ml_m), Box::new(ml_r))
49 }
50
51 pub fn from_boxed(
52 ml_g: Box<dyn Regressor>,
53 ml_m: Box<dyn Classifier>,
54 ml_r: Box<dyn Classifier>,
55 ) -> Self {
56 Self {
57 ml_g,
58 ml_m,
59 ml_r,
60 always_takers: true,
61 never_takers: true,
62 clipping_threshold: 1e-2,
63 normalize_ipw: false,
64 splitting: SampleSplitting::default(),
65 }
66 }
67
68 pub fn subgroups(mut self, always_takers: bool, never_takers: bool) -> Self {
74 self.always_takers = always_takers;
75 self.never_takers = never_takers;
76 self
77 }
78
79 pub fn clipping_threshold(mut self, t: f64) -> Self {
81 self.clipping_threshold = t;
82 self
83 }
84
85 pub fn normalize_ipw(mut self, normalize: bool) -> Self {
93 self.normalize_ipw = normalize;
94 self
95 }
96
97 dml_model_api!();
98}
99
100impl LinearScoreModel for Iivm {
101 fn name(&self) -> String {
102 "IIVM (LATE)".into()
103 }
104
105 fn validate(&self, data: &DmlData) -> Result<()> {
106 if data.n_treat() != 1 || !data.is_binary_treatment(0) {
107 return Err(DmlError::InvalidArgument(
108 "IIVM requires exactly one binary (0/1) treatment".into(),
109 ));
110 }
111 match data.z() {
112 Some(z) if is_binary(z) => {}
113 _ => {
114 return Err(DmlError::InvalidArgument(
115 "IIVM requires a binary (0/1) instrument: use DmlData::with_instrument".into(),
116 ))
117 }
118 }
119 if !(self.clipping_threshold > 0.0 && self.clipping_threshold < 0.5) {
120 return Err(DmlError::InvalidArgument(
121 "clipping_threshold must be in (0, 0.5)".into(),
122 ));
123 }
124 Ok(())
125 }
126
127 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
128 let ScoreInput { y, d, x, z, .. } = *input;
129 let z = z.ok_or_else(|| DmlError::InvalidArgument("IIVM requires an instrument".into()))?;
130 let z1: Vec<bool> = z.iter().map(|&v| v == 1.0).collect();
131 let z0: Vec<bool> = z1.iter().map(|b| !b).collect();
132 let n = y.len();
133
134 let g0 = cross_fit_predict_subset(self.ml_g.as_ref(), x, y, folds, &z0)?;
135 let g1 = cross_fit_predict_subset(self.ml_g.as_ref(), x, y, folds, &z1)?;
136 let t = self.clipping_threshold;
137 let m =
138 cross_fit_predict_proba(self.ml_m.as_ref(), x, z, folds)?.mapv(|p| p.clamp(t, 1.0 - t));
139 let r0 = if self.always_takers {
140 cross_fit_predict_proba_subset(self.ml_r.as_ref(), x, d, folds, &z0)?
141 } else {
142 Array1::zeros(n)
143 };
144 let r1 = if self.never_takers {
145 cross_fit_predict_proba_subset(self.ml_r.as_ref(), x, d, folds, &z1)?
146 } else {
147 Array1::ones(n)
148 };
149
150 let z = z.to_owned();
151 let m_adj = if self.normalize_ipw {
152 normalize_propensity(&m, &z)
153 } else {
154 m.clone()
155 };
156 let psi_b = &g1 - &g0 + &ipw_residual(y, &z, &g0, &g1, &m_adj);
158 let first_stage = &r1 - &r0 + &ipw_residual(d, &z, &r0, &r1, &m_adj);
159 Ok(ScoreEstimate {
160 score: LinearScore::new(-first_stage, psi_b)?,
161 predictions: vec![
162 ("ml_g0", g0),
163 ("ml_g1", g1),
164 ("ml_m", m),
165 ("ml_r0", r0),
166 ("ml_r1", r1),
167 ],
168 })
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::learner::{LogisticRegression, Ols};
176 use ndarray::array;
177
178 #[test]
179 fn requires_binary_treatment_and_instrument() {
180 let x = array![[1.0], [2.0], [3.0], [4.0]];
181 let base = DmlData::new(
182 array![1.0, 2.0, 3.0, 4.0],
183 array![0.0, 1.0, 0.0, 1.0],
184 x.clone(),
185 )
186 .unwrap();
187 let iivm = Iivm::new(
188 Ols::new(),
189 LogisticRegression::default(),
190 LogisticRegression::default(),
191 );
192 assert!(iivm.validate(&base).is_err()); let continuous_z = base
194 .clone()
195 .with_instrument(array![0.5, 1.0, 0.0, 1.0])
196 .unwrap();
197 assert!(iivm.validate(&continuous_z).is_err());
198 let ok = base.with_instrument(array![0.0, 1.0, 0.0, 1.0]).unwrap();
199 assert!(iivm.validate(&ok).is_ok());
200 }
201}