1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
12
13use super::{
14 dml_model_api, CateSignal, LinearScoreModel, NuisanceTarget, ScoreEstimate, ScoreInput,
15};
16use crate::compat::np_mean;
17use crate::crossfit::{cross_fit_predict_proba, cross_fit_predict_subset, Fold, SampleSplitting};
18use crate::data::DmlData;
19use crate::error::{DmlError, Result};
20use crate::learner::{Classifier, Regressor};
21use crate::propensity::{adjust_propensity, Calibration};
22use crate::score::LinearScore;
23use crate::sensitivity::{Nu2Parts, SensitivityParts};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum IrmScore {
28 #[default]
30 Ate,
31 Atte,
33}
34
35pub struct Irm {
39 ml_g: Box<dyn Regressor>,
40 ml_m: Box<dyn Classifier>,
41 score: IrmScore,
42 clipping_threshold: f64,
43 calibration: Calibration,
44 normalize_ipw: bool,
45 weights: Option<ScoreWeights>,
46 splitting: SampleSplitting,
47}
48
49#[derive(Debug, Clone)]
53pub(crate) struct ScoreWeights {
54 pub weights: Array1<f64>,
55 pub weights_bar: Option<Array2<f64>>,
56}
57
58impl ScoreWeights {
59 pub(crate) fn check(&self, n: usize) -> Result<()> {
60 let ok = |w: &mut dyn Iterator<Item = f64>| {
61 let (mut sum, mut all_ok) = (0.0, true);
62 for v in w {
63 all_ok &= v.is_finite() && v >= 0.0;
64 sum += v;
65 }
66 all_ok && sum > 0.0
67 };
68 if self.weights.len() != n {
69 return Err(DmlError::DimensionMismatch(format!(
70 "weights have length {}, the data {n} rows",
71 self.weights.len()
72 )));
73 }
74 if !ok(&mut self.weights.iter().copied()) {
75 return Err(DmlError::InvalidArgument(
76 "weights must be finite, non-negative and not all zero".into(),
77 ));
78 }
79 if let Some(bar) = &self.weights_bar {
80 if bar.nrows() != n || bar.ncols() == 0 {
81 return Err(DmlError::DimensionMismatch(format!(
82 "weights_bar must have shape ({n}, n_rep), got {:?}",
83 bar.dim()
84 )));
85 }
86 if !ok(&mut bar.iter().copied()) {
87 return Err(DmlError::InvalidArgument(
88 "weights_bar must be finite, non-negative and not all zero".into(),
89 ));
90 }
91 }
92 Ok(())
93 }
94
95 pub(crate) fn ate(
97 weights: Option<&ScoreWeights>,
98 n: usize,
99 rep: usize,
100 ) -> Result<(Array1<f64>, Array1<f64>)> {
101 let Some(w) = weights else {
102 return Ok((Array1::ones(n), Array1::ones(n)));
103 };
104 let bar = match &w.weights_bar {
105 None => w.weights.clone(),
106 Some(bar) if rep < bar.ncols() => bar.column(rep).to_owned(),
107 Some(bar) => {
108 return Err(DmlError::DimensionMismatch(format!(
109 "weights_bar has {} columns, one per repetition is needed (repetition {rep})",
110 bar.ncols()
111 )))
112 }
113 };
114 Ok((w.weights.clone(), bar))
115 }
116}
117
118impl Irm {
119 pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
120 Self::from_boxed(Box::new(ml_g), Box::new(ml_m))
121 }
122
123 pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Box<dyn Classifier>) -> Self {
124 Self {
125 ml_g,
126 ml_m,
127 score: IrmScore::default(),
128 clipping_threshold: 1e-2,
129 calibration: Calibration::None,
130 normalize_ipw: false,
131 weights: None,
132 splitting: SampleSplitting::default(),
133 }
134 }
135
136 pub fn weights(mut self, weights: Array1<f64>) -> Self {
141 self.weights = Some(ScoreWeights {
142 weights,
143 weights_bar: None,
144 });
145 self
146 }
147
148 pub fn weights_with_bar(mut self, weights: Array1<f64>, weights_bar: Array2<f64>) -> Self {
151 self.weights = Some(ScoreWeights {
152 weights,
153 weights_bar: Some(weights_bar),
154 });
155 self
156 }
157
158 pub fn score(mut self, score: IrmScore) -> Self {
159 self.score = score;
160 self
161 }
162
163 pub fn clipping_threshold(mut self, t: f64) -> Self {
165 self.clipping_threshold = t;
166 self
167 }
168 pub fn calibration(mut self, calibration: Calibration) -> Self {
171 self.calibration = calibration;
172 self
173 }
174
175 pub fn normalize_ipw(mut self, normalize: bool) -> Self {
178 self.normalize_ipw = normalize;
179 self
180 }
181
182 dml_model_api!();
183}
184
185pub(crate) fn normalize_propensity(m: &Array1<f64>, d: &Array1<f64>) -> Array1<f64> {
188 let ratio1: Vec<f64> = d.iter().zip(m).map(|(&di, &mi)| di / mi).collect();
191 let ratio0: Vec<f64> = d
192 .iter()
193 .zip(m)
194 .map(|(&di, &mi)| (1.0 - di) / (1.0 - mi))
195 .collect();
196 let (mean1, mean0) = (np_mean(&ratio1), np_mean(&ratio0));
197 d.iter()
198 .zip(m)
199 .map(|(&di, &mi)| di * mi * mean1 + (1.0 - di) * (1.0 - (1.0 - mi) * mean0))
200 .collect()
201}
202
203impl LinearScoreModel for Irm {
204 fn name(&self) -> String {
205 match self.score {
206 IrmScore::Ate => "IRM (ATE)".into(),
207 IrmScore::Atte => "IRM (ATTE)".into(),
208 }
209 }
210
211 fn validate(&self, data: &DmlData) -> Result<()> {
212 if data.n_treat() != 1 || !data.is_binary_treatment(0) {
213 return Err(DmlError::InvalidArgument(
214 "IRM requires exactly one binary (0/1) treatment".into(),
215 ));
216 }
217 if !(self.clipping_threshold > 0.0 && self.clipping_threshold < 0.5) {
218 return Err(DmlError::InvalidArgument(
219 "clipping_threshold must be in (0, 0.5)".into(),
220 ));
221 }
222 if let Some(w) = &self.weights {
223 w.check(data.n_obs())?;
224 if self.score == IrmScore::Atte
225 && (w.weights_bar.is_some() || w.weights.iter().any(|&v| v != 0.0 && v != 1.0))
226 {
227 return Err(DmlError::InvalidArgument(
228 "with the ATTE score, weights must be 0/1 (without weights_bar)".into(),
229 ));
230 }
231 }
232 Ok(())
233 }
234
235 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
236 let ScoreInput { y, d, x, rep, .. } = *input;
237 let treated: Vec<bool> = d.iter().map(|&v| v == 1.0).collect();
238 let control: Vec<bool> = treated.iter().map(|t| !t).collect();
239 let ml_g = self.ml_g.as_ref();
240 let g0 = input.predictions_or("ml_g0", || {
241 cross_fit_predict_subset(ml_g, x, y, folds, &control)
242 })?;
243 let g1 = input.predictions_or("ml_g1", || {
244 cross_fit_predict_subset(ml_g, x, y, folds, &treated)
245 })?;
246 let m_raw = input.predictions_or("ml_m", || {
247 cross_fit_predict_proba(self.ml_m.as_ref(), x, d, folds)
248 })?;
249 let m = adjust_propensity(m_raw, d, folds, self.clipping_threshold, self.calibration)?;
250 let d = d.to_owned();
251 let m_adj = if self.normalize_ipw {
252 normalize_propensity(&m, &d)
253 } else {
254 m.clone()
255 };
256 let ipw = ipw_residual(y, &d, &g0, &g1, &m_adj);
257
258 let n = d.len();
259 let (weights, weights_bar) = match self.score {
260 IrmScore::Ate => ScoreWeights::ate(self.weights.as_ref(), n, rep)?,
261 IrmScore::Atte => {
262 let w = self
264 .weights
265 .as_ref()
266 .map_or_else(|| Array1::ones(n), |w| w.weights.clone());
267 let subgroup = &w * &d;
268 let p = subgroup.mean().expect("n > 0");
269 (&subgroup / p, &m_adj * &w / p)
270 }
271 };
272 let psi_b = &weights * &(&g1 - &g0) + &weights_bar * &ipw;
273 let psi_a = -(&weights / weights.mean().expect("n > 0"));
274
275 let outcome_residual = &y - &(&d * &g1) - &(&(1.0 - &d) * &g0);
278 let m_alpha = &weights * &weights_bar * &(1.0 / &m_adj + 1.0 / &(1.0 - &m_adj));
279 let riesz = &weights_bar * &(&d / &m_adj - &(1.0 - &d) / &(1.0 - &m_adj));
280 let nu2 = 2.0 * &m_alpha - &riesz.mapv(|a| a * a);
281 let estimate = ScoreEstimate::new(
282 LinearScore::new(psi_a, psi_b.clone())?,
283 vec![("ml_g0", g0), ("ml_g1", g1), ("ml_m", m)],
284 )
285 .with_sensitivity(SensitivityParts {
286 outcome_residual,
287 theta_factor: None,
288 riesz,
289 nu2: Nu2Parts::Elementwise(nu2),
290 })
291 .with_targets(vec![
292 NuisanceTarget::regression("ml_g0", y.to_owned()).only_where(|i| d[i] == 0.0),
293 NuisanceTarget::regression("ml_g1", y.to_owned()).only_where(|i| d[i] == 1.0),
294 NuisanceTarget::classification("ml_m", d.clone()),
295 ]);
296 Ok(match self.score {
298 IrmScore::Ate => estimate.with_cate_signal(CateSignal {
299 signal: psi_b,
300 theta_factor: None,
301 basis_scale: None,
302 is_effect: true,
303 }),
304 IrmScore::Atte => estimate,
305 })
306 }
307}
308
309#[allow(clippy::too_many_arguments)]
312pub(crate) fn irm_nuisances(
313 ml_g: &dyn Regressor,
314 ml_m: &dyn Classifier,
315 x: ArrayView2<'_, f64>,
316 y: ArrayView1<'_, f64>,
317 d: ArrayView1<'_, f64>,
318 folds: &[Fold],
319 clipping_threshold: f64,
320 calibration: Calibration,
321) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>)> {
322 let treated: Vec<bool> = d.iter().map(|&v| v == 1.0).collect();
323 let control: Vec<bool> = treated.iter().map(|t| !t).collect();
324 let g0 = cross_fit_predict_subset(ml_g, x, y, folds, &control)?;
325 let g1 = cross_fit_predict_subset(ml_g, x, y, folds, &treated)?;
326 let m_raw = cross_fit_predict_proba(ml_m, x, d, folds)?;
327 let m = adjust_propensity(m_raw, d, folds, clipping_threshold, calibration)?;
328 Ok((g0, g1, m))
329}
330
331pub(crate) fn ipw_residual(
334 y: ArrayView1<'_, f64>,
335 d: &Array1<f64>,
336 g0: &Array1<f64>,
337 g1: &Array1<f64>,
338 m: &Array1<f64>,
339) -> Array1<f64> {
340 let u0: Array1<f64> = &y - g0;
341 let u1: Array1<f64> = &y - g1;
342 d * &u1 / m - &(1.0 - d) * &u0 / &(1.0 - m)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::learner::{LogisticRegression, Ols};
349 use ndarray::{array, Array2};
350
351 #[test]
352 fn rejects_continuous_treatment_and_bad_clipping() {
353 let data = DmlData::new(
354 array![1.0, 2.0, 3.0],
355 array![0.0, 0.5, 1.0],
356 array![[1.0], [2.0], [3.0]],
357 )
358 .unwrap();
359 let irm = Irm::new(Ols::new(), LogisticRegression::default());
360 assert!(irm.fit(&data).is_err());
361 let binary =
362 DmlData::new(array![1.0, 2.0], array![0.0, 1.0], array![[1.0], [2.0]]).unwrap();
363 let bad = Irm::new(Ols::new(), LogisticRegression::default()).clipping_threshold(0.7);
364 assert!(bad.validate(&binary).is_err());
365 }
366
367 #[test]
368 fn normalized_weights_have_unit_means() {
369 let m = array![0.2, 0.5, 0.7, 0.4];
370 let d = array![1.0, 0.0, 1.0, 0.0];
371 let adj = normalize_propensity(&m, &d);
372 let n = 4.0;
373 let mean1: f64 = d.iter().zip(&adj).map(|(&a, &b)| a / b).sum::<f64>() / n;
374 let mean0: f64 = d
375 .iter()
376 .zip(&adj)
377 .map(|(&a, &b)| (1.0 - a) / (1.0 - b))
378 .sum::<f64>()
379 / n;
380 assert!((mean1 - 1.0).abs() < 1e-12 && (mean0 - 1.0).abs() < 1e-12);
381 }
382
383 #[test]
384 fn randomized_experiment_recovers_difference_in_means() {
385 let n = 200;
387 let x = Array2::from_shape_fn((n, 1), |(i, _)| ((i * 37) % 17) as f64);
388 let d: Array1<f64> = (0..n).map(|i| ((i * 7919) % 2) as f64).collect();
389 let y: Array1<f64> = (0..n).map(|i| 2.0 * d[i] + ((i * 31) % 7) as f64).collect();
390 let data = DmlData::new(y, d, x).unwrap();
391 let fit = Irm::new(Ols::new(), LogisticRegression::default())
392 .fit(&data)
393 .unwrap();
394 assert!((fit.coef()[0] - 2.0).abs() < 0.5, "ate = {}", fit.coef()[0]);
395 }
396}