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, nuisance_api, ExternalRef, LinearScoreModel, NuisanceLearners,
17    NuisanceTarget, ScoreEstimate, 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    nuisance: NuisanceLearners,
103    ml_g: Box<dyn Regressor>,
104    ml_m: Option<Box<dyn Classifier>>,
105    score: DidScore,
106    in_sample_normalization: bool,
107    clipping_threshold: f64,
108    calibration: Calibration,
109    splitting: SampleSplitting,
110}
111
112impl Did {
113    /// Observational design (doubly robust score).
114    pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
115        Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
116    }
117
118    /// Randomised design: only the outcome model is needed.
119    pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
120        Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
121    }
122
123    pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
124        Self {
125            nuisance: NuisanceLearners::default(),
126            ml_g,
127            ml_m,
128            score: DidScore::default(),
129            in_sample_normalization: true,
130            clipping_threshold: 1e-2,
131            calibration: Calibration::None,
132            splitting: SampleSplitting::default(),
133        }
134    }
135
136    did_builders!();
137    dml_model_api!();
138    nuisance_api!();
139}
140
141impl LinearScoreModel for Did {
142    fn name(&self) -> String {
143        match self.score {
144            DidScore::Observational => "DID panel (observational)".into(),
145            DidScore::Experimental => "DID panel (experimental)".into(),
146        }
147    }
148
149    fn validate(&self, data: &DmlData) -> Result<()> {
150        self.nuisance.check("DID", &["ml_g0", "ml_g1"], &["ml_m"])?;
151        validate_common(
152            data,
153            self.score,
154            self.ml_m.is_some(),
155            self.clipping_threshold,
156        )
157    }
158
159    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
160        let ScoreInput { y, d, x, .. } = *input;
161        panel_did_score(
162            &DidSettings {
163                ml_g: self.ml_g.as_ref(),
164                ml_m: self.ml_m.as_deref(),
165                score: self.score,
166                in_sample_normalization: self.in_sample_normalization,
167                clipping_threshold: self.clipping_threshold,
168                calibration: self.calibration,
169                external: input.external_ref(),
170                nuisance: Some(&self.nuisance),
171            },
172            x,
173            y,
174            d,
175            folds,
176        )
177    }
178}
179
180/// Learners and options of the two-period DID scores, shared by [`Did`],
181/// [`DidCs`] and the per-(cohort, period) models of
182/// [`StaggeredDid`](crate::StaggeredDid).
183pub(crate) struct DidSettings<'a> {
184    pub ml_g: &'a dyn Regressor,
185    pub ml_m: Option<&'a dyn Classifier>,
186    pub score: DidScore,
187    pub in_sample_normalization: bool,
188    pub clipping_threshold: f64,
189    pub calibration: Calibration,
190    /// User-supplied nuisance predictions, if any.
191    pub external: Option<ExternalRef<'a>>,
192    /// Per-nuisance learners replacing `ml_g` / `ml_m`, if any.
193    pub nuisance: Option<&'a NuisanceLearners>,
194}
195
196impl<'a> DidSettings<'a> {
197    fn regressor(&self, name: &str) -> &'a dyn Regressor {
198        match self.nuisance {
199            Some(nu) => nu.regressor(name, self.ml_g),
200            None => self.ml_g,
201        }
202    }
203
204    fn classifier(&self, name: &str, default: &'a dyn Classifier) -> &'a dyn Classifier {
205        match self.nuisance {
206            Some(nu) => nu.classifier(name, default),
207            None => default,
208        }
209    }
210}
211
212/// Panel DID score elements for outcome changes `y`, group indicator `d`.
213pub(crate) fn panel_did_score(
214    settings: &DidSettings<'_>,
215    x: ArrayView2<'_, f64>,
216    y: ArrayView1<'_, f64>,
217    d: ArrayView1<'_, f64>,
218    folds: &[Fold],
219) -> Result<ScoreEstimate> {
220    let treated: Vec<bool> = d.iter().map(|&v| v == 1.0).collect();
221    let control: Vec<bool> = treated.iter().map(|b| !b).collect();
222    let n = y.len();
223    let ext = settings.external;
224    let g0 = external_or(ext, n, "ml_g0", || {
225        cross_fit_predict_subset(settings.regressor("ml_g0"), x, y, folds, &control)
226    })?;
227    let g1 = external_or(ext, n, "ml_g1", || {
228        cross_fit_predict_subset(settings.regressor("ml_g1"), x, y, folds, &treated)
229    })?;
230    let d = d.to_owned();
231    let n = d.len();
232    let p = mean(&d);
233    let resid_d0: Array1<f64> = &y - &g0;
234    let mut predictions = vec![("ml_g0", g0.clone()), ("ml_g1", g1.clone())];
235
236    let (weight_a, psi_b, m_hat) = match settings.score {
237        DidScore::Observational => {
238            let ml_m = settings.ml_m.ok_or_else(|| {
239                DmlError::InvalidArgument("the observational DID score needs ml_m".into())
240            })?;
241            let m_raw = external_or(settings.external, d.len(), "ml_m", || {
242                cross_fit_predict_proba(settings.classifier("ml_m", ml_m), x, d.view(), folds)
243            })?;
244            let m = adjust_propensity(
245                m_raw,
246                d.view(),
247                folds,
248                settings.clipping_threshold,
249                settings.calibration,
250            )?;
251            let (weight_a, weight_resid) = if settings.in_sample_normalization {
252                let weight_a = &d / mean(&d);
253                let propensity_weight = (1.0 - &d) * &m / &(1.0 - &m);
254                let weight_resid = &weight_a - &(&propensity_weight / mean(&propensity_weight));
255                (weight_a, weight_resid)
256            } else {
257                (&d / p, (&d - &m) / &(p * &(1.0 - &m)))
258            };
259            predictions.push(("ml_m", m.clone()));
260            let psi_b = &weight_resid * &resid_d0;
261            (weight_a, psi_b, Some(m))
262        }
263        DidScore::Experimental => {
264            let (weight_g0, weight_g1, weight_resid) = if settings.in_sample_normalization {
265                let share = &d / mean(&d);
266                let control_share = (1.0 - &d) / mean(&(1.0 - &d));
267                (&share - 1.0, 1.0 - &share, &share - &control_share)
268            } else {
269                (&d / p - 1.0, 1.0 - &d / p, (&d - p) / (p * (1.0 - p)))
270            };
271            let psi_b = &weight_g0 * &g0 + &weight_g1 * &g1 + &weight_resid * &resid_d0;
272            (Array1::ones(n), psi_b, None)
273        }
274    };
275
276    // Sensitivity: outcome residual y − ĝ(D, X); Riesz representer of the ATT.
277    let outcome_residual = &y - &(&d * &g1) - &(&(1.0 - &d) * &g0);
278    let (m_alpha, riesz) = match &m_hat {
279        Some(m) => {
280            let odds = m / &(1.0 - m);
281            if settings.in_sample_normalization {
282                let weight_d0 = (1.0 - &d) * &odds;
283                let mean_w = mean(&weight_d0);
284                (
285                    &d / p * &(1.0 / p + &odds / mean_w),
286                    &d / p - &(&weight_d0 / mean_w),
287                )
288            } else {
289                (
290                    &d / (p * p) * &(1.0 + &odds),
291                    &d / p - &((1.0 - &d) / p * &odds),
292                )
293            }
294        }
295        // Experimental: the same with or without normalisation.
296        None => (
297            Array1::from_elem(n, 1.0 / p + 1.0 / (1.0 - p)),
298            &d / p - &((1.0 - &d) / (1.0 - p)),
299        ),
300    };
301    let nu2 = 2.0 * &m_alpha - &riesz.mapv(|a| a * a);
302    let mut targets = vec![
303        NuisanceTarget::regression("ml_g0", y.to_owned()).only_where(|i| d[i] == 0.0),
304        NuisanceTarget::regression("ml_g1", y.to_owned()).only_where(|i| d[i] == 1.0),
305    ];
306    if m_hat.is_some() {
307        targets.push(NuisanceTarget::classification("ml_m", d.clone()));
308    }
309    Ok(
310        ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions)
311            .with_sensitivity(SensitivityParts {
312                outcome_residual,
313                theta_factor: None,
314                riesz,
315                nu2: Nu2Parts::Elementwise(nu2),
316            })
317            .with_targets(targets),
318    )
319}
320
321/// Difference-in-differences with **repeated cross-sections**: the data need
322/// a 0/1 time indicator ([`DmlData::with_time_indicator`]). `ml_g` learns
323/// `E[Y | D = d, T = t, X]` separately in each of the four group × period
324/// cells, `ml_m` the propensity `P(D = 1 | X)` (observational score only).
325pub struct DidCs {
326    nuisance: NuisanceLearners,
327    ml_g: Box<dyn Regressor>,
328    ml_m: Option<Box<dyn Classifier>>,
329    score: DidScore,
330    in_sample_normalization: bool,
331    clipping_threshold: f64,
332    calibration: Calibration,
333    splitting: SampleSplitting,
334}
335
336impl DidCs {
337    /// Observational design (doubly robust score).
338    pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
339        Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
340    }
341
342    /// Randomised design: only the outcome model is needed.
343    pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
344        Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
345    }
346
347    pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
348        Self {
349            nuisance: NuisanceLearners::default(),
350            ml_g,
351            ml_m,
352            score: DidScore::default(),
353            in_sample_normalization: true,
354            clipping_threshold: 1e-2,
355            calibration: Calibration::None,
356            splitting: SampleSplitting::default(),
357        }
358    }
359
360    did_builders!();
361    dml_model_api!();
362    nuisance_api!();
363}
364
365/// Cell indicator `1{D = a, T = b}`.
366fn cell(d: &Array1<f64>, t: ArrayView1<'_, f64>, a: f64, b: f64) -> Array1<f64> {
367    d.iter()
368        .zip(t)
369        .map(|(&di, &ti)| f64::from(di == a && ti == b))
370        .collect()
371}
372
373impl LinearScoreModel for DidCs {
374    fn name(&self) -> String {
375        match self.score {
376            DidScore::Observational => "DID repeated cross-sections (observational)".into(),
377            DidScore::Experimental => "DID repeated cross-sections (experimental)".into(),
378        }
379    }
380
381    fn validate(&self, data: &DmlData) -> Result<()> {
382        self.nuisance.check(
383            "DIDCS",
384            &["ml_g_d0_t0", "ml_g_d0_t1", "ml_g_d1_t0", "ml_g_d1_t1"],
385            &["ml_m"],
386        )?;
387        validate_common(
388            data,
389            self.score,
390            self.ml_m.is_some(),
391            self.clipping_threshold,
392        )?;
393        if data.time().is_none() {
394            return Err(DmlError::InvalidArgument(
395                "repeated cross-section DID needs a time indicator: use DmlData::with_time_indicator"
396                    .into(),
397            ));
398        }
399        Ok(())
400    }
401
402    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
403        let ScoreInput { y, d, x, t, .. } = *input;
404        let t = t.ok_or_else(|| DmlError::InvalidArgument("missing time indicator".into()))?;
405        cs_did_score(
406            &DidSettings {
407                ml_g: self.ml_g.as_ref(),
408                ml_m: self.ml_m.as_deref(),
409                score: self.score,
410                in_sample_normalization: self.in_sample_normalization,
411                clipping_threshold: self.clipping_threshold,
412                calibration: self.calibration,
413                external: input.external_ref(),
414                nuisance: Some(&self.nuisance),
415            },
416            x,
417            y,
418            d,
419            t,
420            folds,
421        )
422    }
423}
424
425/// Repeated cross-section DID score elements for outcome levels `y`, group
426/// indicator `d` and post-period indicator `t`.
427pub(crate) fn cs_did_score(
428    settings: &DidSettings<'_>,
429    x: ArrayView2<'_, f64>,
430    y: ArrayView1<'_, f64>,
431    d: ArrayView1<'_, f64>,
432    t: ArrayView1<'_, f64>,
433    folds: &[Fold],
434) -> Result<ScoreEstimate> {
435    let d = d.to_owned();
436    let (d1t1, d1t0) = (cell(&d, t, 1.0, 1.0), cell(&d, t, 1.0, 0.0));
437    let (d0t1, d0t0) = (cell(&d, t, 0.0, 1.0), cell(&d, t, 0.0, 0.0));
438    let fit_cell = |c: &Array1<f64>, name: &str| -> Result<Array1<f64>> {
439        let keep: Vec<bool> = c.iter().map(|&v| v == 1.0).collect();
440        external_or(settings.external, y.len(), name, || {
441            cross_fit_predict_subset(settings.regressor(name), x, y, folds, &keep)
442        })
443    };
444    let (g00, g01) = (
445        fit_cell(&d0t0, "ml_g_d0_t0")?,
446        fit_cell(&d0t1, "ml_g_d0_t1")?,
447    );
448    let (g10, g11) = (
449        fit_cell(&d1t0, "ml_g_d1_t0")?,
450        fit_cell(&d1t1, "ml_g_d1_t1")?,
451    );
452    let n = d.len();
453    let p = mean(&d);
454    let lambda = t.mean().expect("n > 0");
455
456    let m = match settings.score {
457        DidScore::Observational => {
458            let ml_m = settings.ml_m.ok_or_else(|| {
459                DmlError::InvalidArgument("the observational DID score needs ml_m".into())
460            })?;
461            let m_raw = external_or(settings.external, d.len(), "ml_m", || {
462                cross_fit_predict_proba(settings.classifier("ml_m", ml_m), x, d.view(), folds)
463            })?;
464            Some(adjust_propensity(
465                m_raw,
466                d.view(),
467                folds,
468                settings.clipping_threshold,
469                settings.calibration,
470            )?)
471        }
472        DidScore::Experimental => None,
473    };
474
475    // Weights on the four residuals (y − ĝ_cell) and on the ĝ contrast.
476    let (weight_a, w11, w10, w01, w00) = match (&m, settings.in_sample_normalization) {
477        (Some(m), true) => {
478            let odds = m / &(1.0 - m);
479            let u01 = &d0t1 * &odds;
480            let u00 = &d0t0 * &odds;
481            (
482                &d / mean(&d),
483                &d1t1 / mean(&d1t1),
484                -(&d1t0 / mean(&d1t0)),
485                -(&u01 / mean(&u01)),
486                &u00 / mean(&u00),
487            )
488        }
489        (Some(m), false) => {
490            let odds = m / &(1.0 - m);
491            (
492                &d / p,
493                &d1t1 / (p * lambda),
494                -(&d1t0 / (p * (1.0 - lambda))),
495                -(&d0t1 / (p * lambda) * &odds),
496                &d0t0 / (p * (1.0 - lambda)) * &odds,
497            )
498        }
499        (None, true) => (
500            Array1::ones(n),
501            &d1t1 / mean(&d1t1),
502            -(&d1t0 / mean(&d1t0)),
503            -(&d0t1 / mean(&d0t1)),
504            &d0t0 / mean(&d0t0),
505        ),
506        (None, false) => (
507            Array1::ones(n),
508            &d1t1 / (p * lambda),
509            -(&d1t0 / (p * (1.0 - lambda))),
510            -(&d0t1 / ((1.0 - p) * lambda)),
511            &d0t0 / ((1.0 - p) * (1.0 - lambda)),
512        ),
513    };
514    let contrast = &g11 - &g10 - &g01 + &g00;
515    let psi_b = &weight_a * &contrast
516        + &w11 * &(&y - &g11)
517        + &w10 * &(&y - &g10)
518        + &w00 * &(&y - &g00)
519        + &w01 * &(&y - &g01);
520
521    // Sensitivity: outcome residual y − ĝ(D, T, X); Riesz representer of the ATT.
522    let g_hat = &d0t0 * &g00 + &d0t1 * &g01 + &d1t0 * &g10 + &d1t1 * &g11;
523    let outcome_residual = &y - &g_hat;
524    let (m_alpha, riesz) = match (&m, settings.in_sample_normalization) {
525        (Some(m), true) => {
526            let odds = m / &(1.0 - m);
527            let (w01, w00) = (&d0t1 * &odds, &d0t0 * &odds);
528            let (mw01, mw00) = (mean(&w01), mean(&w00));
529            let (m11, m10) = (mean(&d1t1), mean(&d1t0));
530            let m_alpha = &d / p * &(1.0 / m11 + 1.0 / m10 + &odds / mw01 + &odds / mw00);
531            let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&w01 / mw01) + &(&w00 / mw00);
532            (m_alpha, riesz)
533        }
534        (Some(m), false) => {
535            let odds = m / &(1.0 - m);
536            let m_alpha = &d / (p * p) * (1.0 / lambda + 1.0 / (1.0 - lambda)) * &(1.0 + &odds);
537            // DoubleML's form: only one term of each factor is non-zero per row.
538            let rr_1 = &t / (p * lambda) + &((1.0 - &t) / (p * (1.0 - lambda)));
539            let rr_2 = &d + &((1.0 - &d) * &odds);
540            (m_alpha, rr_1 * &rr_2)
541        }
542        (None, true) => {
543            let (m11, m10, m01, m00) = (mean(&d1t1), mean(&d1t0), mean(&d0t1), mean(&d0t0));
544            let m_alpha = Array1::from_elem(n, 1.0 / m11 + 1.0 / m10 + 1.0 / m01 + 1.0 / m00);
545            let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&d0t1 / m01) + &(&d0t0 / m00);
546            (m_alpha, riesz)
547        }
548        (None, false) => {
549            let (c11, c10) = (p * lambda, p * (1.0 - lambda));
550            let (c01, c00) = ((1.0 - p) * lambda, (1.0 - p) * (1.0 - lambda));
551            let m_alpha = Array1::from_elem(n, 1.0 / c11 + 1.0 / c10 + 1.0 / c01 + 1.0 / c00);
552            let riesz = &d1t1 / c11 - &(&d1t0 / c10) - &(&d0t1 / c01) + &(&d0t0 / c00);
553            (m_alpha, riesz)
554        }
555    };
556    let nu2 = 2.0 * &m_alpha - &riesz.mapv(|a| a * a);
557
558    let mut predictions = vec![
559        ("ml_g_d0_t0", g00),
560        ("ml_g_d0_t1", g01),
561        ("ml_g_d1_t0", g10),
562        ("ml_g_d1_t1", g11),
563    ];
564    let in_cell = |c: &Array1<f64>, name: &'static str| {
565        NuisanceTarget::regression(name, y.to_owned()).only_where(|i| c[i] == 1.0)
566    };
567    let mut targets = vec![
568        in_cell(&d0t0, "ml_g_d0_t0"),
569        in_cell(&d0t1, "ml_g_d0_t1"),
570        in_cell(&d1t0, "ml_g_d1_t0"),
571        in_cell(&d1t1, "ml_g_d1_t1"),
572    ];
573    if let Some(m) = m {
574        predictions.push(("ml_m", m));
575        targets.push(NuisanceTarget::classification("ml_m", d.clone()));
576    }
577    Ok(
578        ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions)
579            .with_sensitivity(SensitivityParts {
580                outcome_residual,
581                theta_factor: None,
582                riesz,
583                nu2: Nu2Parts::Elementwise(nu2),
584            })
585            .with_targets(targets),
586    )
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::learner::{LogisticRegression, Ols};
593    use ndarray::array;
594
595    #[test]
596    fn validation_rules() {
597        let x = array![[1.0], [2.0], [3.0], [4.0]];
598        let data = DmlData::new(array![1.0, 2.0, 3.0, 4.0], array![0.0, 1.0, 0.0, 1.0], x).unwrap();
599        // Observational score needs ml_m.
600        let no_m = Did::from_boxed(Box::new(Ols::new()), None);
601        assert!(no_m.validate(&data).is_err());
602        assert!(Did::experimental(Ols::new()).validate(&data).is_ok());
603        assert!(Did::new(Ols::new(), LogisticRegression::default())
604            .validate(&data)
605            .is_ok());
606        // Repeated cross-sections need a time indicator.
607        assert!(DidCs::experimental(Ols::new()).validate(&data).is_err());
608        let with_t = data
609            .with_time_indicator(array![0.0, 0.0, 1.0, 1.0])
610            .unwrap();
611        assert!(DidCs::experimental(Ols::new()).validate(&with_t).is_ok());
612    }
613
614    #[test]
615    fn cell_indicators_partition_rows() {
616        let d = array![0.0, 0.0, 1.0, 1.0];
617        let t = array![0.0, 1.0, 0.0, 1.0];
618        let total = cell(&d, t.view(), 0.0, 0.0)
619            + cell(&d, t.view(), 0.0, 1.0)
620            + cell(&d, t.view(), 1.0, 0.0)
621            + cell(&d, t.view(), 1.0, 1.0);
622        assert_eq!(total, Array1::ones(4));
623        assert_eq!(cell(&d, t.view(), 1.0, 0.0), array![0.0, 0.0, 1.0, 0.0]);
624    }
625}