1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum DidScore {
30 #[default]
33 Observational,
34 Experimental,
36}
37
38fn mean(v: &Array1<f64>) -> f64 {
39 v.mean().expect("n > 0")
40}
41
42fn 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
67macro_rules! did_builders {
69 () => {
70 pub fn score(mut self, score: DidScore) -> Self {
72 self.score = score;
73 self
74 }
75
76 pub fn in_sample_normalization(mut self, normalize: bool) -> Self {
79 self.in_sample_normalization = normalize;
80 self
81 }
82
83 pub fn clipping_threshold(mut self, t: f64) -> Self {
85 self.clipping_threshold = t;
86 self
87 }
88
89 pub fn calibration(mut self, calibration: $crate::propensity::Calibration) -> Self {
92 self.calibration = calibration;
93 self
94 }
95 };
96}
97
98pub 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 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 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
180pub(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 pub external: Option<ExternalRef<'a>>,
192 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
212pub(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 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 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
321pub 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 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 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
365fn 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
425pub(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 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 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 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 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 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}