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