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