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