1use ndarray::{concatenate, Array1, Array2, Axis};
7
8use super::{
9 dml_model_api, CateSignal, LinearScoreModel, NuisanceTarget, ScoreEstimate, ScoreInput,
10};
11use crate::crossfit::{map_folds, Fold, SampleSplitting};
12use crate::data::gather;
13use crate::error::{DmlError, Result};
14use crate::learner::Regressor;
15use crate::riesz::{apply_terms, LinearFunctional, RieszLasso, RieszLearner, Term};
16use crate::score::LinearScore;
17use crate::sensitivity::{Nu2Parts, SensitivityParts};
18
19pub struct AutoDml {
53 ml_g: Box<dyn Regressor>,
54 riesz: Box<dyn RieszLearner>,
55 functional: Box<dyn LinearFunctional>,
56 splitting: SampleSplitting,
57}
58
59impl AutoDml {
60 pub fn new(
61 ml_g: impl Regressor + 'static,
62 functional: impl LinearFunctional + 'static,
63 ) -> Self {
64 Self::from_boxed(Box::new(ml_g), Box::new(functional))
65 }
66
67 pub fn from_boxed(ml_g: Box<dyn Regressor>, functional: Box<dyn LinearFunctional>) -> Self {
69 Self {
70 ml_g,
71 riesz: Box::new(RieszLasso::default()),
72 functional,
73 splitting: SampleSplitting::default(),
74 }
75 }
76
77 pub fn riesz(mut self, riesz: impl RieszLearner + 'static) -> Self {
79 self.riesz = Box::new(riesz);
80 self
81 }
82
83 pub fn riesz_boxed(mut self, riesz: Box<dyn RieszLearner>) -> Self {
84 self.riesz = riesz;
85 self
86 }
87
88 dml_model_api!();
89}
90
91struct FoldResult {
93 g: Array1<f64>,
94 m_g: Array1<f64>,
95 alpha: Array1<f64>,
96 m_alpha: Array1<f64>,
97}
98
99fn rows_of(terms: &[Term], idx: &[usize]) -> Vec<Term> {
100 terms.iter().map(|t| t.rows(idx)).collect()
101}
102
103impl LinearScoreModel for AutoDml {
104 fn name(&self) -> String {
105 format!("Auto-DML ({})", self.functional.name())
106 }
107
108 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
109 let ScoreInput { y, d, x, .. } = *input;
110 let n = y.len();
111 let w: Array2<f64> = concatenate![Axis(1), d.insert_axis(Axis(1)), x];
112 let terms = self.functional.terms(w.view())?;
113 if terms.is_empty() {
114 return Err(DmlError::InvalidArgument(
115 "the functional has no terms".into(),
116 ));
117 }
118 for t in &terms {
119 if t.w.dim() != w.dim() || t.weight.len() != n {
120 return Err(DmlError::DimensionMismatch("functional terms".into()));
121 }
122 }
123
124 let per_fold = map_folds(folds.len(), |k| -> Result<FoldResult> {
125 let fold = &folds[k];
126 let w_train = w.select(Axis(0), &fold.train);
127 let w_test = w.select(Axis(0), &fold.test);
128 let test_terms = rows_of(&terms, &fold.test);
129
130 let mut g = self.ml_g.fresh();
131 g.fit(w_train.view(), gather(y, &fold.train).view())?;
132 let g_test = g.predict(w_test.view())?;
133 let m_g = apply_terms(&test_terms, |wk| g.predict(wk))?;
134
135 let mut a = self.riesz.fresh();
136 a.fit(w_train.view(), &rows_of(&terms, &fold.train))?;
137 let alpha = a.predict(w_test.view())?;
138 let m_alpha = a.predict_functional(&test_terms)?;
139 Ok(FoldResult {
140 g: g_test,
141 m_g,
142 alpha,
143 m_alpha,
144 })
145 })?;
146
147 let mut g_hat = Array1::from_elem(n, f64::NAN);
148 let mut m_hat = Array1::from_elem(n, f64::NAN);
149 let mut alpha = Array1::from_elem(n, f64::NAN);
150 let mut m_alpha = Array1::from_elem(n, f64::NAN);
151 for (fold, r) in folds.iter().zip(per_fold) {
152 if r.g.len() != fold.test.len() || r.alpha.len() != fold.test.len() {
153 return Err(DmlError::DimensionMismatch(
154 "learner returned wrong number of predictions".into(),
155 ));
156 }
157 for (j, &i) in fold.test.iter().enumerate() {
158 g_hat[i] = r.g[j];
159 m_hat[i] = r.m_g[j];
160 alpha[i] = r.alpha[j];
161 m_alpha[i] = r.m_alpha[j];
162 }
163 }
164 if [&g_hat, &m_hat, &alpha, &m_alpha]
165 .iter()
166 .any(|v| v.iter().any(|x| !x.is_finite()))
167 {
168 return Err(DmlError::InvalidArgument(
169 "folds do not cover every observation, or a learner returned a non-finite value"
170 .into(),
171 ));
172 }
173
174 let resid: Array1<f64> = &y - &g_hat;
175 let psi_b: Array1<f64> = &m_hat + &(&alpha * &resid);
176 let parts = SensitivityParts {
177 outcome_residual: resid,
178 theta_factor: None,
179 riesz: alpha.clone(),
180 nu2: Nu2Parts::Elementwise(2.0 * &m_alpha - &alpha.mapv(|a| a * a)),
181 };
182 Ok(ScoreEstimate::new(
183 LinearScore::new(Array1::from_elem(n, -1.0), psi_b.clone())?,
184 vec![("ml_g", g_hat), ("riesz", alpha), ("functional", m_hat)],
185 )
186 .with_sensitivity(parts)
187 .with_targets(vec![NuisanceTarget::regression("ml_g", y.to_owned())])
188 .with_cate_signal(CateSignal {
191 signal: psi_b,
192 theta_factor: None,
193 basis_scale: None,
194 is_effect: true,
195 }))
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::data::DmlData;
203 use crate::learner::Ols;
204 use crate::riesz::{Ate, AverageDerivative, PolicyEffect, RieszPenalty, ShiftEffect};
205 use rand::{Rng, SeedableRng};
206 use rand_chacha::ChaCha8Rng;
207 use rand_distr::StandardNormal;
208
209 fn continuous(n: usize, seed: u64, theta: f64) -> DmlData {
212 let mut rng = ChaCha8Rng::seed_from_u64(seed);
213 let mut draw = || -> f64 { rng.sample(StandardNormal) };
214 let x = Array2::from_shape_fn((n, 2), |_| draw());
215 let d: Array1<f64> = (0..n).map(|i| 0.5 * x[[i, 0]] + draw()).collect();
216 let y: Array1<f64> = (0..n)
217 .map(|i| theta * d[i] + x[[i, 0]].sin() + x[[i, 1]] + draw())
218 .collect();
219 DmlData::new(y, d, x).unwrap()
220 }
221
222 #[test]
223 fn average_derivative_and_shift_recover_theta() {
224 let data = continuous(2000, 1, 0.7);
225 let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
226 .fit(&data)
227 .unwrap();
228 assert!(
229 (fit.coef()[0] - 0.7).abs() < 4.0 * fit.se()[0],
230 "{}",
231 fit.coef()[0]
232 );
233 let shift = AutoDml::new(Ols::new(), ShiftEffect::new(0.5).per_unit(true))
234 .fit(&data)
235 .unwrap();
236 assert!((shift.coef()[0] - fit.coef()[0]).abs() < 1e-6);
237 let policy = PolicyEffect::new(data.treatment(0).mapv(|d| d + 0.5));
239 let p = AutoDml::new(Ols::new(), policy).fit(&data).unwrap();
240 let s = AutoDml::new(Ols::new(), ShiftEffect::new(0.5))
241 .fit(&data)
242 .unwrap();
243 assert!((p.coef()[0] - s.coef()[0]).abs() < 1e-10);
244 assert!(fit.prediction("riesz", 0, 0).is_some());
245 }
246
247 #[test]
248 fn riesz_correction_removes_the_bias_of_a_misspecified_regression() {
249 let mut rng = ChaCha8Rng::seed_from_u64(7);
252 let n = 4000;
253 let mut draw = || -> f64 { rng.sample(StandardNormal) };
254 let x = Array2::from_shape_fn((n, 1), |_| draw());
255 let d: Array1<f64> = (0..n).map(|i| x[[i, 0]] + draw()).collect();
256 let y: Array1<f64> = (0..n)
257 .map(|i| d[i] * (1.0 + x[[i, 0]]) + x[[i, 0]] + draw())
258 .collect();
259 let data = DmlData::new(y, d, x).unwrap();
260 let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
261 .fit(&data)
262 .unwrap();
263 assert!(
264 (fit.coef()[0] - 1.0).abs() < 4.0 * fit.se()[0],
265 "{} ± {}",
266 fit.coef()[0],
267 fit.se()[0]
268 );
269 let plug_in = fit.prediction("functional", 0, 0).unwrap().mean().unwrap();
270 assert!((plug_in - 1.0).abs() > (fit.coef()[0] - 1.0).abs());
271 }
272
273 #[test]
274 fn ate_is_close_to_irm() {
275 let mut rng = ChaCha8Rng::seed_from_u64(3);
276 let n = 3000;
277 let mut draw = || -> f64 { rng.sample(StandardNormal) };
278 let x = Array2::from_shape_fn((n, 2), |_| draw());
279 let mut u = ChaCha8Rng::seed_from_u64(4);
280 let d: Array1<f64> = (0..n)
281 .map(|i| {
282 let p = 1.0 / (1.0 + (-0.8 * x[[i, 0]]).exp());
283 f64::from(u.gen::<f64>() < p)
284 })
285 .collect();
286 let y: Array1<f64> = (0..n)
287 .map(|i| 0.5 * d[i] + x[[i, 0]] + 0.5 * x[[i, 1]] + draw())
288 .collect();
289 let data = DmlData::new(y, d, x).unwrap();
290 let auto = AutoDml::new(Ols::new(), Ate).fit(&data).unwrap();
291 let irm = crate::Irm::new(Ols::new(), crate::LogisticRegression::default())
292 .fit(&data)
293 .unwrap();
294 assert!((auto.coef()[0] - irm.coef()[0]).abs() < 0.5 * irm.se()[0]);
295 assert!((auto.coef()[0] - 0.5).abs() < 4.0 * auto.se()[0]);
296 let s = auto
297 .sensitivity_analysis(&crate::SensitivityParams::default())
298 .unwrap();
299 assert!(s.theta_lower[0] < auto.coef()[0]);
300 }
301
302 #[test]
303 fn fixed_penalty_and_errors() {
304 let data = continuous(300, 2, 0.5);
305 let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
306 .riesz(RieszLasso::new().penalty(RieszPenalty::Fixed(0.0)))
307 .fit(&data)
308 .unwrap();
309 assert!(fit.coef()[0].is_finite());
310 assert!(AutoDml::new(Ols::new(), Ate).fit(&data).is_err());
311 }
312
313 #[test]
314 fn coverage_of_the_average_derivative() {
315 let reps = 120;
316 let mut hits = 0;
317 for r in 0..reps {
318 let data = continuous(400, 100 + r, 0.7);
319 let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
320 .seed(r)
321 .fit(&data)
322 .unwrap();
323 let ci = fit.confint(0.95).unwrap();
324 if ci[[0, 0]] <= 0.7 && 0.7 <= ci[[0, 1]] {
325 hits += 1;
326 }
327 }
328 let cover = hits as f64 / reps as f64;
329 assert!(cover > 0.88, "coverage {cover}");
330 }
331}