use ndarray::{concatenate, Array1, Array2, Axis};
use super::{
dml_model_api, CateSignal, LinearScoreModel, NuisanceTarget, ScoreEstimate, ScoreInput,
};
use crate::crossfit::{map_folds, Fold, SampleSplitting};
use crate::data::gather;
use crate::error::{DmlError, Result};
use crate::learner::Regressor;
use crate::riesz::{apply_terms, LinearFunctional, RieszLasso, RieszLearner, Term};
use crate::score::LinearScore;
use crate::sensitivity::{Nu2Parts, SensitivityParts};
pub struct AutoDml {
ml_g: Box<dyn Regressor>,
riesz: Box<dyn RieszLearner>,
functional: Box<dyn LinearFunctional>,
splitting: SampleSplitting,
}
impl AutoDml {
pub fn new(
ml_g: impl Regressor + 'static,
functional: impl LinearFunctional + 'static,
) -> Self {
Self::from_boxed(Box::new(ml_g), Box::new(functional))
}
pub fn from_boxed(ml_g: Box<dyn Regressor>, functional: Box<dyn LinearFunctional>) -> Self {
Self {
ml_g,
riesz: Box::new(RieszLasso::default()),
functional,
splitting: SampleSplitting::default(),
}
}
pub fn riesz(mut self, riesz: impl RieszLearner + 'static) -> Self {
self.riesz = Box::new(riesz);
self
}
pub fn riesz_boxed(mut self, riesz: Box<dyn RieszLearner>) -> Self {
self.riesz = riesz;
self
}
dml_model_api!();
}
struct FoldResult {
g: Array1<f64>,
m_g: Array1<f64>,
alpha: Array1<f64>,
m_alpha: Array1<f64>,
}
fn rows_of(terms: &[Term], idx: &[usize]) -> Vec<Term> {
terms.iter().map(|t| t.rows(idx)).collect()
}
impl LinearScoreModel for AutoDml {
fn name(&self) -> String {
format!("Auto-DML ({})", self.functional.name())
}
fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
let ScoreInput { y, d, x, .. } = *input;
let n = y.len();
let w: Array2<f64> = concatenate![Axis(1), d.insert_axis(Axis(1)), x];
let terms = self.functional.terms(w.view())?;
if terms.is_empty() {
return Err(DmlError::InvalidArgument(
"the functional has no terms".into(),
));
}
for t in &terms {
if t.w.dim() != w.dim() || t.weight.len() != n {
return Err(DmlError::DimensionMismatch("functional terms".into()));
}
}
let per_fold = map_folds(folds.len(), |k| -> Result<FoldResult> {
let fold = &folds[k];
let w_train = w.select(Axis(0), &fold.train);
let w_test = w.select(Axis(0), &fold.test);
let test_terms = rows_of(&terms, &fold.test);
let mut g = self.ml_g.fresh();
g.fit(w_train.view(), gather(y, &fold.train).view())?;
let g_test = g.predict(w_test.view())?;
let m_g = apply_terms(&test_terms, |wk| g.predict(wk))?;
let mut a = self.riesz.fresh();
a.fit(w_train.view(), &rows_of(&terms, &fold.train))?;
let alpha = a.predict(w_test.view())?;
let m_alpha = a.predict_functional(&test_terms)?;
Ok(FoldResult {
g: g_test,
m_g,
alpha,
m_alpha,
})
})?;
let mut g_hat = Array1::from_elem(n, f64::NAN);
let mut m_hat = Array1::from_elem(n, f64::NAN);
let mut alpha = Array1::from_elem(n, f64::NAN);
let mut m_alpha = Array1::from_elem(n, f64::NAN);
for (fold, r) in folds.iter().zip(per_fold) {
if r.g.len() != fold.test.len() || r.alpha.len() != fold.test.len() {
return Err(DmlError::DimensionMismatch(
"learner returned wrong number of predictions".into(),
));
}
for (j, &i) in fold.test.iter().enumerate() {
g_hat[i] = r.g[j];
m_hat[i] = r.m_g[j];
alpha[i] = r.alpha[j];
m_alpha[i] = r.m_alpha[j];
}
}
if [&g_hat, &m_hat, &alpha, &m_alpha]
.iter()
.any(|v| v.iter().any(|x| !x.is_finite()))
{
return Err(DmlError::InvalidArgument(
"folds do not cover every observation, or a learner returned a non-finite value"
.into(),
));
}
let resid: Array1<f64> = &y - &g_hat;
let psi_b: Array1<f64> = &m_hat + &(&alpha * &resid);
let parts = SensitivityParts {
outcome_residual: resid,
theta_factor: None,
riesz: alpha.clone(),
nu2: Nu2Parts::Elementwise(2.0 * &m_alpha - &alpha.mapv(|a| a * a)),
};
Ok(ScoreEstimate::new(
LinearScore::new(Array1::from_elem(n, -1.0), psi_b.clone())?,
vec![("ml_g", g_hat), ("riesz", alpha), ("functional", m_hat)],
)
.with_sensitivity(parts)
.with_targets(vec![NuisanceTarget::regression("ml_g", y.to_owned())])
.with_cate_signal(CateSignal {
signal: psi_b,
theta_factor: None,
basis_scale: None,
is_effect: true,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::DmlData;
use crate::learner::Ols;
use crate::riesz::{Ate, AverageDerivative, PolicyEffect, RieszPenalty, ShiftEffect};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use rand_distr::StandardNormal;
fn continuous(n: usize, seed: u64, theta: f64) -> DmlData {
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let mut draw = || -> f64 { rng.sample(StandardNormal) };
let x = Array2::from_shape_fn((n, 2), |_| draw());
let d: Array1<f64> = (0..n).map(|i| 0.5 * x[[i, 0]] + draw()).collect();
let y: Array1<f64> = (0..n)
.map(|i| theta * d[i] + x[[i, 0]].sin() + x[[i, 1]] + draw())
.collect();
DmlData::new(y, d, x).unwrap()
}
#[test]
fn average_derivative_and_shift_recover_theta() {
let data = continuous(2000, 1, 0.7);
let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
.fit(&data)
.unwrap();
assert!(
(fit.coef()[0] - 0.7).abs() < 4.0 * fit.se()[0],
"{}",
fit.coef()[0]
);
let shift = AutoDml::new(Ols::new(), ShiftEffect::new(0.5).per_unit(true))
.fit(&data)
.unwrap();
assert!((shift.coef()[0] - fit.coef()[0]).abs() < 1e-6);
let policy = PolicyEffect::new(data.treatment(0).mapv(|d| d + 0.5));
let p = AutoDml::new(Ols::new(), policy).fit(&data).unwrap();
let s = AutoDml::new(Ols::new(), ShiftEffect::new(0.5))
.fit(&data)
.unwrap();
assert!((p.coef()[0] - s.coef()[0]).abs() < 1e-10);
assert!(fit.prediction("riesz", 0, 0).is_some());
}
#[test]
fn riesz_correction_removes_the_bias_of_a_misspecified_regression() {
let mut rng = ChaCha8Rng::seed_from_u64(7);
let n = 4000;
let mut draw = || -> f64 { rng.sample(StandardNormal) };
let x = Array2::from_shape_fn((n, 1), |_| draw());
let d: Array1<f64> = (0..n).map(|i| x[[i, 0]] + draw()).collect();
let y: Array1<f64> = (0..n)
.map(|i| d[i] * (1.0 + x[[i, 0]]) + x[[i, 0]] + draw())
.collect();
let data = DmlData::new(y, d, x).unwrap();
let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
.fit(&data)
.unwrap();
assert!(
(fit.coef()[0] - 1.0).abs() < 4.0 * fit.se()[0],
"{} ± {}",
fit.coef()[0],
fit.se()[0]
);
let plug_in = fit.prediction("functional", 0, 0).unwrap().mean().unwrap();
assert!((plug_in - 1.0).abs() > (fit.coef()[0] - 1.0).abs());
}
#[test]
fn ate_is_close_to_irm() {
let mut rng = ChaCha8Rng::seed_from_u64(3);
let n = 3000;
let mut draw = || -> f64 { rng.sample(StandardNormal) };
let x = Array2::from_shape_fn((n, 2), |_| draw());
let mut u = ChaCha8Rng::seed_from_u64(4);
let d: Array1<f64> = (0..n)
.map(|i| {
let p = 1.0 / (1.0 + (-0.8 * x[[i, 0]]).exp());
f64::from(u.gen::<f64>() < p)
})
.collect();
let y: Array1<f64> = (0..n)
.map(|i| 0.5 * d[i] + x[[i, 0]] + 0.5 * x[[i, 1]] + draw())
.collect();
let data = DmlData::new(y, d, x).unwrap();
let auto = AutoDml::new(Ols::new(), Ate).fit(&data).unwrap();
let irm = crate::Irm::new(Ols::new(), crate::LogisticRegression::default())
.fit(&data)
.unwrap();
assert!((auto.coef()[0] - irm.coef()[0]).abs() < 0.5 * irm.se()[0]);
assert!((auto.coef()[0] - 0.5).abs() < 4.0 * auto.se()[0]);
let s = auto
.sensitivity_analysis(&crate::SensitivityParams::default())
.unwrap();
assert!(s.theta_lower[0] < auto.coef()[0]);
}
#[test]
fn fixed_penalty_and_errors() {
let data = continuous(300, 2, 0.5);
let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
.riesz(RieszLasso::new().penalty(RieszPenalty::Fixed(0.0)))
.fit(&data)
.unwrap();
assert!(fit.coef()[0].is_finite());
assert!(AutoDml::new(Ols::new(), Ate).fit(&data).is_err());
}
#[test]
fn coverage_of_the_average_derivative() {
let reps = 120;
let mut hits = 0;
for r in 0..reps {
let data = continuous(400, 100 + r, 0.7);
let fit = AutoDml::new(Ols::new(), AverageDerivative::new())
.seed(r)
.fit(&data)
.unwrap();
let ci = fit.confint(0.95).unwrap();
if ci[[0, 0]] <= 0.7 && 0.7 <= ci[[0, 1]] {
hits += 1;
}
}
let cover = hits as f64 / reps as f64;
assert!(cover > 0.88, "coverage {cover}");
}
}