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, NuisanceTarget, 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    let mut targets = vec![
249        NuisanceTarget::regression("ml_g0", y.to_owned()).only_where(|i| d[i] == 0.0),
250        NuisanceTarget::regression("ml_g1", y.to_owned()).only_where(|i| d[i] == 1.0),
251    ];
252    if m_hat.is_some() {
253        targets.push(NuisanceTarget::classification("ml_m", d.clone()));
254    }
255    Ok(
256        ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions)
257            .with_sensitivity(SensitivityParts {
258                outcome_residual,
259                theta_factor: None,
260                riesz,
261                nu2: Nu2Parts::Elementwise(nu2),
262            })
263            .with_targets(targets),
264    )
265}
266
267/// Difference-in-differences with **repeated cross-sections**: the data need
268/// a 0/1 time indicator ([`DmlData::with_time_indicator`]). `ml_g` learns
269/// `E[Y | D = d, T = t, X]` separately in each of the four group × period
270/// cells, `ml_m` the propensity `P(D = 1 | X)` (observational score only).
271pub struct DidCs {
272    ml_g: Box<dyn Regressor>,
273    ml_m: Option<Box<dyn Classifier>>,
274    score: DidScore,
275    in_sample_normalization: bool,
276    clipping_threshold: f64,
277    splitting: SampleSplitting,
278}
279
280impl DidCs {
281    /// Observational design (doubly robust score).
282    pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
283        Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
284    }
285
286    /// Randomised design: only the outcome model is needed.
287    pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
288        Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
289    }
290
291    pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
292        Self {
293            ml_g,
294            ml_m,
295            score: DidScore::default(),
296            in_sample_normalization: true,
297            clipping_threshold: 1e-2,
298            splitting: SampleSplitting::default(),
299        }
300    }
301
302    did_builders!();
303    dml_model_api!();
304}
305
306/// Cell indicator `1{D = a, T = b}`.
307fn cell(d: &Array1<f64>, t: ArrayView1<'_, f64>, a: f64, b: f64) -> Array1<f64> {
308    d.iter()
309        .zip(t)
310        .map(|(&di, &ti)| f64::from(di == a && ti == b))
311        .collect()
312}
313
314impl LinearScoreModel for DidCs {
315    fn name(&self) -> String {
316        match self.score {
317            DidScore::Observational => "DID repeated cross-sections (observational)".into(),
318            DidScore::Experimental => "DID repeated cross-sections (experimental)".into(),
319        }
320    }
321
322    fn validate(&self, data: &DmlData) -> Result<()> {
323        validate_common(
324            data,
325            self.score,
326            self.ml_m.is_some(),
327            self.clipping_threshold,
328        )?;
329        if data.time().is_none() {
330            return Err(DmlError::InvalidArgument(
331                "repeated cross-section DID needs a time indicator: use DmlData::with_time_indicator"
332                    .into(),
333            ));
334        }
335        Ok(())
336    }
337
338    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
339        let ScoreInput { y, d, x, t, .. } = *input;
340        let t = t.ok_or_else(|| DmlError::InvalidArgument("missing time indicator".into()))?;
341        cs_did_score(
342            &DidSettings {
343                ml_g: self.ml_g.as_ref(),
344                ml_m: self.ml_m.as_deref(),
345                score: self.score,
346                in_sample_normalization: self.in_sample_normalization,
347                clipping_threshold: self.clipping_threshold,
348            },
349            x,
350            y,
351            d,
352            t,
353            folds,
354        )
355    }
356}
357
358/// Repeated cross-section DID score elements for outcome levels `y`, group
359/// indicator `d` and post-period indicator `t`.
360pub(crate) fn cs_did_score(
361    settings: &DidSettings<'_>,
362    x: ArrayView2<'_, f64>,
363    y: ArrayView1<'_, f64>,
364    d: ArrayView1<'_, f64>,
365    t: ArrayView1<'_, f64>,
366    folds: &[Fold],
367) -> Result<ScoreEstimate> {
368    let d = d.to_owned();
369    let (d1t1, d1t0) = (cell(&d, t, 1.0, 1.0), cell(&d, t, 1.0, 0.0));
370    let (d0t1, d0t0) = (cell(&d, t, 0.0, 1.0), cell(&d, t, 0.0, 0.0));
371    let fit_cell = |c: &Array1<f64>| -> Result<Array1<f64>> {
372        let keep: Vec<bool> = c.iter().map(|&v| v == 1.0).collect();
373        cross_fit_predict_subset(settings.ml_g, x, y, folds, &keep)
374    };
375    let (g00, g01) = (fit_cell(&d0t0)?, fit_cell(&d0t1)?);
376    let (g10, g11) = (fit_cell(&d1t0)?, fit_cell(&d1t1)?);
377    let n = d.len();
378    let p = mean(&d);
379    let lambda = t.mean().expect("n > 0");
380
381    let m = match settings.score {
382        DidScore::Observational => {
383            let ml_m = settings.ml_m.ok_or_else(|| {
384                DmlError::InvalidArgument("the observational DID score needs ml_m".into())
385            })?;
386            let c = settings.clipping_threshold;
387            Some(cross_fit_predict_proba(ml_m, x, d.view(), folds)?.mapv(|q| q.clamp(c, 1.0 - c)))
388        }
389        DidScore::Experimental => None,
390    };
391
392    // Weights on the four residuals (y − ĝ_cell) and on the ĝ contrast.
393    let (weight_a, w11, w10, w01, w00) = match (&m, settings.in_sample_normalization) {
394        (Some(m), true) => {
395            let odds = m / &(1.0 - m);
396            let u01 = &d0t1 * &odds;
397            let u00 = &d0t0 * &odds;
398            (
399                &d / mean(&d),
400                &d1t1 / mean(&d1t1),
401                -(&d1t0 / mean(&d1t0)),
402                -(&u01 / mean(&u01)),
403                &u00 / mean(&u00),
404            )
405        }
406        (Some(m), false) => {
407            let odds = m / &(1.0 - m);
408            (
409                &d / p,
410                &d1t1 / (p * lambda),
411                -(&d1t0 / (p * (1.0 - lambda))),
412                -(&d0t1 / (p * lambda) * &odds),
413                &d0t0 / (p * (1.0 - lambda)) * &odds,
414            )
415        }
416        (None, true) => (
417            Array1::ones(n),
418            &d1t1 / mean(&d1t1),
419            -(&d1t0 / mean(&d1t0)),
420            -(&d0t1 / mean(&d0t1)),
421            &d0t0 / mean(&d0t0),
422        ),
423        (None, false) => (
424            Array1::ones(n),
425            &d1t1 / (p * lambda),
426            -(&d1t0 / (p * (1.0 - lambda))),
427            -(&d0t1 / ((1.0 - p) * lambda)),
428            &d0t0 / ((1.0 - p) * (1.0 - lambda)),
429        ),
430    };
431    let contrast = &g11 - &g10 - &g01 + &g00;
432    let psi_b = &weight_a * &contrast
433        + &w11 * &(&y - &g11)
434        + &w10 * &(&y - &g10)
435        + &w00 * &(&y - &g00)
436        + &w01 * &(&y - &g01);
437
438    // Sensitivity: outcome residual y − ĝ(D, T, X); Riesz representer of the ATT.
439    let g_hat = &d0t0 * &g00 + &d0t1 * &g01 + &d1t0 * &g10 + &d1t1 * &g11;
440    let outcome_residual = &y - &g_hat;
441    let (m_alpha, riesz) = match (&m, settings.in_sample_normalization) {
442        (Some(m), true) => {
443            let odds = m / &(1.0 - m);
444            let (w01, w00) = (&d0t1 * &odds, &d0t0 * &odds);
445            let (mw01, mw00) = (mean(&w01), mean(&w00));
446            let (m11, m10) = (mean(&d1t1), mean(&d1t0));
447            let m_alpha = &d / p * &(1.0 / m11 + 1.0 / m10 + &odds / mw01 + &odds / mw00);
448            let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&w01 / mw01) + &(&w00 / mw00);
449            (m_alpha, riesz)
450        }
451        (Some(m), false) => {
452            let odds = m / &(1.0 - m);
453            let m_alpha = &d / (p * p) * (1.0 / lambda + 1.0 / (1.0 - lambda)) * &(1.0 + &odds);
454            // DoubleML's form: only one term of each factor is non-zero per row.
455            let rr_1 = &t / (p * lambda) + &((1.0 - &t) / (p * (1.0 - lambda)));
456            let rr_2 = &d + &((1.0 - &d) * &odds);
457            (m_alpha, rr_1 * &rr_2)
458        }
459        (None, true) => {
460            let (m11, m10, m01, m00) = (mean(&d1t1), mean(&d1t0), mean(&d0t1), mean(&d0t0));
461            let m_alpha = Array1::from_elem(n, 1.0 / m11 + 1.0 / m10 + 1.0 / m01 + 1.0 / m00);
462            let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&d0t1 / m01) + &(&d0t0 / m00);
463            (m_alpha, riesz)
464        }
465        (None, false) => {
466            let (c11, c10) = (p * lambda, p * (1.0 - lambda));
467            let (c01, c00) = ((1.0 - p) * lambda, (1.0 - p) * (1.0 - lambda));
468            let m_alpha = Array1::from_elem(n, 1.0 / c11 + 1.0 / c10 + 1.0 / c01 + 1.0 / c00);
469            let riesz = &d1t1 / c11 - &(&d1t0 / c10) - &(&d0t1 / c01) + &(&d0t0 / c00);
470            (m_alpha, riesz)
471        }
472    };
473    let nu2 = 2.0 * &m_alpha - &riesz.mapv(|a| a * a);
474
475    let mut predictions = vec![
476        ("ml_g_d0_t0", g00),
477        ("ml_g_d0_t1", g01),
478        ("ml_g_d1_t0", g10),
479        ("ml_g_d1_t1", g11),
480    ];
481    let in_cell = |c: &Array1<f64>, name: &'static str| {
482        NuisanceTarget::regression(name, y.to_owned()).only_where(|i| c[i] == 1.0)
483    };
484    let mut targets = vec![
485        in_cell(&d0t0, "ml_g_d0_t0"),
486        in_cell(&d0t1, "ml_g_d0_t1"),
487        in_cell(&d1t0, "ml_g_d1_t0"),
488        in_cell(&d1t1, "ml_g_d1_t1"),
489    ];
490    if let Some(m) = m {
491        predictions.push(("ml_m", m));
492        targets.push(NuisanceTarget::classification("ml_m", d.clone()));
493    }
494    Ok(
495        ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions)
496            .with_sensitivity(SensitivityParts {
497                outcome_residual,
498                theta_factor: None,
499                riesz,
500                nu2: Nu2Parts::Elementwise(nu2),
501            })
502            .with_targets(targets),
503    )
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::learner::{LogisticRegression, Ols};
510    use ndarray::array;
511
512    #[test]
513    fn validation_rules() {
514        let x = array![[1.0], [2.0], [3.0], [4.0]];
515        let data = DmlData::new(array![1.0, 2.0, 3.0, 4.0], array![0.0, 1.0, 0.0, 1.0], x).unwrap();
516        // Observational score needs ml_m.
517        let no_m = Did::from_boxed(Box::new(Ols::new()), None);
518        assert!(no_m.validate(&data).is_err());
519        assert!(Did::experimental(Ols::new()).validate(&data).is_ok());
520        assert!(Did::new(Ols::new(), LogisticRegression::default())
521            .validate(&data)
522            .is_ok());
523        // Repeated cross-sections need a time indicator.
524        assert!(DidCs::experimental(Ols::new()).validate(&data).is_err());
525        let with_t = data
526            .with_time_indicator(array![0.0, 0.0, 1.0, 1.0])
527            .unwrap();
528        assert!(DidCs::experimental(Ols::new()).validate(&with_t).is_ok());
529    }
530
531    #[test]
532    fn cell_indicators_partition_rows() {
533        let d = array![0.0, 0.0, 1.0, 1.0];
534        let t = array![0.0, 1.0, 0.0, 1.0];
535        let total = cell(&d, t.view(), 0.0, 0.0)
536            + cell(&d, t.view(), 0.0, 1.0)
537            + cell(&d, t.view(), 1.0, 0.0)
538            + cell(&d, t.view(), 1.0, 1.0);
539        assert_eq!(total, Array1::ones(4));
540        assert_eq!(cell(&d, t.view(), 1.0, 0.0), array![0.0, 0.0, 1.0, 0.0]);
541    }
542}