1use ndarray::{Array1, ArrayView1};
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 let treated: Vec<bool> = d.iter().map(|&v| v == 1.0).collect();
144 let control: Vec<bool> = treated.iter().map(|b| !b).collect();
145 let g0 = cross_fit_predict_subset(self.ml_g.as_ref(), x, y, folds, &control)?;
146 let g1 = cross_fit_predict_subset(self.ml_g.as_ref(), x, y, folds, &treated)?;
147 let d = d.to_owned();
148 let n = d.len();
149 let p = mean(&d);
150 let resid_d0: Array1<f64> = &y - &g0;
151 let mut predictions = vec![("ml_g0", g0.clone()), ("ml_g1", g1.clone())];
152
153 let (weight_a, psi_b) = match self.score {
154 DidScore::Observational => {
155 let ml_m = self.ml_m.as_ref().expect("checked in validate");
156 let t = self.clipping_threshold;
157 let m = cross_fit_predict_proba(ml_m.as_ref(), x, d.view(), folds)?
158 .mapv(|q| q.clamp(t, 1.0 - t));
159 let (weight_a, weight_resid) = if self.in_sample_normalization {
160 let weight_a = &d / mean(&d);
161 let propensity_weight = (1.0 - &d) * &m / &(1.0 - &m);
162 let weight_resid = &weight_a - &(&propensity_weight / mean(&propensity_weight));
163 (weight_a, weight_resid)
164 } else {
165 (&d / p, (&d - &m) / &(p * &(1.0 - &m)))
166 };
167 predictions.push(("ml_m", m));
168 let psi_b = &weight_resid * &resid_d0;
169 (weight_a, psi_b)
170 }
171 DidScore::Experimental => {
172 let (weight_g0, weight_g1, weight_resid) = if self.in_sample_normalization {
173 let share = &d / mean(&d);
174 let control_share = (1.0 - &d) / mean(&(1.0 - &d));
175 (&share - 1.0, 1.0 - &share, &share - &control_share)
176 } else {
177 (&d / p - 1.0, 1.0 - &d / p, (&d - p) / (p * (1.0 - p)))
178 };
179 let psi_b = &weight_g0 * &g0 + &weight_g1 * &g1 + &weight_resid * &resid_d0;
180 (Array1::ones(n), psi_b)
181 }
182 };
183 Ok(ScoreEstimate {
184 score: LinearScore::new(-weight_a, psi_b)?,
185 predictions,
186 })
187 }
188}
189
190pub struct DidCs {
195 ml_g: Box<dyn Regressor>,
196 ml_m: Option<Box<dyn Classifier>>,
197 score: DidScore,
198 in_sample_normalization: bool,
199 clipping_threshold: f64,
200 splitting: SampleSplitting,
201}
202
203impl DidCs {
204 pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
206 Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
207 }
208
209 pub fn experimental(ml_g: impl Regressor + 'static) -> Self {
211 Self::from_boxed(Box::new(ml_g), None).score(DidScore::Experimental)
212 }
213
214 pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
215 Self {
216 ml_g,
217 ml_m,
218 score: DidScore::default(),
219 in_sample_normalization: true,
220 clipping_threshold: 1e-2,
221 splitting: SampleSplitting::default(),
222 }
223 }
224
225 did_builders!();
226 dml_model_api!();
227}
228
229fn cell(d: &Array1<f64>, t: ArrayView1<'_, f64>, a: f64, b: f64) -> Array1<f64> {
231 d.iter()
232 .zip(t)
233 .map(|(&di, &ti)| f64::from(di == a && ti == b))
234 .collect()
235}
236
237impl LinearScoreModel for DidCs {
238 fn name(&self) -> String {
239 match self.score {
240 DidScore::Observational => "DID repeated cross-sections (observational)".into(),
241 DidScore::Experimental => "DID repeated cross-sections (experimental)".into(),
242 }
243 }
244
245 fn validate(&self, data: &DmlData) -> Result<()> {
246 validate_common(
247 data,
248 self.score,
249 self.ml_m.is_some(),
250 self.clipping_threshold,
251 )?;
252 if data.time().is_none() {
253 return Err(DmlError::InvalidArgument(
254 "repeated cross-section DID needs a time indicator: use DmlData::with_time_indicator"
255 .into(),
256 ));
257 }
258 Ok(())
259 }
260
261 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
262 let ScoreInput { y, d, x, t, .. } = *input;
263 let t = t.ok_or_else(|| DmlError::InvalidArgument("missing time indicator".into()))?;
264 let d = d.to_owned();
265 let (d1t1, d1t0) = (cell(&d, t, 1.0, 1.0), cell(&d, t, 1.0, 0.0));
266 let (d0t1, d0t0) = (cell(&d, t, 0.0, 1.0), cell(&d, t, 0.0, 0.0));
267 let fit_cell = |c: &Array1<f64>| -> Result<Array1<f64>> {
268 let keep: Vec<bool> = c.iter().map(|&v| v == 1.0).collect();
269 cross_fit_predict_subset(self.ml_g.as_ref(), x, y, folds, &keep)
270 };
271 let (g00, g01) = (fit_cell(&d0t0)?, fit_cell(&d0t1)?);
272 let (g10, g11) = (fit_cell(&d1t0)?, fit_cell(&d1t1)?);
273 let n = d.len();
274 let p = mean(&d);
275 let lambda = t.mean().expect("n > 0");
276
277 let m = match self.score {
278 DidScore::Observational => {
279 let ml_m = self.ml_m.as_ref().expect("checked in validate");
280 let c = self.clipping_threshold;
281 Some(
282 cross_fit_predict_proba(ml_m.as_ref(), x, d.view(), folds)?
283 .mapv(|q| q.clamp(c, 1.0 - c)),
284 )
285 }
286 DidScore::Experimental => None,
287 };
288
289 let (weight_a, w11, w10, w01, w00) = match (&m, self.in_sample_normalization) {
291 (Some(m), true) => {
292 let odds = m / &(1.0 - m);
293 let u01 = &d0t1 * &odds;
294 let u00 = &d0t0 * &odds;
295 (
296 &d / mean(&d),
297 &d1t1 / mean(&d1t1),
298 -(&d1t0 / mean(&d1t0)),
299 -(&u01 / mean(&u01)),
300 &u00 / mean(&u00),
301 )
302 }
303 (Some(m), false) => {
304 let odds = m / &(1.0 - m);
305 (
306 &d / p,
307 &d1t1 / (p * lambda),
308 -(&d1t0 / (p * (1.0 - lambda))),
309 -(&d0t1 / (p * lambda) * &odds),
310 &d0t0 / (p * (1.0 - lambda)) * &odds,
311 )
312 }
313 (None, true) => (
314 Array1::ones(n),
315 &d1t1 / mean(&d1t1),
316 -(&d1t0 / mean(&d1t0)),
317 -(&d0t1 / mean(&d0t1)),
318 &d0t0 / mean(&d0t0),
319 ),
320 (None, false) => (
321 Array1::ones(n),
322 &d1t1 / (p * lambda),
323 -(&d1t0 / (p * (1.0 - lambda))),
324 -(&d0t1 / ((1.0 - p) * lambda)),
325 &d0t0 / ((1.0 - p) * (1.0 - lambda)),
326 ),
327 };
328 let contrast = &g11 - &g10 - &g01 + &g00;
329 let psi_b = &weight_a * &contrast
330 + &w11 * &(&y - &g11)
331 + &w10 * &(&y - &g10)
332 + &w00 * &(&y - &g00)
333 + &w01 * &(&y - &g01);
334
335 let mut predictions = vec![
336 ("ml_g_d0_t0", g00),
337 ("ml_g_d0_t1", g01),
338 ("ml_g_d1_t0", g10),
339 ("ml_g_d1_t1", g11),
340 ];
341 if let Some(m) = m {
342 predictions.push(("ml_m", m));
343 }
344 Ok(ScoreEstimate {
345 score: LinearScore::new(-weight_a, psi_b)?,
346 predictions,
347 })
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::learner::{LogisticRegression, Ols};
355 use ndarray::array;
356
357 #[test]
358 fn validation_rules() {
359 let x = array![[1.0], [2.0], [3.0], [4.0]];
360 let data = DmlData::new(array![1.0, 2.0, 3.0, 4.0], array![0.0, 1.0, 0.0, 1.0], x).unwrap();
361 let no_m = Did::from_boxed(Box::new(Ols::new()), None);
363 assert!(no_m.validate(&data).is_err());
364 assert!(Did::experimental(Ols::new()).validate(&data).is_ok());
365 assert!(Did::new(Ols::new(), LogisticRegression::default())
366 .validate(&data)
367 .is_ok());
368 assert!(DidCs::experimental(Ols::new()).validate(&data).is_err());
370 let with_t = data
371 .with_time_indicator(array![0.0, 0.0, 1.0, 1.0])
372 .unwrap();
373 assert!(DidCs::experimental(Ols::new()).validate(&with_t).is_ok());
374 }
375
376 #[test]
377 fn cell_indicators_partition_rows() {
378 let d = array![0.0, 0.0, 1.0, 1.0];
379 let t = array![0.0, 1.0, 0.0, 1.0];
380 let total = cell(&d, t.view(), 0.0, 0.0)
381 + cell(&d, t.view(), 0.0, 1.0)
382 + cell(&d, t.view(), 1.0, 0.0)
383 + cell(&d, t.view(), 1.0, 1.0);
384 assert_eq!(total, Array1::ones(4));
385 assert_eq!(cell(&d, t.view(), 1.0, 0.0), array![0.0, 0.0, 1.0, 0.0]);
386 }
387}