use dml_core::{
DmlData, DrLearner, LogisticRegression, Ols, RLearner, Regressor, Result, SLearner, TLearner,
XLearner,
};
use ndarray::{concatenate, Array1, Array2, ArrayView1, ArrayView2, Axis};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
fn std_normal(rng: &mut impl Rng) -> f64 {
let u1: f64 = 1.0 - rng.gen::<f64>();
let u2: f64 = rng.gen();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
fn tau(x: ArrayView1<'_, f64>) -> f64 {
1.0 + x[0]
}
fn simulate(n: usize, seed: u64) -> DmlData {
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let x = Array2::from_shape_simple_fn((n, 3), || std_normal(&mut rng));
let mut d = Array1::zeros(n);
let mut y = Array1::zeros(n);
for i in 0..n {
let row = x.row(i);
let e = 1.0 / (1.0 + (-(0.5 * row[0] - 0.3 * row[1])).exp());
d[i] = f64::from(rng.gen::<f64>() < e);
y[i] = row[1] + 0.5 * row[2] + d[i] * tau(row) + std_normal(&mut rng);
}
DmlData::new(y, d, x).unwrap()
}
fn test_grid() -> Array2<f64> {
Array2::from_shape_fn(
(41, 3),
|(i, j)| if j == 0 { -2.0 + 0.1 * i as f64 } else { 0.0 },
)
}
fn rmse(effect: &Array1<f64>, grid: &Array2<f64>) -> f64 {
let err: f64 = grid
.rows()
.into_iter()
.zip(effect)
.map(|(r, &e)| (e - tau(r)).powi(2))
.sum();
(err / grid.nrows() as f64).sqrt()
}
#[derive(Clone, Default)]
struct InteractedOls(Ols);
fn interact(xd: ArrayView2<'_, f64>) -> Array2<f64> {
let p = xd.ncols() - 1;
let d = xd.column(p).to_owned().insert_axis(Axis(1));
let x = xd.slice(ndarray::s![.., ..p]);
concatenate![Axis(1), xd, &x * &d]
}
impl Regressor for InteractedOls {
fn fit(&mut self, x: ArrayView2<'_, f64>, y: ArrayView1<'_, f64>) -> Result<()> {
self.0.fit(interact(x).view(), y)
}
fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
self.0.predict(interact(x).view())
}
fn fresh(&self) -> Box<dyn Regressor> {
Box::new(Self::default())
}
}
#[test]
fn all_meta_learners_recover_the_effect_function() {
let data = simulate(4000, 1);
let grid = test_grid();
let fits = [
SLearner::new(InteractedOls::default()).fit(&data).unwrap(),
TLearner::new(Ols::new()).fit(&data).unwrap(),
XLearner::new(Ols::new(), Ols::new(), LogisticRegression::default())
.fit(&data)
.unwrap(),
RLearner::new(Ols::new(), LogisticRegression::default(), Ols::new())
.seed(3)
.fit(&data)
.unwrap(),
DrLearner::new(Ols::new(), LogisticRegression::default(), Ols::new())
.seed(3)
.fit(&data)
.unwrap(),
];
for fit in &fits {
let effect = fit.effect(grid.view()).unwrap();
let err = rmse(&effect, &grid);
assert!(err < 0.15, "{}: rmse {err}", fit.method());
let ate = fit.average_effect(data.x()).unwrap();
assert!((ate - 1.0).abs() < 0.15, "{}: ate {ate}", fit.method());
}
}
#[test]
fn plain_s_learner_with_linear_model_is_constant() {
let data = simulate(2000, 2);
let fit = SLearner::new(Ols::new()).fit(&data).unwrap();
let effect = fit.effect(test_grid().view()).unwrap();
let spread = effect.iter().fold(f64::MIN, |m, &v| m.max(v))
- effect.iter().fold(f64::MAX, |m, &v| m.min(v));
assert!(spread < 1e-9);
}
#[test]
fn dr_blp_inference_covers_true_coefficients() {
let reps = 100;
let mut covered = [0usize; 2];
for r in 0..reps {
let data = simulate(1500, 100 + r);
let fit = DrLearner::new(Ols::new(), LogisticRegression::default(), Ols::new())
.seed(r)
.fit(&data)
.unwrap();
let basis = concatenate![
Axis(1),
Array2::ones((data.n_obs(), 1)),
data.x().slice(ndarray::s![.., ..1])
];
let ci = fit.blp(basis.view()).unwrap().confint(0.95).unwrap();
for j in 0..2 {
covered[j] += usize::from(ci[[j, 0]] <= 1.0 && 1.0 <= ci[[j, 1]]);
}
}
for (j, &c) in covered.iter().enumerate() {
let coverage = c as f64 / reps as f64;
assert!(
(0.88..=1.0).contains(&coverage),
"coef {j}: coverage {coverage}"
);
}
}
#[test]
fn gate_and_intercept_blp_agree_with_ate() {
let data = simulate(3000, 9);
let fit = DrLearner::new(Ols::new(), LogisticRegression::default(), Ols::new())
.fit(&data)
.unwrap();
let n = data.n_obs();
let ate = fit.blp(Array2::ones((n, 1)).view()).unwrap();
let phi = fit.pseudo_outcomes().unwrap();
assert!((ate.coef()[0] - phi.mean().unwrap()).abs() < 1e-12);
let groups = Array2::from_shape_fn((n, 2), |(i, g)| {
let high = data.x()[[i, 0]] > 0.0;
f64::from(if g == 0 { !high } else { high })
});
let gate = fit.gate(groups.view()).unwrap();
assert!((gate.coef()[0] - 0.202).abs() < 0.2, "{}", gate.coef()[0]);
assert!((gate.coef()[1] - 1.798).abs() < 0.2, "{}", gate.coef()[1]);
}
#[test]
fn r_learner_rejects_effect_learner_without_weights() {
struct NoWeights;
impl Regressor for NoWeights {
fn fit(&mut self, _: ArrayView2<'_, f64>, _: ArrayView1<'_, f64>) -> Result<()> {
Ok(())
}
fn predict(&self, x: ArrayView2<'_, f64>) -> Result<Array1<f64>> {
Ok(Array1::zeros(x.nrows()))
}
fn fresh(&self) -> Box<dyn Regressor> {
Box::new(NoWeights)
}
}
let data = simulate(300, 4);
let err = RLearner::new(Ols::new(), LogisticRegression::default(), NoWeights)
.fit(&data)
.unwrap_err();
assert!(err.to_string().contains("sample weights"));
assert!(TLearner::new(Ols::new())
.fit(
&DmlData::new(
data.y().to_owned(),
data.x().column(0).to_owned(),
data.x().to_owned()
)
.unwrap()
)
.is_err());
}