Skip to main content

dml_core/model/
apo.rs

1//! Average potential outcomes of a discrete (multi-valued) treatment, as
2//! DoubleML's `DoubleMLAPO` and `DoubleMLAPOS`:
3//!
4//! ```text
5//! θ(d) = E[Y(d)] = E[g(d, X)],   g(d, X) = E[Y | D = d, X],   m_d(X) = P(D = d | X)
6//! ```
7//!
8//! estimated with the doubly robust score
9//! `ψ = g(d, X) + 1{D = d}·(Y − g(d, X)) / m_d(X) − θ`. Contrasts between
10//! levels (e.g. `θ(1) − θ(0)`, the average effect of level 1 against level 0)
11//! come from [`AposFit::causal_contrast`].
12
13use ndarray::{concatenate, Array1, Array2, Axis};
14
15use super::irm::{normalize_propensity, ScoreWeights};
16use super::{
17    dml_model_api, CateSignal, LinearScoreModel, NuisanceTarget, ScoreEstimate, ScoreInput,
18};
19use crate::cluster::ClusterFold;
20use crate::crossfit::{cross_fit_predict_proba, cross_fit_predict_subset, Fold, SampleSplitting};
21use crate::data::DmlData;
22use crate::error::{DmlError, Result};
23use crate::fit::DmlFit;
24use crate::learner::{Classifier, Regressor};
25use crate::score::LinearScore;
26use crate::sensitivity::{Elements, Nu2Parts, SensitivityParts};
27
28/// Average potential outcome `E[Y(d)]` of one treatment level `d`.
29///
30/// `ml_g` learns `E[Y | D = d, X]` on the units at level `d`; `ml_m` learns the
31/// generalised propensity `P(D = d | X)`. A second outcome model, trained on
32/// `(D, X)` for the units at the other levels, enters only the sensitivity
33/// analysis (as in DoubleML). Default sample splits are stratified by the
34/// treatment.
35pub struct Apo {
36    ml_g: Box<dyn Regressor>,
37    ml_m: Box<dyn Classifier>,
38    treatment_level: f64,
39    clipping_threshold: f64,
40    normalize_ipw: bool,
41    weights: Option<ScoreWeights>,
42    splitting: SampleSplitting,
43}
44
45impl Apo {
46    pub fn new(
47        ml_g: impl Regressor + 'static,
48        ml_m: impl Classifier + 'static,
49        treatment_level: f64,
50    ) -> Self {
51        Self::from_boxed(Box::new(ml_g), Box::new(ml_m), treatment_level)
52    }
53
54    pub fn from_boxed(
55        ml_g: Box<dyn Regressor>,
56        ml_m: Box<dyn Classifier>,
57        treatment_level: f64,
58    ) -> Self {
59        Self {
60            ml_g,
61            ml_m,
62            treatment_level,
63            clipping_threshold: 1e-2,
64            normalize_ipw: false,
65            weights: None,
66            splitting: SampleSplitting::default(),
67        }
68    }
69
70    /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
71    pub fn clipping_threshold(mut self, t: f64) -> Self {
72        self.clipping_threshold = t;
73        self
74    }
75
76    /// Normalise the inverse-probability weights, as DoubleML's `normalize_ipw=True`.
77    pub fn normalize_ipw(mut self, normalize: bool) -> Self {
78        self.normalize_ipw = normalize;
79        self
80    }
81
82    /// Weighted average potential outcome, as DoubleML's `weights`:
83    /// `E[w·g(d, X)] / E[w]` (e.g. `w = 1{X ∈ G}` for a subgroup).
84    pub fn weights(mut self, weights: Array1<f64>) -> Self {
85        self.weights = Some(ScoreWeights {
86            weights,
87            weights_bar: None,
88        });
89        self
90    }
91
92    /// Weights with separate `weights_bar` for the inverse-probability
93    /// correction, one column per repetition (DoubleML's dict form).
94    pub fn weights_with_bar(mut self, weights: Array1<f64>, weights_bar: Array2<f64>) -> Self {
95        self.weights = Some(ScoreWeights {
96            weights,
97            weights_bar: Some(weights_bar),
98        });
99        self
100    }
101
102    dml_model_api!();
103}
104
105/// Index of each observation's treatment value among the sorted distinct
106/// values (sample-splitting strata).
107fn level_codes(d: &[f64]) -> Vec<usize> {
108    let mut levels = d.to_vec();
109    levels.sort_by(f64::total_cmp);
110    levels.dedup();
111    d.iter()
112        .map(|v| {
113            levels
114                .binary_search_by(|l| l.total_cmp(v))
115                .expect("present")
116        })
117        .collect()
118}
119
120fn check_clipping(t: f64) -> Result<()> {
121    if t > 0.0 && t < 0.5 {
122        Ok(())
123    } else {
124        Err(DmlError::InvalidArgument(
125            "clipping_threshold must be in (0, 0.5)".into(),
126        ))
127    }
128}
129
130impl LinearScoreModel for Apo {
131    fn name(&self) -> String {
132        format!("APO (treatment level {})", self.treatment_level)
133    }
134
135    fn validate(&self, data: &DmlData) -> Result<()> {
136        if data.n_treat() != 1 {
137            return Err(DmlError::InvalidArgument(
138                "APO requires exactly one (discrete) treatment".into(),
139            ));
140        }
141        check_clipping(self.clipping_threshold)?;
142        if let Some(w) = &self.weights {
143            w.check(data.n_obs())?;
144        }
145        let d = data.treatment(0);
146        let at_level = d.iter().filter(|&&v| v == self.treatment_level).count();
147        if at_level == 0 || at_level == d.len() {
148            return Err(DmlError::InvalidArgument(format!(
149                "treatment level {} must occur in the data, and other levels too",
150                self.treatment_level
151            )));
152        }
153        Ok(())
154    }
155
156    fn strata(&self, data: &DmlData) -> Option<Vec<usize>> {
157        Some(level_codes(&data.treatment(0).to_vec()))
158    }
159
160    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
161        let ScoreInput { y, d, x, rep, .. } = *input;
162        let level = self.treatment_level;
163        let at_level: Vec<bool> = d.iter().map(|&v| v == level).collect();
164        let others: Vec<bool> = at_level.iter().map(|t| !t).collect();
165        let treated: Array1<f64> = at_level.iter().map(|&t| f64::from(t)).collect();
166
167        // g(d, X) on the units at the level; the model for the other levels
168        // also sees D (it only enters the sensitivity analysis).
169        let g1 = cross_fit_predict_subset(self.ml_g.as_ref(), x, y, folds, &at_level)?;
170        let dx: Array2<f64> = concatenate![Axis(1), d.insert_axis(Axis(1)), x];
171        let g0 = cross_fit_predict_subset(self.ml_g.as_ref(), dx.view(), y, folds, &others)?;
172        let t = self.clipping_threshold;
173        let m = cross_fit_predict_proba(self.ml_m.as_ref(), x, treated.view(), folds)?
174            .mapv(|p| p.clamp(t, 1.0 - t));
175        let m_adj = if self.normalize_ipw {
176            normalize_propensity(&m, &treated)
177        } else {
178            m.clone()
179        };
180
181        let (w, w_bar) = ScoreWeights::ate(self.weights.as_ref(), d.len(), rep)?;
182        let psi_b = &w * &g1 + &(&w_bar * &treated * &(&y - &g1) / &m_adj);
183        let psi_a = -(&w / w.mean().expect("n > 0"));
184
185        // Sensitivity: outcome residual y − ĝ(D, X), Riesz representer
186        // 1{D = d} / m_d(X) and m(W, α) = 1 / m_d(X).
187        let outcome_residual = &y - &(&treated * &g1) - &(&(1.0 - &treated) * &g0);
188        let riesz = &w_bar * &treated / &m_adj;
189        let nu2 = 2.0 * &w * &w_bar / &m_adj - &riesz.mapv(|a| a * a);
190        Ok(ScoreEstimate::new(
191            LinearScore::new(psi_a, psi_b.clone())?,
192            vec![("ml_g_d_lvl0", g0), ("ml_g_d_lvl1", g1), ("ml_m", m)],
193        )
194        .with_sensitivity(SensitivityParts {
195            outcome_residual,
196            theta_factor: None,
197            riesz,
198            nu2: Nu2Parts::Elementwise(nu2),
199        })
200        .with_targets(vec![
201            NuisanceTarget::regression("ml_g_d_lvl0", y.to_owned()).only_where(|i| !at_level[i]),
202            NuisanceTarget::regression("ml_g_d_lvl1", y.to_owned()).only_where(|i| at_level[i]),
203            NuisanceTarget::classification("ml_m", treated.clone()),
204        ])
205        // Conditional APOs (DoubleML's capo / gapo): the orthogonal signal ψ_b.
206        .with_cate_signal(CateSignal {
207            signal: psi_b,
208            theta_factor: None,
209            basis_scale: None,
210            is_effect: false,
211        }))
212    }
213}
214
215/// Average potential outcomes of several treatment levels, fitted on shared
216/// sample splits so that they (and their contrasts) have joint inference.
217pub struct Apos {
218    ml_g: Box<dyn Regressor>,
219    ml_m: Box<dyn Classifier>,
220    treatment_levels: Vec<f64>,
221    clipping_threshold: f64,
222    normalize_ipw: bool,
223    weights: Option<ScoreWeights>,
224    splitting: SampleSplitting,
225}
226
227impl Apos {
228    pub fn new(
229        ml_g: impl Regressor + 'static,
230        ml_m: impl Classifier + 'static,
231        treatment_levels: Vec<f64>,
232    ) -> Self {
233        Self::from_boxed(Box::new(ml_g), Box::new(ml_m), treatment_levels)
234    }
235
236    pub fn from_boxed(
237        ml_g: Box<dyn Regressor>,
238        ml_m: Box<dyn Classifier>,
239        treatment_levels: Vec<f64>,
240    ) -> Self {
241        Self {
242            ml_g,
243            ml_m,
244            treatment_levels,
245            clipping_threshold: 1e-2,
246            normalize_ipw: false,
247            weights: None,
248            splitting: SampleSplitting::default(),
249        }
250    }
251
252    /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
253    pub fn clipping_threshold(mut self, t: f64) -> Self {
254        self.clipping_threshold = t;
255        self
256    }
257
258    /// Normalise the inverse-probability weights, as DoubleML's `normalize_ipw=True`.
259    pub fn normalize_ipw(mut self, normalize: bool) -> Self {
260        self.normalize_ipw = normalize;
261        self
262    }
263
264    /// Weighted average potential outcome, as DoubleML's `weights`:
265    /// `E[w·g(d, X)] / E[w]` (e.g. `w = 1{X ∈ G}` for a subgroup).
266    pub fn weights(mut self, weights: Array1<f64>) -> Self {
267        self.weights = Some(ScoreWeights {
268            weights,
269            weights_bar: None,
270        });
271        self
272    }
273
274    /// Weights with separate `weights_bar` for the inverse-probability
275    /// correction, one column per repetition (DoubleML's dict form).
276    pub fn weights_with_bar(mut self, weights: Array1<f64>, weights_bar: Array2<f64>) -> Self {
277        self.weights = Some(ScoreWeights {
278            weights,
279            weights_bar: Some(weights_bar),
280        });
281        self
282    }
283
284    /// Number of cross-fitting folds (default 5).
285    pub fn n_folds(mut self, n_folds: usize) -> Self {
286        self.splitting.n_folds = n_folds;
287        self
288    }
289
290    /// Number of independent sample splits (default 1).
291    pub fn n_rep(mut self, n_rep: usize) -> Self {
292        self.splitting.n_rep = n_rep;
293        self
294    }
295
296    /// Seed for the sample splits (default 0).
297    pub fn seed(mut self, seed: u64) -> Self {
298        self.splitting.seed = seed;
299        self
300    }
301
302    fn level_model(&self, level: f64) -> Apo {
303        let model = Apo::from_boxed(self.ml_g.fresh(), self.ml_m.fresh(), level)
304            .clipping_threshold(self.clipping_threshold)
305            .normalize_ipw(self.normalize_ipw);
306        match &self.weights {
307            Some(w) => Apo {
308                weights: Some(w.clone()),
309                ..model
310            },
311            None => model,
312        }
313    }
314
315    fn check_levels(&self, data: &DmlData) -> Result<()> {
316        if data.n_treat() != 1 {
317            return Err(DmlError::InvalidArgument(
318                "APOS requires exactly one (discrete) treatment".into(),
319            ));
320        }
321        if self.treatment_levels.is_empty() {
322            return Err(DmlError::InvalidArgument(
323                "at least one treatment level is required".into(),
324            ));
325        }
326        let d = data.treatment(0);
327        for (i, &level) in self.treatment_levels.iter().enumerate() {
328            if self.treatment_levels[..i].contains(&level) {
329                return Err(DmlError::InvalidArgument(format!(
330                    "treatment level {level} is listed twice"
331                )));
332            }
333            if !d.iter().any(|&v| v == level) {
334                return Err(DmlError::InvalidArgument(format!(
335                    "treatment level {level} does not occur in the data"
336                )));
337            }
338        }
339        Ok(())
340    }
341
342    /// Draw sample splits (stratified by the treatment, or by cluster for
343    /// clustered data) and fit every level on them.
344    pub fn fit(&self, data: &DmlData) -> Result<AposFit> {
345        self.check_levels(data)?;
346        match data.clusters() {
347            Some(clusters) => {
348                let splits = self.splitting.draw_clustered(clusters)?;
349                self.fit_with_cluster_folds(data, &splits)
350            }
351            None => {
352                let strata = level_codes(&data.treatment(0).to_vec());
353                let splits = self.splitting.draw_stratified(&strata)?;
354                self.fit_with_folds(data, &splits)
355            }
356        }
357    }
358
359    /// Fit every level on the given splits (one `Vec<Fold>` per repetition).
360    pub fn fit_with_folds(&self, data: &DmlData, splits: &[Vec<Fold>]) -> Result<AposFit> {
361        self.check_levels(data)?;
362        let fits = self
363            .treatment_levels
364            .iter()
365            .map(|&level| self.level_model(level).fit_with_folds(data, splits))
366            .collect::<Result<Vec<_>>>()?;
367        self.assemble(fits)
368    }
369
370    /// Fit every level of clustered data on the given cluster splits.
371    pub fn fit_with_cluster_folds(
372        &self,
373        data: &DmlData,
374        splits: &[Vec<ClusterFold>],
375    ) -> Result<AposFit> {
376        self.check_levels(data)?;
377        let fits = self
378            .treatment_levels
379            .iter()
380            .map(|&level| self.level_model(level).fit_with_cluster_folds(data, splits))
381            .collect::<Result<Vec<_>>>()?;
382        self.assemble(fits)
383    }
384
385    fn assemble(&self, fits: Vec<DmlFit>) -> Result<AposFit> {
386        let mut fit = DmlFit::concat(&fits)?;
387        fit.model = "APOS".into();
388        fit.treatment_names = self
389            .treatment_levels
390            .iter()
391            .map(|l| l.to_string())
392            .collect();
393        Ok(AposFit {
394            levels: self.treatment_levels.clone(),
395            fit,
396        })
397    }
398}
399
400/// Fitted [`Apos`]: the average potential outcome of every level, with joint
401/// inference, and contrasts between levels.
402#[derive(Debug, Clone)]
403pub struct AposFit {
404    levels: Vec<f64>,
405    fit: DmlFit,
406}
407
408impl AposFit {
409    pub fn levels(&self) -> &[f64] {
410        &self.levels
411    }
412
413    /// One estimate per treatment level (named by the level).
414    pub fn apos(&self) -> &DmlFit {
415        &self.fit
416    }
417
418    /// Average effects of each level against each reference level,
419    /// `θ(d) − θ(d_ref)`, named `"d vs d_ref"`, as DoubleML's
420    /// `causal_contrast`: for every reference level in turn, all levels not yet
421    /// used as a reference. Inference accounts for the correlation between the
422    /// estimates; sensitivity analysis combines the elements of both levels
423    /// (average `σ²`, summed `ν²`), as DoubleML.
424    pub fn causal_contrast(&self, reference_levels: &[f64]) -> Result<DmlFit> {
425        let k = self.levels.len();
426        if k < 2 {
427            return Err(DmlError::InvalidArgument(
428                "only one treatment level: no causal contrast can be computed".into(),
429            ));
430        }
431        let mut rows: Vec<(usize, usize)> = Vec::new();
432        let mut used = vec![false; k];
433        for &reference in reference_levels {
434            let r = self
435                .levels
436                .iter()
437                .position(|&l| l == reference)
438                .ok_or_else(|| {
439                    DmlError::InvalidArgument(format!(
440                        "reference level {reference} is not one of the fitted treatment levels"
441                    ))
442                })?;
443            used[r] = true;
444            rows.extend((0..k).filter(|&i| !used[i]).map(|i| (i, r)));
445        }
446        if rows.is_empty() {
447            return Err(DmlError::InvalidArgument(
448                "no contrasts: give at least one reference level".into(),
449            ));
450        }
451        let mut weights = Array2::<f64>::zeros((rows.len(), k));
452        for (c, &(i, r)) in rows.iter().enumerate() {
453            weights[[c, i]] = 1.0;
454            weights[[c, r]] = -1.0;
455        }
456        let names = rows
457            .iter()
458            .map(|&(i, r)| format!("{} vs {}", self.levels[i], self.levels[r]))
459            .collect();
460        let mut contrast = self
461            .fit
462            .linear_combination("APOS contrasts".into(), names, &weights);
463        if let Some(store) = &self.fit.sensitivity {
464            let n_rep = self.fit.n_rep();
465            let mut combined = crate::sensitivity::SensitivityStore::new(
466                self.fit.n_obs,
467                rows.len(),
468                n_rep,
469                store.var_y,
470            );
471            for (c, &(i, r)) in rows.iter().enumerate() {
472                for rep in 0..n_rep {
473                    let (a, b) = (store.elements(i, rep), store.elements(r, rep));
474                    combined.set(
475                        c,
476                        rep,
477                        &Elements {
478                            sigma2: (a.sigma2 + b.sigma2) / 2.0,
479                            nu2: a.nu2 + b.nu2,
480                            psi_sigma2: (&a.psi_sigma2 + &b.psi_sigma2) / 2.0,
481                            psi_nu2: &a.psi_nu2 + &b.psi_nu2,
482                        },
483                    );
484                }
485            }
486            contrast.sensitivity = Some(combined);
487        }
488        Ok(contrast)
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::learner::{LogisticRegression, Ols};
496    use ndarray::array;
497
498    #[test]
499    fn level_codes_index_sorted_distinct_values() {
500        assert_eq!(level_codes(&[2.0, 0.0, 1.0, 2.0, 0.0]), vec![2, 0, 1, 2, 0]);
501    }
502
503    #[test]
504    fn rejects_missing_levels_and_single_level_contrasts() {
505        let data = DmlData::new(
506            array![1.0, 2.0, 3.0, 4.0],
507            array![0.0, 1.0, 0.0, 1.0],
508            array![[1.0], [2.0], [3.0], [4.0]],
509        )
510        .unwrap();
511        assert!(Apo::new(Ols::new(), LogisticRegression::default(), 5.0)
512            .validate(&data)
513            .is_err());
514        let apos = Apos::new(Ols::new(), LogisticRegression::default(), vec![0.0, 0.0]);
515        assert!(apos.check_levels(&data).is_err());
516    }
517}