1use 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;
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 Ok(
249 ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions).with_sensitivity(
250 SensitivityParts {
251 outcome_residual,
252 theta_factor: None,
253 riesz,
254 nu2: Nu2Parts::Elementwise(nu2),
255 },
256 ),
257 )
258}
259
260pub struct DidCs {
265 ml_g: Box<dyn Regressor>,
266 ml_m: Option<Box<dyn Classifier>>,
267 score: DidScore,
268 in_sample_normalization: bool,
269 clipping_threshold: f64,
270 splitting: SampleSplitting,
271}
272
273impl DidCs {
274 pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
276 Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
277 }
278
279 pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
281 Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
282 }
283
284 pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
285 Self {
286 ml_g,
287 ml_m,
288 score: DidScore::default(),
289 in_sample_normalization: true,
290 clipping_threshold: 1e-2,
291 splitting: SampleSplitting::default(),
292 }
293 }
294
295 did_builders!();
296 dml_model_api!();
297}
298
299fn cell(d: &Array1<f64>, t: ArrayView1<'_, f64>, a: f64, b: f64) -> Array1<f64> {
301 d.iter()
302 .zip(t)
303 .map(|(&di, &ti)| f64::from(di == a && ti == b))
304 .collect()
305}
306
307impl LinearScoreModel for DidCs {
308 fn name(&self) -> String {
309 match self.score {
310 DidScore::Observational => "DID repeated cross-sections (observational)".into(),
311 DidScore::Experimental => "DID repeated cross-sections (experimental)".into(),
312 }
313 }
314
315 fn validate(&self, data: &DmlData) -> Result<()> {
316 validate_common(
317 data,
318 self.score,
319 self.ml_m.is_some(),
320 self.clipping_threshold,
321 )?;
322 if data.time().is_none() {
323 return Err(DmlError::InvalidArgument(
324 "repeated cross-section DID needs a time indicator: use DmlData::with_time_indicator"
325 .into(),
326 ));
327 }
328 Ok(())
329 }
330
331 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
332 let ScoreInput { y, d, x, t, .. } = *input;
333 let t = t.ok_or_else(|| DmlError::InvalidArgument("missing time indicator".into()))?;
334 cs_did_score(
335 &DidSettings {
336 ml_g: self.ml_g.as_ref(),
337 ml_m: self.ml_m.as_deref(),
338 score: self.score,
339 in_sample_normalization: self.in_sample_normalization,
340 clipping_threshold: self.clipping_threshold,
341 },
342 x,
343 y,
344 d,
345 t,
346 folds,
347 )
348 }
349}
350
351pub(crate) fn cs_did_score(
354 settings: &DidSettings<'_>,
355 x: ArrayView2<'_, f64>,
356 y: ArrayView1<'_, f64>,
357 d: ArrayView1<'_, f64>,
358 t: ArrayView1<'_, f64>,
359 folds: &[Fold],
360) -> Result<ScoreEstimate> {
361 let d = d.to_owned();
362 let (d1t1, d1t0) = (cell(&d, t, 1.0, 1.0), cell(&d, t, 1.0, 0.0));
363 let (d0t1, d0t0) = (cell(&d, t, 0.0, 1.0), cell(&d, t, 0.0, 0.0));
364 let fit_cell = |c: &Array1<f64>| -> Result<Array1<f64>> {
365 let keep: Vec<bool> = c.iter().map(|&v| v == 1.0).collect();
366 cross_fit_predict_subset(settings.ml_g, x, y, folds, &keep)
367 };
368 let (g00, g01) = (fit_cell(&d0t0)?, fit_cell(&d0t1)?);
369 let (g10, g11) = (fit_cell(&d1t0)?, fit_cell(&d1t1)?);
370 let n = d.len();
371 let p = mean(&d);
372 let lambda = t.mean().expect("n > 0");
373
374 let m = match settings.score {
375 DidScore::Observational => {
376 let ml_m = settings.ml_m.ok_or_else(|| {
377 DmlError::InvalidArgument("the observational DID score needs ml_m".into())
378 })?;
379 let c = settings.clipping_threshold;
380 Some(cross_fit_predict_proba(ml_m, x, d.view(), folds)?.mapv(|q| q.clamp(c, 1.0 - c)))
381 }
382 DidScore::Experimental => None,
383 };
384
385 let (weight_a, w11, w10, w01, w00) = match (&m, settings.in_sample_normalization) {
387 (Some(m), true) => {
388 let odds = m / &(1.0 - m);
389 let u01 = &d0t1 * &odds;
390 let u00 = &d0t0 * &odds;
391 (
392 &d / mean(&d),
393 &d1t1 / mean(&d1t1),
394 -(&d1t0 / mean(&d1t0)),
395 -(&u01 / mean(&u01)),
396 &u00 / mean(&u00),
397 )
398 }
399 (Some(m), false) => {
400 let odds = m / &(1.0 - m);
401 (
402 &d / p,
403 &d1t1 / (p * lambda),
404 -(&d1t0 / (p * (1.0 - lambda))),
405 -(&d0t1 / (p * lambda) * &odds),
406 &d0t0 / (p * (1.0 - lambda)) * &odds,
407 )
408 }
409 (None, true) => (
410 Array1::ones(n),
411 &d1t1 / mean(&d1t1),
412 -(&d1t0 / mean(&d1t0)),
413 -(&d0t1 / mean(&d0t1)),
414 &d0t0 / mean(&d0t0),
415 ),
416 (None, false) => (
417 Array1::ones(n),
418 &d1t1 / (p * lambda),
419 -(&d1t0 / (p * (1.0 - lambda))),
420 -(&d0t1 / ((1.0 - p) * lambda)),
421 &d0t0 / ((1.0 - p) * (1.0 - lambda)),
422 ),
423 };
424 let contrast = &g11 - &g10 - &g01 + &g00;
425 let psi_b = &weight_a * &contrast
426 + &w11 * &(&y - &g11)
427 + &w10 * &(&y - &g10)
428 + &w00 * &(&y - &g00)
429 + &w01 * &(&y - &g01);
430
431 let g_hat = &d0t0 * &g00 + &d0t1 * &g01 + &d1t0 * &g10 + &d1t1 * &g11;
433 let outcome_residual = &y - &g_hat;
434 let (m_alpha, riesz) = match (&m, settings.in_sample_normalization) {
435 (Some(m), true) => {
436 let odds = m / &(1.0 - m);
437 let (w01, w00) = (&d0t1 * &odds, &d0t0 * &odds);
438 let (mw01, mw00) = (mean(&w01), mean(&w00));
439 let (m11, m10) = (mean(&d1t1), mean(&d1t0));
440 let m_alpha = &d / p * &(1.0 / m11 + 1.0 / m10 + &odds / mw01 + &odds / mw00);
441 let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&w01 / mw01) + &(&w00 / mw00);
442 (m_alpha, riesz)
443 }
444 (Some(m), false) => {
445 let odds = m / &(1.0 - m);
446 let m_alpha = &d / (p * p) * (1.0 / lambda + 1.0 / (1.0 - lambda)) * &(1.0 + &odds);
447 let rr_1 = &t / (p * lambda) + &((1.0 - &t) / (p * (1.0 - lambda)));
449 let rr_2 = &d + &((1.0 - &d) * &odds);
450 (m_alpha, rr_1 * &rr_2)
451 }
452 (None, true) => {
453 let (m11, m10, m01, m00) = (mean(&d1t1), mean(&d1t0), mean(&d0t1), mean(&d0t0));
454 let m_alpha = Array1::from_elem(n, 1.0 / m11 + 1.0 / m10 + 1.0 / m01 + 1.0 / m00);
455 let riesz = &d1t1 / m11 - &(&d1t0 / m10) - &(&d0t1 / m01) + &(&d0t0 / m00);
456 (m_alpha, riesz)
457 }
458 (None, false) => {
459 let (c11, c10) = (p * lambda, p * (1.0 - lambda));
460 let (c01, c00) = ((1.0 - p) * lambda, (1.0 - p) * (1.0 - lambda));
461 let m_alpha = Array1::from_elem(n, 1.0 / c11 + 1.0 / c10 + 1.0 / c01 + 1.0 / c00);
462 let riesz = &d1t1 / c11 - &(&d1t0 / c10) - &(&d0t1 / c01) + &(&d0t0 / c00);
463 (m_alpha, riesz)
464 }
465 };
466 let nu2 = 2.0 * &m_alpha - &riesz.mapv(|a| a * a);
467
468 let mut predictions = vec![
469 ("ml_g_d0_t0", g00),
470 ("ml_g_d0_t1", g01),
471 ("ml_g_d1_t0", g10),
472 ("ml_g_d1_t1", g11),
473 ];
474 if let Some(m) = m {
475 predictions.push(("ml_m", m));
476 }
477 Ok(
478 ScoreEstimate::new(LinearScore::new(-weight_a, psi_b)?, predictions).with_sensitivity(
479 SensitivityParts {
480 outcome_residual,
481 theta_factor: None,
482 riesz,
483 nu2: Nu2Parts::Elementwise(nu2),
484 },
485 ),
486 )
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::learner::{LogisticRegression, Ols};
493 use ndarray::array;
494
495 #[test]
496 fn validation_rules() {
497 let x = array![[1.0], [2.0], [3.0], [4.0]];
498 let data = DmlData::new(array![1.0, 2.0, 3.0, 4.0], array![0.0, 1.0, 0.0, 1.0], x).unwrap();
499 let no_m = Did::from_boxed(Box::new(Ols::new()), None);
501 assert!(no_m.validate(&data).is_err());
502 assert!(Did::experimental(Ols::new()).validate(&data).is_ok());
503 assert!(Did::new(Ols::new(), LogisticRegression::default())
504 .validate(&data)
505 .is_ok());
506 assert!(DidCs::experimental(Ols::new()).validate(&data).is_err());
508 let with_t = data
509 .with_time_indicator(array![0.0, 0.0, 1.0, 1.0])
510 .unwrap();
511 assert!(DidCs::experimental(Ols::new()).validate(&with_t).is_ok());
512 }
513
514 #[test]
515 fn cell_indicators_partition_rows() {
516 let d = array![0.0, 0.0, 1.0, 1.0];
517 let t = array![0.0, 1.0, 0.0, 1.0];
518 let total = cell(&d, t.view(), 0.0, 0.0)
519 + cell(&d, t.view(), 0.0, 1.0)
520 + cell(&d, t.view(), 1.0, 0.0)
521 + cell(&d, t.view(), 1.0, 1.0);
522 assert_eq!(total, Array1::ones(4));
523 assert_eq!(cell(&d, t.view(), 1.0, 0.0), array![0.0, 0.0, 1.0, 0.0]);
524 }
525}