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 &PanelDidSettings {
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 PanelDidSettings<'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: &PanelDidSettings<'_>,
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 let d = d.to_owned();
301 let (d1t1, d1t0) = (cell(&d, t, 1.0, 1.0), cell(&d, t, 1.0, 0.0));
302 let (d0t1, d0t0) = (cell(&d, t, 0.0, 1.0), cell(&d, t, 0.0, 0.0));
303 let fit_cell = |c: &Array1<f64>| -> Result<Array1<f64>> {
304 let keep: Vec<bool> = c.iter().map(|&v| v == 1.0).collect();
305 cross_fit_predict_subset(self.ml_g.as_ref(), x, y, folds, &keep)
306 };
307 let (g00, g01) = (fit_cell(&d0t0)?, fit_cell(&d0t1)?);
308 let (g10, g11) = (fit_cell(&d1t0)?, fit_cell(&d1t1)?);
309 let n = d.len();
310 let p = mean(&d);
311 let lambda = t.mean().expect("n > 0");
312
313 let m = match self.score {
314 DidScore::Observational => {
315 let ml_m = self.ml_m.as_ref().expect("checked in validate");
316 let c = self.clipping_threshold;
317 Some(
318 cross_fit_predict_proba(ml_m.as_ref(), x, d.view(), folds)?
319 .mapv(|q| q.clamp(c, 1.0 - c)),
320 )
321 }
322 DidScore::Experimental => None,
323 };
324
325 let (weight_a, w11, w10, w01, w00) = match (&m, self.in_sample_normalization) {
327 (Some(m), true) => {
328 let odds = m / &(1.0 - m);
329 let u01 = &d0t1 * &odds;
330 let u00 = &d0t0 * &odds;
331 (
332 &d / mean(&d),
333 &d1t1 / mean(&d1t1),
334 -(&d1t0 / mean(&d1t0)),
335 -(&u01 / mean(&u01)),
336 &u00 / mean(&u00),
337 )
338 }
339 (Some(m), false) => {
340 let odds = m / &(1.0 - m);
341 (
342 &d / p,
343 &d1t1 / (p * lambda),
344 -(&d1t0 / (p * (1.0 - lambda))),
345 -(&d0t1 / (p * lambda) * &odds),
346 &d0t0 / (p * (1.0 - lambda)) * &odds,
347 )
348 }
349 (None, true) => (
350 Array1::ones(n),
351 &d1t1 / mean(&d1t1),
352 -(&d1t0 / mean(&d1t0)),
353 -(&d0t1 / mean(&d0t1)),
354 &d0t0 / mean(&d0t0),
355 ),
356 (None, false) => (
357 Array1::ones(n),
358 &d1t1 / (p * lambda),
359 -(&d1t0 / (p * (1.0 - lambda))),
360 -(&d0t1 / ((1.0 - p) * lambda)),
361 &d0t0 / ((1.0 - p) * (1.0 - lambda)),
362 ),
363 };
364 let contrast = &g11 - &g10 - &g01 + &g00;
365 let psi_b = &weight_a * &contrast
366 + &w11 * &(&y - &g11)
367 + &w10 * &(&y - &g10)
368 + &w00 * &(&y - &g00)
369 + &w01 * &(&y - &g01);
370
371 let mut predictions = vec![
372 ("ml_g_d0_t0", g00),
373 ("ml_g_d0_t1", g01),
374 ("ml_g_d1_t0", g10),
375 ("ml_g_d1_t1", g11),
376 ];
377 if let Some(m) = m {
378 predictions.push(("ml_m", m));
379 }
380 Ok(ScoreEstimate {
381 score: LinearScore::new(-weight_a, psi_b)?,
382 predictions,
383 })
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::learner::{LogisticRegression, Ols};
391 use ndarray::array;
392
393 #[test]
394 fn validation_rules() {
395 let x = array![[1.0], [2.0], [3.0], [4.0]];
396 let data = DmlData::new(array![1.0, 2.0, 3.0, 4.0], array![0.0, 1.0, 0.0, 1.0], x).unwrap();
397 let no_m = Did::from_boxed(Box::new(Ols::new()), None);
399 assert!(no_m.validate(&data).is_err());
400 assert!(Did::experimental(Ols::new()).validate(&data).is_ok());
401 assert!(Did::new(Ols::new(), LogisticRegression::default())
402 .validate(&data)
403 .is_ok());
404 assert!(DidCs::experimental(Ols::new()).validate(&data).is_err());
406 let with_t = data
407 .with_time_indicator(array![0.0, 0.0, 1.0, 1.0])
408 .unwrap();
409 assert!(DidCs::experimental(Ols::new()).validate(&with_t).is_ok());
410 }
411
412 #[test]
413 fn cell_indicators_partition_rows() {
414 let d = array![0.0, 0.0, 1.0, 1.0];
415 let t = array![0.0, 1.0, 0.0, 1.0];
416 let total = cell(&d, t.view(), 0.0, 0.0)
417 + cell(&d, t.view(), 0.0, 1.0)
418 + cell(&d, t.view(), 1.0, 0.0)
419 + cell(&d, t.view(), 1.0, 1.0);
420 assert_eq!(total, Array1::ones(4));
421 assert_eq!(cell(&d, t.view(), 1.0, 0.0), array![0.0, 0.0, 1.0, 0.0]);
422 }
423}