Skip to main content

dml_core/model/
iivm.rs

1//! Interactive IV model (IIVM) for a binary treatment `D` and a binary
2//! instrument `Z`, identifying the local average treatment effect (LATE) for
3//! compliers:
4//!
5//! ```text
6//! Y = ℓ(D, X) + ζ,       E[ζ | Z, X] = 0
7//! D = r(Z, X) + ν,       E[ν | Z, X] = 0
8//! Z = m(X) + V,          E[V | X] = 0
9//! θ = (E[g(1,X) − g(0,X)]) / (E[r(1,X) − r(0,X)])
10//! ```
11//!
12//! where `g(z, X) = E[Y | Z = z, X]` and `r(z, X) = P(D = 1 | Z = z, X)`.
13
14use 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
27/// IIVM estimator of the LATE: `ml_g` learns `E[Y | Z, X]` (fitted separately
28/// on the `Z = 0` and `Z = 1` rows), `ml_m` the instrument propensity
29/// `P(Z = 1 | X)`, and `ml_r` the treatment probability `P(D = 1 | Z, X)`
30/// (again per instrument arm).
31pub 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    /// Whether always-takers (`D = 1` despite `Z = 0`) and never-takers
69    /// (`D = 0` despite `Z = 1`) can exist. Without always-takers
70    /// `r(0, X) = 0` is imposed instead of estimated; without never-takers
71    /// `r(1, X) = 1`. With one-sided non-compliance, switch the impossible
72    /// group off: its classifier would otherwise see only one class.
73    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    /// Instrument propensities are clipped to `[t, 1 − t]` (default 0.01).
80    pub fn clipping_threshold(mut self, t: f64) -> Self {
81        self.clipping_threshold = t;
82        self
83    }
84
85    /// Normalise the inverse-probability weights within each instrument arm.
86    ///
87    /// Note: DoubleML 0.11 normalises with the treatment `D` here; this
88    /// implementation uses the instrument `Z`, whose propensity the weights
89    /// invert. Results therefore differ from DoubleML when this is enabled.
90    /// Reported upstream as
91    /// [DoubleML#411](https://github.com/DoubleML/doubleml-for-py/issues/411).
92    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        // Reduced form (effect of Z on Y) over first stage (effect of Z on D).
157        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()); // no instrument
193        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}