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