Skip to main content

dml_core/
blp.rs

1//! Best linear predictor (BLP) of a CATE and group average treatment
2//! effects (GATE), following Semenova & Chernozhukov (2021) and DoubleML.
3//!
4//! Given an orthogonal signal `φᵢ` with `E[φ | X] = τ(X)` (e.g. the AIPW
5//! pseudo-outcome), the BLP is the OLS fit of `φ` on a basis `b(X)`, with
6//! heteroskedasticity-robust (HC0) covariance `Ω`. Pointwise inference for
7//! `τ̂(x) = b(x)ᵀβ̂` uses `se(x) = √(b(x)ᵀ Ω b(x))`.
8
9use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
10use rand::{Rng, SeedableRng};
11use rand_chacha::ChaCha8Rng;
12use rand_distr::StandardNormal;
13
14use crate::error::{DmlError, Result};
15use crate::fit::{aggregate, check_level};
16use crate::linalg::{cholesky_factor, spd_inverse};
17use crate::stats::{median, norm_ppf, norm_sf, quantile_mut};
18
19/// A fitted best linear predictor, one OLS fit per repetition of the signal.
20#[derive(Debug, Clone)]
21pub struct Blp {
22    /// Shape `(p, n_rep)`.
23    all_coef: Array2<f64>,
24    all_se: Array2<f64>,
25    /// HC0 covariance per repetition, each `(p, p)`.
26    omega: Vec<Array2<f64>>,
27    coef: Array1<f64>,
28    se: Array1<f64>,
29    is_gate: bool,
30}
31
32impl Blp {
33    /// Regress each column of `signal` (shape `(n, n_rep)`) on `basis` (shape `(n, p)`).
34    pub fn fit(signal: ArrayView2<'_, f64>, basis: ArrayView2<'_, f64>) -> Result<Self> {
35        let (n, n_rep) = signal.dim();
36        let p = basis.ncols();
37        if basis.nrows() != n {
38            return Err(DmlError::DimensionMismatch(format!(
39                "basis has {} rows, signal has {n}",
40                basis.nrows()
41            )));
42        }
43        if p == 0 || n_rep == 0 || n <= p {
44            return Err(DmlError::InvalidArgument(
45                "BLP needs at least one basis column, one repetition and n > p".into(),
46            ));
47        }
48        let btb = basis.t().dot(&basis);
49        let bread = spd_inverse(&btb).map_err(|_| {
50            DmlError::InvalidArgument("basis columns are collinear (BᵀB is singular)".into())
51        })?;
52        let mut all_coef = Array2::zeros((p, n_rep));
53        let mut all_se = Array2::zeros((p, n_rep));
54        let mut omega = Vec::with_capacity(n_rep);
55        for r in 0..n_rep {
56            let phi = signal.column(r);
57            let beta = bread.dot(&basis.t().dot(&phi));
58            let resid = &phi - &basis.dot(&beta);
59            // HC0 meat: Bᵀ diag(e²) B
60            let weighted = &basis * &resid.mapv(|e| e * e).insert_axis(Axis(1));
61            let meat = basis.t().dot(&weighted);
62            let om = bread.dot(&meat).dot(&bread);
63            all_se.column_mut(r).assign(&om.diag().mapv(f64::sqrt));
64            all_coef.column_mut(r).assign(&beta);
65            omega.push(om);
66        }
67        let (coef, se) = aggregate(&all_coef, &all_se);
68        Ok(Self {
69            all_coef,
70            all_se,
71            omega,
72            coef,
73            se,
74            is_gate: false,
75        })
76    }
77
78    /// GATEs: BLP on mutually exclusive 0/1 group dummies.
79    pub fn fit_gate(signal: ArrayView2<'_, f64>, groups: ArrayView2<'_, f64>) -> Result<Self> {
80        let valid = groups.iter().all(|&g| g == 0.0 || g == 1.0)
81            && groups.rows().into_iter().all(|r| r.sum() <= 1.0);
82        if !valid {
83            return Err(DmlError::InvalidArgument(
84                "groups must be mutually exclusive 0/1 dummy columns".into(),
85            ));
86        }
87        let mut blp = Self::fit(signal, groups)?;
88        blp.is_gate = true;
89        Ok(blp)
90    }
91
92    pub fn is_gate(&self) -> bool {
93        self.is_gate
94    }
95
96    /// Basis coefficients (for GATEs: the group effects).
97    pub fn coef(&self) -> &Array1<f64> {
98        &self.coef
99    }
100
101    /// HC0 standard errors of the coefficients.
102    pub fn se(&self) -> &Array1<f64> {
103        &self.se
104    }
105
106    pub fn t_stat(&self) -> Array1<f64> {
107        &self.coef / &self.se
108    }
109
110    pub fn p_value(&self) -> Array1<f64> {
111        self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
112    }
113
114    /// HC0 covariance of the coefficients for one repetition.
115    pub fn covariance(&self, rep: usize) -> Option<&Array2<f64>> {
116        self.omega.get(rep)
117    }
118
119    /// Pointwise intervals for the coefficients, shape `(p, 2)`.
120    pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
121        check_level(level)?;
122        let z = norm_ppf(0.5 + level / 2.0);
123        Ok(per_rep_bounds(
124            &self.all_coef,
125            &self.all_se,
126            &Array1::from_elem(self.all_coef.ncols(), z),
127        ))
128    }
129
130    /// Predicted effects `b(x)ᵀβ̂` and standard errors at new basis rows.
131    pub fn predict(&self, basis: ArrayView2<'_, f64>) -> Result<(Array1<f64>, Array1<f64>)> {
132        let (g, s) = self.predict_all(basis)?;
133        Ok(aggregate(&g, &s))
134    }
135
136    fn predict_all(&self, basis: ArrayView2<'_, f64>) -> Result<(Array2<f64>, Array2<f64>)> {
137        if basis.ncols() != self.coef.len() {
138            return Err(DmlError::DimensionMismatch(format!(
139                "basis has {} columns, the BLP was fitted with {}",
140                basis.ncols(),
141                self.coef.len()
142            )));
143        }
144        let n_rep = self.all_coef.ncols();
145        let mut g = Array2::zeros((basis.nrows(), n_rep));
146        let mut s = Array2::zeros((basis.nrows(), n_rep));
147        for r in 0..n_rep {
148            g.column_mut(r).assign(&basis.dot(&self.all_coef.column(r)));
149            let bo = basis.dot(&self.omega[r]);
150            let var = (&bo * &basis).sum_axis(Axis(1));
151            s.column_mut(r).assign(&var.mapv(f64::sqrt));
152        }
153        Ok((g, s))
154    }
155
156    /// Pointwise intervals for `τ(x)` at new basis rows: columns
157    /// `[lower, effect, upper]`.
158    pub fn confint_at(&self, basis: ArrayView2<'_, f64>, level: f64) -> Result<Array2<f64>> {
159        check_level(level)?;
160        let z = norm_ppf(0.5 + level / 2.0);
161        let crit = Array1::from_elem(self.all_coef.ncols(), z);
162        self.intervals_at(basis, &crit)
163    }
164
165    /// Simultaneous intervals over all rows of `basis`, with critical values
166    /// from a Gaussian multiplier bootstrap of `b(x)ᵀ Ω^{1/2} Z / se(x)`.
167    pub fn joint_confint_at(
168        &self,
169        basis: ArrayView2<'_, f64>,
170        level: f64,
171        n_boot: usize,
172        seed: u64,
173    ) -> Result<Array2<f64>> {
174        check_level(level)?;
175        if n_boot == 0 {
176            return Err(DmlError::InvalidArgument("n_boot must be positive".into()));
177        }
178        let (_, all_s) = self.predict_all(basis)?;
179        let p = self.coef.len();
180        let mut rng = ChaCha8Rng::seed_from_u64(seed);
181        let mut crit = Array1::zeros(self.all_coef.ncols());
182        for (r, om) in self.omega.iter().enumerate() {
183            let l = cholesky_factor(om)?;
184            let bl = basis.dot(&l);
185            let mut max_abs = Vec::with_capacity(n_boot);
186            for _ in 0..n_boot {
187                let z: Array1<f64> = (0..p)
188                    .map(|_| rng.sample::<f64, _>(StandardNormal))
189                    .collect();
190                let draw = bl.dot(&z);
191                let m = draw
192                    .iter()
193                    .zip(all_s.column(r))
194                    .fold(0.0_f64, |m, (&v, &s)| m.max((v / s).abs()));
195                max_abs.push(m);
196            }
197            crit[r] = quantile_mut(&mut max_abs, level);
198        }
199        self.intervals_at(basis, &crit)
200    }
201
202    fn intervals_at(&self, basis: ArrayView2<'_, f64>, crit: &Array1<f64>) -> Result<Array2<f64>> {
203        let (g, s) = self.predict_all(basis)?;
204        let (effect, _) = aggregate(&g, &s);
205        let bounds = per_rep_bounds(&g, &s, crit);
206        let mut out = Array2::zeros((basis.nrows(), 3));
207        out.column_mut(0).assign(&bounds.column(0));
208        out.column_mut(1).assign(&effect);
209        out.column_mut(2).assign(&bounds.column(1));
210        Ok(out)
211    }
212}
213
214/// Median over repetitions of `value ∓ crit·se`, shape `(rows, 2)`.
215fn per_rep_bounds(values: &Array2<f64>, ses: &Array2<f64>, crit: &Array1<f64>) -> Array2<f64> {
216    let rows = values.nrows();
217    let mut out = Array2::zeros((rows, 2));
218    for i in 0..rows {
219        let lo: Vec<f64> = (0..values.ncols())
220            .map(|r| values[[i, r]] - crit[r] * ses[[i, r]])
221            .collect();
222        let hi: Vec<f64> = (0..values.ncols())
223            .map(|r| values[[i, r]] + crit[r] * ses[[i, r]])
224            .collect();
225        out[[i, 0]] = median(&lo);
226        out[[i, 1]] = median(&hi);
227    }
228    out
229}
230
231/// Convenience: a single-repetition signal as an `(n, 1)` matrix.
232pub(crate) fn as_column(signal: ArrayView1<'_, f64>) -> Array2<f64> {
233    signal.to_owned().insert_axis(Axis(1))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use ndarray::array;
240
241    #[test]
242    fn intercept_only_blp_is_mean_with_hc0_se() {
243        let phi = array![1.0, 2.0, 4.0, 5.0];
244        let basis = Array2::ones((4, 1));
245        let blp = Blp::fit(as_column(phi.view()).view(), basis.view()).unwrap();
246        assert!((blp.coef()[0] - 3.0).abs() < 1e-12);
247        // HC0: Σe² / n² = (4 + 1 + 1 + 4) / 16
248        assert!((blp.se()[0] - (10.0_f64 / 16.0).sqrt()).abs() < 1e-12);
249    }
250
251    #[test]
252    fn prediction_se_matches_coefficient_se_on_unit_basis() {
253        let phi = array![1.0, 3.0, 2.0, 6.0, 5.0, 9.0];
254        let basis = array![
255            [1.0, 0.0],
256            [1.0, 1.0],
257            [1.0, 0.5],
258            [1.0, 2.0],
259            [1.0, 1.5],
260            [1.0, 3.0]
261        ];
262        let blp = Blp::fit(as_column(phi.view()).view(), basis.view()).unwrap();
263        let (effect, se) = blp.predict(array![[1.0, 0.0], [0.0, 1.0]].view()).unwrap();
264        assert!((effect[0] - blp.coef()[0]).abs() < 1e-12);
265        assert!((se[1] - blp.se()[1]).abs() < 1e-12);
266        let joint = blp.joint_confint_at(basis.view(), 0.95, 2000, 1).unwrap();
267        let point = blp.confint_at(basis.view(), 0.95).unwrap();
268        assert!((0..6).all(|i| joint[[i, 2]] - joint[[i, 0]] >= point[[i, 2]] - point[[i, 0]]));
269    }
270
271    #[test]
272    fn gate_requires_exclusive_dummies() {
273        let phi = array![1.0, 2.0, 3.0, 4.0];
274        let overlapping = array![[1.0, 1.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0]];
275        assert!(Blp::fit_gate(as_column(phi.view()).view(), overlapping.view()).is_err());
276        let groups = array![[1.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0]];
277        let gate = Blp::fit_gate(as_column(phi.view()).view(), groups.view()).unwrap();
278        let diff = gate.coef() - &array![1.5, 3.5];
279        assert!(diff.iter().all(|v| v.abs() < 1e-12));
280    }
281}