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