Skip to main content

dml_core/model/
did.rs

1//! Doubly robust difference-in-differences (Sant'Anna & Zhao, 2020) for the
2//! average treatment effect on the treated (ATT) in a two-period design.
3//!
4//! - [`Did`]: **panel data**. The outcome passed to [`DmlData`] is the change
5//!   `Y = Y₁ − Y₀` of each unit between the two periods.
6//! - [`DidCs`]: **repeated cross-sections**. Each row is observed once, in the
7//!   pre- (`t = 0`) or post-period (`t = 1`), given by
8//!   [`DmlData::with_time_indicator`].
9//!
10//! Identification rests on conditional parallel trends: absent treatment,
11//! treated and control units with the same covariates would have evolved alike.
12
13use ndarray::{Array1, ArrayView1, ArrayView2};
14
15use super::{
16    dml_model_api, external_or, ExternalRef, LinearScoreModel, NuisanceTarget, ScoreEstimate,
17    ScoreInput,
18};
19use crate::crossfit::{cross_fit_predict_proba, cross_fit_predict_subset, Fold, SampleSplitting};
20use crate::data::DmlData;
21use crate::error::{DmlError, Result};
22use crate::learner::{Classifier, Regressor};
23use crate::propensity::{adjust_propensity, Calibration};
24use crate::score::LinearScore;
25use crate::sensitivity::{Nu2Parts, SensitivityParts};
26
27/// Assignment mechanism assumed by [`Did`] / [`DidCs`].
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum DidScore {
30    /// Treatment depends on covariates: the propensity `P(D = 1 | X)` is
31    /// estimated with `ml_m` (doubly robust score).
32    #[default]
33    Observational,
34    /// Treatment is randomised: no propensity model is needed.
35    Experimental,
36}
37
38fn mean(v: &Array1<f64>) -> f64 {
39    v.mean().expect("n > 0")
40}
41
42/// Shared validation for both DID estimators.
43fn validate_common(
44    data: &DmlData,
45    score: DidScore,
46    has_ml_m: bool,
47    clipping_threshold: f64,
48) -> Result<()> {
49    if data.n_treat() != 1 || !data.is_binary_treatment(0) {
50        return Err(DmlError::InvalidArgument(
51            "DID requires exactly one binary (0/1) treatment group indicator".into(),
52        ));
53    }
54    if score == DidScore::Observational && !has_ml_m {
55        return Err(DmlError::InvalidArgument(
56            "the observational DID score needs a propensity learner ml_m".into(),
57        ));
58    }
59    if !(clipping_threshold > 0.0 && clipping_threshold < 0.5) {
60        return Err(DmlError::InvalidArgument(
61            "clipping_threshold must be in (0, 0.5)".into(),
62        ));
63    }
64    Ok(())
65}
66
67/// Builder methods shared by [`Did`] and [`DidCs`].
68macro_rules! did_builders {
69    () => {
70        /// Observational (default) or experimental score.
71        pub fn score(mut self, score: DidScore) -> Self {
72            self.score = score;
73            self
74        }
75
76        /// Normalise the weights by their sample means (default `true`, as
77        /// DoubleML); `false` uses the plug-in probabilities.
78        pub fn in_sample_normalization(mut self, normalize: bool) -> Self {
79            self.in_sample_normalization = normalize;
80            self
81        }
82
83        /// Propensities are clipped to `[t, 1 − t]` (default 0.01).
84        pub fn clipping_threshold(mut self, t: f64) -> Self {
85            self.clipping_threshold = t;
86            self
87        }
88
89        /// Calibrate the propensity scores before clipping (default: none), as
90        /// DoubleML's `ps_processor_config`.
91        pub fn calibration(mut self, calibration: $crate::propensity::Calibration) -> Self {
92            self.calibration = calibration;
93            self
94        }
95    };
96}
97
98/// Difference-in-differences with **panel data** (two periods): `y` must be
99/// the outcome change `Y₁ − Y₀`. `ml_g` learns `E[ΔY | D = d, X]` per group,
100/// `ml_m` the propensity `P(D = 1 | X)` (observational score only).
101pub struct Did {
102    ml_g: Box<dyn Regressor>,
103    ml_m: Option<Box<dyn Classifier>>,
104    score: DidScore,
105    in_sample_normalization: bool,
106    clipping_threshold: f64,
107    calibration: Calibration,
108    splitting: SampleSplitting,
109}
110
111impl Did {
112    /// Observational design (doubly robust score).
113    pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
114        Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
115    }
116
117    /// Randomised design: only the outcome model is needed.
118    pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
119        Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
120    }
121
122    pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
123        Self {
124            ml_g,
125            ml_m,
126            score: DidScore::default(),
127            in_sample_normalization: true,
128            clipping_threshold: 1e-2,
129            calibration: Calibration::None,
130            splitting: SampleSplitting::default(),
131        }
132    }
133
134    did_builders!();
135    dml_model_api!();
136}
137
138impl LinearScoreModel for Did {
139    fn name(&self) -> String {
140        match self.score {
141            DidScore::Observational => "DID panel (observational)".into(),
142            DidScore::Experimental => "DID panel (experimental)".into(),
143        }
144    }
145
146    fn validate(&self, data: &DmlData) -> Result<()> {
147        validate_common(
148            data,
149            self.score,
150            self.ml_m.is_some(),
151            self.clipping_threshold,
152        )
153    }
154
155    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
156        let ScoreInput { y, d, x, .. } = *input;
157        panel_did_score(
158            &DidSettings {
159                ml_g: self.ml_g.as_ref(),
160                ml_m: self.ml_m.as_deref(),
161                score: self.score,
162                in_sample_normalization: self.in_sample_normalization,
163                clipping_threshold: self.clipping_threshold,
164                calibration: self.calibration,
165                external: input.external_ref(),
166            },
167            x,
168            y,
169            d,
170            folds,
171        )
172    }
173}
174
175/// Learners and options of the two-period DID scores, shared by [`Did`],
176/// [`DidCs`] and the per-(cohort, period) models of
177/// [`StaggeredDid`](crate::StaggeredDid).
178pub(crate) struct DidSettings<'a> {
179    pub ml_g: &'a dyn Regressor,
180    pub ml_m: Option<&'a dyn Classifier>,
181    pub score: DidScore,
182    pub in_sample_normalization: bool,
183    pub clipping_threshold: f64,
184    pub calibration: Calibration,
185    /// User-supplied nuisance predictions, if any.
186    pub external: Option<ExternalRef<'a>>,
187}
188
189/// Panel DID score elements for outcome changes `y`, group indicator `d`.
190pub(crate) fn panel_did_score(
191    settings: &DidSettings<'_>,
192    x: ArrayView2<'_, f64>,
193    y: ArrayView1<'_, f64>,
194    d: ArrayView1<'_, f64>,
195    folds: &[Fold],
196) -> Result<ScoreEstimate> {
197    let treated: Vec<bool> = d.iter().map(|&v| v == 1.0).collect();
198    let control: Vec<bool> = treated.iter().map(|b| !b).collect();
199    let n = y.len();
200    let ext = settings.external;
201    let g0 = external_or(ext, n, "ml_g0", || {
202        cross_fit_predict_subset(settings.ml_g, x, y, folds, &control)
203    })?;
204    let g1 = external_or(ext, n, "ml_g1", || {
205        cross_fit_predict_subset(settings.ml_g, x, y, folds, &treated)
206    })?;
207    let d = d.to_owned();
208    let n = d.len();
209    let p = mean(&d);
210    let resid_d0: Array1<f64> = &y - &g0;
211    let mut predictions = vec![("ml_g0", g0.clone()), ("ml_g1", g1.clone())];
212
213    let (weight_a, psi_b, m_hat) = match settings.score {
214        DidScore::Observational => {
215            let ml_m = settings.ml_m.ok_or_else(|| {
216                DmlError::InvalidArgument("the observational DID score needs ml_m".into())
217            })?;
218            let m_raw = external_or(settings.external, d.len(), "ml_m", || {
219                cross_fit_predict_proba(ml_m, x, d.view(), folds)
220            })?;
221            let m = adjust_propensity(
222                m_raw,
223                d.view(),
224                folds,
225                settings.clipping_threshold,
226                settings.calibration,
227            )?;
228            let (weight_a, weight_resid) = if settings.in_sample_normalization {
229                let weight_a = &d / mean(&d);
230                let propensity_weight = (1.0 - &d) * &m / &(1.0 - &m);
231                let weight_resid = &weight_a - &(&propensity_weight / mean(&propensity_weight));
232                (weight_a, weight_resid)
233            } else {
234                (&d / p, (&d - &m) / &(p * &(1.0 - &m)))
235            };
236            predictions.push(("ml_m", m.clone()));
237            let psi_b = &weight_resid * &resid_d0;
238            (weight_a, psi_b, Some(m))
239        }
240        DidScore::Experimental => {
241            let (weight_g0, weight_g1, weight_resid) = if settings.in_sample_normalization {
242                let share = &d / mean(&d);
243                let control_share = (1.0 - &d) / mean(&(1.0 - &d));
244                (&share - 1.0, 1.0 - &share, &share - &control_share)
245            } else {
246                (&d / p - 1.0, 1.0 - &d / p, (&d - p) / (p * (1.0 - p)))
247            };
248            let psi_b = &weight_g0 * &g0 + &weight_g1 * &g1 + &weight_resid * &resid_d0;
249            (Array1::ones(n), psi_b, None)
250        }
251    };
252
253    // Sensitivity: outcome residual y − ĝ(D, X); Riesz representer of the ATT.
254    let outcome_residual = &y - &(&d * &g1) - &(&(1.0 - &d) * &g0);
255    let (m_alpha, riesz) = match &m_hat {
256        Some(m) => {
257            let odds = m / &(1.0 - m);
258            if settings.in_sample_normalization {
259                let weight_d0 = (1.0 - &d) * &odds;
260                let mean_w = mean(&weight_d0);
261                (
262                    &d / p * &(1.0 / p + &odds / mean_w),
263                    &d / p - &(&weight_d0 / mean_w),
264                )
265            } else {
266                (
267                    &d / (p * p) * &(1.0 + &odds),
268                    &d / p - &((1.0 - &d) / p * &odds),
269                )
270            }
271        }
272        // Experimental: the same with or without normalisation.
273        None => (
274            Array1::from_elem(n, 1.0 / p + 1.0 / (1.0 - p)),
275            &d / p - &((1.0 - &d) / (1.0 - p)),
276        ),
277    };
278    let nu2 = 2.0 * &m_alpha - &riesz.mapv(|a| a * a);
279    let mut targets = vec![
280        NuisanceTarget::regression("ml_g0", y.to_owned()).only_where(|i| d[i] == 0.0),
281        NuisanceTarget::regression("ml_g1", y.to_owned()).only_where(|i| d[i] == 1.0),
282    ];
283    if m_hat.is_some() {
284        targets.push(NuisanceTarget::classification("ml_m", d.clone()));
285    }
286    Ok(
287        ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions)
288            .with_sensitivity(SensitivityParts {
289                outcome_residual,
290                theta_factor: None,
291                riesz,
292                nu2: Nu2Parts::Elementwise(nu2),
293            })
294            .with_targets(targets),
295    )
296}
297
298/// Difference-in-differences with **repeated cross-sections**: the data need
299/// a 0/1 time indicator ([`DmlData::with_time_indicator`]). `ml_g` learns
300/// `E[Y | D = d, T = t, X]` separately in each of the four group × period
301/// cells, `ml_m` the propensity `P(D = 1 | X)` (observational score only).
302pub struct DidCs {
303    ml_g: Box<dyn Regressor>,
304    ml_m: Option<Box<dyn Classifier>>,
305    score: DidScore,
306    in_sample_normalization: bool,
307    clipping_threshold: f64,
308    calibration: Calibration,
309    splitting: SampleSplitting,
310}
311
312impl DidCs {
313    /// Observational design (doubly robust score).
314    pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
315        Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
316    }
317
318    /// Randomised design: only the outcome model is needed.
319    pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
320        Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
321    }
322
323    pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
324        Self {
325            ml_g,
326            ml_m,
327            score: DidScore::default(),
328            in_sample_normalization: true,
329            clipping_threshold: 1e-2,
330            calibration: Calibration::None,
331            splitting: SampleSplitting::default(),
332        }
333    }
334
335    did_builders!();
336    dml_model_api!();
337}
338
339/// Cell indicator `1{D = a, T = b}`.
340fn cell(d: &Array1<f64>, t: ArrayView1<'_, f64>, a: f64, b: f64) -> Array1<f64> {
341    d.iter()
342        .zip(t)
343        .map(|(&di, &ti)| f64::from(di == a && ti == b))
344        .collect()
345}
346
347impl LinearScoreModel for DidCs {
348    fn name(&self) -> String {
349        match self.score {
350            DidScore::Observational => "DID repeated cross-sections (observational)".into(),
351            DidScore::Experimental => "DID repeated cross-sections (experimental)".into(),
352        }
353    }
354
355    fn validate(&self, data: &DmlData) -> Result<()> {
356        validate_common(
357            data,
358            self.score,
359            self.ml_m.is_some(),
360            self.clipping_threshold,
361        )?;
362        if data.time().is_none() {
363            return Err(DmlError::InvalidArgument(
364                "repeated cross-section DID needs a time indicator: use DmlData::with_time_indicator"
365                    .into(),
366            ));
367        }
368        Ok(())
369    }
370
371    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
372        let ScoreInput { y, d, x, t, .. } = *input;
373        let t = t.ok_or_else(|| DmlError::InvalidArgument("missing time indicator".into()))?;
374        cs_did_score(
375            &DidSettings {
376                ml_g: self.ml_g.as_ref(),
377                ml_m: self.ml_m.as_deref(),
378                score: self.score,
379                in_sample_normalization: self.in_sample_normalization,
380                clipping_threshold: self.clipping_threshold,
381                calibration: self.calibration,
382                external: input.external_ref(),
383            },
384            x,
385            y,
386            d,
387            t,
388            folds,
389        )
390    }
391}
392
393/// Repeated cross-section DID score elements for outcome levels `y`, group
394/// indicator `d` and post-period indicator `t`.
395pub(crate) fn cs_did_score(
396    settings: &DidSettings<'_>,
397    x: ArrayView2<'_, f64>,
398    y: ArrayView1<'_, f64>,
399    d: ArrayView1<'_, f64>,
400    t: ArrayView1<'_, f64>,
401    folds: &[Fold],
402) -> Result<ScoreEstimate> {
403    let d = d.to_owned();
404    let (d1t1, d1t0) = (cell(&d, t, 1.0, 1.0), cell(&d, t, 1.0, 0.0));
405    let (d0t1, d0t0) = (cell(&d, t, 0.0, 1.0), cell(&d, t, 0.0, 0.0));
406    let fit_cell = |c: &Array1<f64>, name: &str| -> Result<Array1<f64>> {
407        let keep: Vec<bool> = c.iter().map(|&v| v == 1.0).collect();
408        external_or(settings.external, y.len(), name, || {
409            cross_fit_predict_subset(settings.ml_g, x, y, folds, &keep)
410        })
411    };
412    let (g00, g01) = (
413        fit_cell(&d0t0, "ml_g_d0_t0")?,
414        fit_cell(&d0t1, "ml_g_d0_t1")?,
415    );
416    let (g10, g11) = (
417        fit_cell(&d1t0, "ml_g_d1_t0")?,
418        fit_cell(&d1t1, "ml_g_d1_t1")?,
419    );
420    let n = d.len();
421    let p = mean(&d);
422    let lambda = t.mean().expect("n > 0");
423
424    let m = match settings.score {
425        DidScore::Observational => {
426            let ml_m = settings.ml_m.ok_or_else(|| {
427                DmlError::InvalidArgument("the observational DID score needs ml_m".into())
428            })?;
429            let m_raw = external_or(settings.external, d.len(), "ml_m", || {
430                cross_fit_predict_proba(ml_m, x, d.view(), folds)
431            })?;
432            Some(adjust_propensity(
433                m_raw,
434                d.view(),
435                folds,
436                settings.clipping_threshold,
437                settings.calibration,
438            )?)
439        }
440        DidScore::Experimental => None,
441    };
442
443    // Weights on the four residuals (y − ĝ_cell) and on the ĝ contrast.
444    let (weight_a, w11, w10, w01, w00) = match (&m, settings.in_sample_normalization) {
445        (Some(m), true) => {
446            let odds = m / &(1.0 - m);
447            let u01 = &d0t1 * &odds;
448            let u00 = &d0t0 * &odds;
449            (
450                &d / mean(&d),
451                &d1t1 / mean(&d1t1),
452                -(&d1t0 / mean(&d1t0)),
453                -(&u01 / mean(&u01)),
454                &u00 / mean(&u00),
455            )
456        }
457        (Some(m), false) => {
458            let odds = m / &(1.0 - m);
459            (
460                &d / p,
461                &d1t1 / (p * lambda),
462                -(&d1t0 / (p * (1.0 - lambda))),
463                -(&d0t1 / (p * lambda) * &odds),
464                &d0t0 / (p * (1.0 - lambda)) * &odds,
465            )
466        }
467        (None, true) => (
468            Array1::ones(n),
469            &d1t1 / mean(&d1t1),
470            -(&d1t0 / mean(&d1t0)),
471            -(&d0t1 / mean(&d0t1)),
472            &d0t0 / mean(&d0t0),
473        ),
474        (None, false) => (
475            Array1::ones(n),
476            &d1t1 / (p * lambda),
477            -(&d1t0 / (p * (1.0 - lambda))),
478            -(&d0t1 / ((1.0 - p) * lambda)),
479            &d0t0 / ((1.0 - p) * (1.0 - lambda)),
480        ),
481    };
482    let contrast = &g11 - &g10 - &g01 + &g00;
483    let psi_b = &weight_a * &contrast
484        + &w11 * &(&y - &g11)
485        + &w10 * &(&y - &g10)
486        + &w00 * &(&y - &g00)
487        + &w01 * &(&y - &g01);
488
489    // Sensitivity: outcome residual y − ĝ(D, T, X); Riesz representer of the ATT.
490    let g_hat = &d0t0 * &g00 + &d0t1 * &g01 + &d1t0 * &g10 + &d1t1 * &g11;
491    let outcome_residual = &y - &g_hat;
492    let (m_alpha, riesz) = match (&m, settings.in_sample_normalization) {
493        (Some(m), true) => {
494            let odds = m / &(1.0 - m);
495            let (w01, w00) = (&d0t1 * &odds, &d0t0 * &odds);
496            let (mw01, mw00) = (mean(&w01), mean(&w00));
497            let (m11, m10) = (mean(&d1t1), mean(&d1t0));
498            let m_alpha = &d / p * &(1.0 / m11 + 1.0 / m10 + &odds / mw01 + &odds / mw00);
499            let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&w01 / mw01) + &(&w00 / mw00);
500            (m_alpha, riesz)
501        }
502        (Some(m), false) => {
503            let odds = m / &(1.0 - m);
504            let m_alpha = &d / (p * p) * (1.0 / lambda + 1.0 / (1.0 - lambda)) * &(1.0 + &odds);
505            // DoubleML's form: only one term of each factor is non-zero per row.
506            let rr_1 = &t / (p * lambda) + &((1.0 - &t) / (p * (1.0 - lambda)));
507            let rr_2 = &d + &((1.0 - &d) * &odds);
508            (m_alpha, rr_1 * &rr_2)
509        }
510        (None, true) => {
511            let (m11, m10, m01, m00) = (mean(&d1t1), mean(&d1t0), mean(&d0t1), mean(&d0t0));
512            let m_alpha = Array1::from_elem(n, 1.0 / m11 + 1.0 / m10 + 1.0 / m01 + 1.0 / m00);
513            let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&d0t1 / m01) + &(&d0t0 / m00);
514            (m_alpha, riesz)
515        }
516        (None, false) => {
517            let (c11, c10) = (p * lambda, p * (1.0 - lambda));
518            let (c01, c00) = ((1.0 - p) * lambda, (1.0 - p) * (1.0 - lambda));
519            let m_alpha = Array1::from_elem(n, 1.0 / c11 + 1.0 / c10 + 1.0 / c01 + 1.0 / c00);
520            let riesz = &d1t1 / c11 - &(&d1t0 / c10) - &(&d0t1 / c01) + &(&d0t0 / c00);
521            (m_alpha, riesz)
522        }
523    };
524    let nu2 = 2.0 * &m_alpha - &riesz.mapv(|a| a * a);
525
526    let mut predictions = vec![
527        ("ml_g_d0_t0", g00),
528        ("ml_g_d0_t1", g01),
529        ("ml_g_d1_t0", g10),
530        ("ml_g_d1_t1", g11),
531    ];
532    let in_cell = |c: &Array1<f64>, name: &'static str| {
533        NuisanceTarget::regression(name, y.to_owned()).only_where(|i| c[i] == 1.0)
534    };
535    let mut targets = vec![
536        in_cell(&d0t0, "ml_g_d0_t0"),
537        in_cell(&d0t1, "ml_g_d0_t1"),
538        in_cell(&d1t0, "ml_g_d1_t0"),
539        in_cell(&d1t1, "ml_g_d1_t1"),
540    ];
541    if let Some(m) = m {
542        predictions.push(("ml_m", m));
543        targets.push(NuisanceTarget::classification("ml_m", d.clone()));
544    }
545    Ok(
546        ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions)
547            .with_sensitivity(SensitivityParts {
548                outcome_residual,
549                theta_factor: None,
550                riesz,
551                nu2: Nu2Parts::Elementwise(nu2),
552            })
553            .with_targets(targets),
554    )
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::learner::{LogisticRegression, Ols};
561    use ndarray::array;
562
563    #[test]
564    fn validation_rules() {
565        let x = array![[1.0], [2.0], [3.0], [4.0]];
566        let data = DmlData::new(array![1.0, 2.0, 3.0, 4.0], array![0.0, 1.0, 0.0, 1.0], x).unwrap();
567        // Observational score needs ml_m.
568        let no_m = Did::from_boxed(Box::new(Ols::new()), None);
569        assert!(no_m.validate(&data).is_err());
570        assert!(Did::experimental(Ols::new()).validate(&data).is_ok());
571        assert!(Did::new(Ols::new(), LogisticRegression::default())
572            .validate(&data)
573            .is_ok());
574        // Repeated cross-sections need a time indicator.
575        assert!(DidCs::experimental(Ols::new()).validate(&data).is_err());
576        let with_t = data
577            .with_time_indicator(array![0.0, 0.0, 1.0, 1.0])
578            .unwrap();
579        assert!(DidCs::experimental(Ols::new()).validate(&with_t).is_ok());
580    }
581
582    #[test]
583    fn cell_indicators_partition_rows() {
584        let d = array![0.0, 0.0, 1.0, 1.0];
585        let t = array![0.0, 1.0, 0.0, 1.0];
586        let total = cell(&d, t.view(), 0.0, 0.0)
587            + cell(&d, t.view(), 0.0, 1.0)
588            + cell(&d, t.view(), 1.0, 0.0)
589            + cell(&d, t.view(), 1.0, 1.0);
590        assert_eq!(total, Array1::ones(4));
591        assert_eq!(cell(&d, t.view(), 1.0, 0.0), array![0.0, 0.0, 1.0, 0.0]);
592    }
593}