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