1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum DidScore {
26 #[default]
29 Observational,
30 Experimental,
32}
33
34fn mean(v: &Array1<f64>) -> f64 {
35 v.mean().expect("n > 0")
36}
37
38fn 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
63macro_rules! did_builders {
65 () => {
66 pub fn score(mut self, score: DidScore) -> Self {
68 self.score = score;
69 self
70 }
71
72 pub fn in_sample_normalization(mut self, normalize: bool) -> Self {
75 self.in_sample_normalization = normalize;
76 self
77 }
78
79 pub fn clipping_threshold(mut self, t: f64) -> Self {
81 self.clipping_threshold = t;
82 self
83 }
84 };
85}
86
87pub 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 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 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
160pub(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
171pub(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 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 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
267pub 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 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 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
306fn 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
358pub(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 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 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 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 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 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}