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