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;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum DidScore {
25 #[default]
28 Observational,
29 Experimental,
31}
32
33fn mean(v: &Array1<f64>) -> f64 {
34 v.mean().expect("n > 0")
35}
36
37fn validate_common(
39 data: &DmlData,
40 score: DidScore,
41 has_ml_m: bool,
42 clipping_threshold: f64,
43) -> Result<()> {
44 if data.n_treat() != 1 || !data.is_binary_treatment(0) {
45 return Err(DmlError::InvalidArgument(
46 "DID requires exactly one binary (0/1) treatment group indicator".into(),
47 ));
48 }
49 if score == DidScore::Observational && !has_ml_m {
50 return Err(DmlError::InvalidArgument(
51 "the observational DID score needs a propensity learner ml_m".into(),
52 ));
53 }
54 if !(clipping_threshold > 0.0 && clipping_threshold < 0.5) {
55 return Err(DmlError::InvalidArgument(
56 "clipping_threshold must be in (0, 0.5)".into(),
57 ));
58 }
59 Ok(())
60}
61
62macro_rules! did_builders {
64 () => {
65 pub fn score(mut self, score: DidScore) -> Self {
67 self.score = score;
68 self
69 }
70
71 pub fn in_sample_normalization(mut self, normalize: bool) -> Self {
74 self.in_sample_normalization = normalize;
75 self
76 }
77
78 pub fn clipping_threshold(mut self, t: f64) -> Self {
80 self.clipping_threshold = t;
81 self
82 }
83 };
84}
85
86pub struct Did {
90 ml_g: Box<dyn Regressor>,
91 ml_m: Option<Box<dyn Classifier>>,
92 score: DidScore,
93 in_sample_normalization: bool,
94 clipping_threshold: f64,
95 splitting: SampleSplitting,
96}
97
98impl Did {
99 pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
101 Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
102 }
103
104 pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
106 Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
107 }
108
109 pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
110 Self {
111 ml_g,
112 ml_m,
113 score: DidScore::default(),
114 in_sample_normalization: true,
115 clipping_threshold: 1e-2,
116 splitting: SampleSplitting::default(),
117 }
118 }
119
120 did_builders!();
121 dml_model_api!();
122}
123
124impl LinearScoreModel for Did {
125 fn name(&self) -> String {
126 match self.score {
127 DidScore::Observational => "DID panel (observational)".into(),
128 DidScore::Experimental => "DID panel (experimental)".into(),
129 }
130 }
131
132 fn validate(&self, data: &DmlData) -> Result<()> {
133 validate_common(
134 data,
135 self.score,
136 self.ml_m.is_some(),
137 self.clipping_threshold,
138 )
139 }
140
141 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
142 let ScoreInput { y, d, x, .. } = *input;
143 panel_did_score(
144 &DidSettings {
145 ml_g: self.ml_g.as_ref(),
146 ml_m: self.ml_m.as_deref(),
147 score: self.score,
148 in_sample_normalization: self.in_sample_normalization,
149 clipping_threshold: self.clipping_threshold,
150 },
151 x,
152 y,
153 d,
154 folds,
155 )
156 }
157}
158
159pub(crate) struct DidSettings<'a> {
163 pub ml_g: &'a dyn Regressor,
164 pub ml_m: Option<&'a dyn Classifier>,
165 pub score: DidScore,
166 pub in_sample_normalization: bool,
167 pub clipping_threshold: f64,
168}
169
170pub(crate) fn panel_did_score(
172 settings: &DidSettings<'_>,
173 x: ArrayView2<'_, f64>,
174 y: ArrayView1<'_, f64>,
175 d: ArrayView1<'_, f64>,
176 folds: &[Fold],
177) -> Result<ScoreEstimate> {
178 let treated: Vec<bool> = d.iter().map(|&v| v == 1.0).collect();
179 let control: Vec<bool> = treated.iter().map(|b| !b).collect();
180 let g0 = cross_fit_predict_subset(settings.ml_g, x, y, folds, &control)?;
181 let g1 = cross_fit_predict_subset(settings.ml_g, x, y, folds, &treated)?;
182 let d = d.to_owned();
183 let n = d.len();
184 let p = mean(&d);
185 let resid_d0: Array1<f64> = &y - &g0;
186 let mut predictions = vec![("ml_g0", g0.clone()), ("ml_g1", g1.clone())];
187
188 let (weight_a, psi_b) = match settings.score {
189 DidScore::Observational => {
190 let ml_m = settings.ml_m.ok_or_else(|| {
191 DmlError::InvalidArgument("the observational DID score needs ml_m".into())
192 })?;
193 let t = settings.clipping_threshold;
194 let m =
195 cross_fit_predict_proba(ml_m, x, d.view(), folds)?.mapv(|q| q.clamp(t, 1.0 - t));
196 let (weight_a, weight_resid) = if settings.in_sample_normalization {
197 let weight_a = &d / mean(&d);
198 let propensity_weight = (1.0 - &d) * &m / &(1.0 - &m);
199 let weight_resid = &weight_a - &(&propensity_weight / mean(&propensity_weight));
200 (weight_a, weight_resid)
201 } else {
202 (&d / p, (&d - &m) / &(p * &(1.0 - &m)))
203 };
204 predictions.push(("ml_m", m));
205 let psi_b = &weight_resid * &resid_d0;
206 (weight_a, psi_b)
207 }
208 DidScore::Experimental => {
209 let (weight_g0, weight_g1, weight_resid) = if settings.in_sample_normalization {
210 let share = &d / mean(&d);
211 let control_share = (1.0 - &d) / mean(&(1.0 - &d));
212 (&share - 1.0, 1.0 - &share, &share - &control_share)
213 } else {
214 (&d / p - 1.0, 1.0 - &d / p, (&d - p) / (p * (1.0 - p)))
215 };
216 let psi_b = &weight_g0 * &g0 + &weight_g1 * &g1 + &weight_resid * &resid_d0;
217 (Array1::ones(n), psi_b)
218 }
219 };
220 Ok(ScoreEstimate {
221 score: LinearScore::new(-weight_a, psi_b)?,
222 predictions,
223 })
224}
225
226pub struct DidCs {
231 ml_g: Box<dyn Regressor>,
232 ml_m: Option<Box<dyn Classifier>>,
233 score: DidScore,
234 in_sample_normalization: bool,
235 clipping_threshold: f64,
236 splitting: SampleSplitting,
237}
238
239impl DidCs {
240 pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
242 Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
243 }
244
245 pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
247 Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
248 }
249
250 pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
251 Self {
252 ml_g,
253 ml_m,
254 score: DidScore::default(),
255 in_sample_normalization: true,
256 clipping_threshold: 1e-2,
257 splitting: SampleSplitting::default(),
258 }
259 }
260
261 did_builders!();
262 dml_model_api!();
263}
264
265fn cell(d: &Array1<f64>, t: ArrayView1<'_, f64>, a: f64, b: f64) -> Array1<f64> {
267 d.iter()
268 .zip(t)
269 .map(|(&di, &ti)| f64::from(di == a && ti == b))
270 .collect()
271}
272
273impl LinearScoreModel for DidCs {
274 fn name(&self) -> String {
275 match self.score {
276 DidScore::Observational => "DID repeated cross-sections (observational)".into(),
277 DidScore::Experimental => "DID repeated cross-sections (experimental)".into(),
278 }
279 }
280
281 fn validate(&self, data: &DmlData) -> Result<()> {
282 validate_common(
283 data,
284 self.score,
285 self.ml_m.is_some(),
286 self.clipping_threshold,
287 )?;
288 if data.time().is_none() {
289 return Err(DmlError::InvalidArgument(
290 "repeated cross-section DID needs a time indicator: use DmlData::with_time_indicator"
291 .into(),
292 ));
293 }
294 Ok(())
295 }
296
297 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
298 let ScoreInput { y, d, x, t, .. } = *input;
299 let t = t.ok_or_else(|| DmlError::InvalidArgument("missing time indicator".into()))?;
300 cs_did_score(
301 &DidSettings {
302 ml_g: self.ml_g.as_ref(),
303 ml_m: self.ml_m.as_deref(),
304 score: self.score,
305 in_sample_normalization: self.in_sample_normalization,
306 clipping_threshold: self.clipping_threshold,
307 },
308 x,
309 y,
310 d,
311 t,
312 folds,
313 )
314 }
315}
316
317pub(crate) fn cs_did_score(
320 settings: &DidSettings<'_>,
321 x: ArrayView2<'_, f64>,
322 y: ArrayView1<'_, f64>,
323 d: ArrayView1<'_, f64>,
324 t: ArrayView1<'_, f64>,
325 folds: &[Fold],
326) -> Result<ScoreEstimate> {
327 let d = d.to_owned();
328 let (d1t1, d1t0) = (cell(&d, t, 1.0, 1.0), cell(&d, t, 1.0, 0.0));
329 let (d0t1, d0t0) = (cell(&d, t, 0.0, 1.0), cell(&d, t, 0.0, 0.0));
330 let fit_cell = |c: &Array1<f64>| -> Result<Array1<f64>> {
331 let keep: Vec<bool> = c.iter().map(|&v| v == 1.0).collect();
332 cross_fit_predict_subset(settings.ml_g, x, y, folds, &keep)
333 };
334 let (g00, g01) = (fit_cell(&d0t0)?, fit_cell(&d0t1)?);
335 let (g10, g11) = (fit_cell(&d1t0)?, fit_cell(&d1t1)?);
336 let n = d.len();
337 let p = mean(&d);
338 let lambda = t.mean().expect("n > 0");
339
340 let m = match settings.score {
341 DidScore::Observational => {
342 let ml_m = settings.ml_m.ok_or_else(|| {
343 DmlError::InvalidArgument("the observational DID score needs ml_m".into())
344 })?;
345 let c = settings.clipping_threshold;
346 Some(cross_fit_predict_proba(ml_m, x, d.view(), folds)?.mapv(|q| q.clamp(c, 1.0 - c)))
347 }
348 DidScore::Experimental => None,
349 };
350
351 let (weight_a, w11, w10, w01, w00) = match (&m, settings.in_sample_normalization) {
353 (Some(m), true) => {
354 let odds = m / &(1.0 - m);
355 let u01 = &d0t1 * &odds;
356 let u00 = &d0t0 * &odds;
357 (
358 &d / mean(&d),
359 &d1t1 / mean(&d1t1),
360 -(&d1t0 / mean(&d1t0)),
361 -(&u01 / mean(&u01)),
362 &u00 / mean(&u00),
363 )
364 }
365 (Some(m), false) => {
366 let odds = m / &(1.0 - m);
367 (
368 &d / p,
369 &d1t1 / (p * lambda),
370 -(&d1t0 / (p * (1.0 - lambda))),
371 -(&d0t1 / (p * lambda) * &odds),
372 &d0t0 / (p * (1.0 - lambda)) * &odds,
373 )
374 }
375 (None, true) => (
376 Array1::ones(n),
377 &d1t1 / mean(&d1t1),
378 -(&d1t0 / mean(&d1t0)),
379 -(&d0t1 / mean(&d0t1)),
380 &d0t0 / mean(&d0t0),
381 ),
382 (None, false) => (
383 Array1::ones(n),
384 &d1t1 / (p * lambda),
385 -(&d1t0 / (p * (1.0 - lambda))),
386 -(&d0t1 / ((1.0 - p) * lambda)),
387 &d0t0 / ((1.0 - p) * (1.0 - lambda)),
388 ),
389 };
390 let contrast = &g11 - &g10 - &g01 + &g00;
391 let psi_b = &weight_a * &contrast
392 + &w11 * &(&y - &g11)
393 + &w10 * &(&y - &g10)
394 + &w00 * &(&y - &g00)
395 + &w01 * &(&y - &g01);
396
397 let mut predictions = vec![
398 ("ml_g_d0_t0", g00),
399 ("ml_g_d0_t1", g01),
400 ("ml_g_d1_t0", g10),
401 ("ml_g_d1_t1", g11),
402 ];
403 if let Some(m) = m {
404 predictions.push(("ml_m", m));
405 }
406 Ok(ScoreEstimate {
407 score: LinearScore::new(-weight_a, psi_b)?,
408 predictions,
409 })
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::learner::{LogisticRegression, Ols};
416 use ndarray::array;
417
418 #[test]
419 fn validation_rules() {
420 let x = array![[1.0], [2.0], [3.0], [4.0]];
421 let data = DmlData::new(array![1.0, 2.0, 3.0, 4.0], array![0.0, 1.0, 0.0, 1.0], x).unwrap();
422 let no_m = Did::from_boxed(Box::new(Ols::new()), None);
424 assert!(no_m.validate(&data).is_err());
425 assert!(Did::experimental(Ols::new()).validate(&data).is_ok());
426 assert!(Did::new(Ols::new(), LogisticRegression::default())
427 .validate(&data)
428 .is_ok());
429 assert!(DidCs::experimental(Ols::new()).validate(&data).is_err());
431 let with_t = data
432 .with_time_indicator(array![0.0, 0.0, 1.0, 1.0])
433 .unwrap();
434 assert!(DidCs::experimental(Ols::new()).validate(&with_t).is_ok());
435 }
436
437 #[test]
438 fn cell_indicators_partition_rows() {
439 let d = array![0.0, 0.0, 1.0, 1.0];
440 let t = array![0.0, 1.0, 0.0, 1.0];
441 let total = cell(&d, t.view(), 0.0, 0.0)
442 + cell(&d, t.view(), 0.0, 1.0)
443 + cell(&d, t.view(), 1.0, 0.0)
444 + cell(&d, t.view(), 1.0, 1.0);
445 assert_eq!(total, Array1::ones(4));
446 assert_eq!(cell(&d, t.view(), 1.0, 0.0), array![0.0, 0.0, 1.0, 0.0]);
447 }
448}