1use 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#[derive(Debug, Clone)]
21pub struct Blp {
22 all_coef: Array2<f64>,
24 all_se: Array2<f64>,
25 omega: Vec<Array2<f64>>,
27 coef: Array1<f64>,
28 se: Array1<f64>,
29 is_gate: bool,
30}
31
32impl Blp {
33 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 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 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 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 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 pub fn coef(&self) -> &Array1<f64> {
124 &self.coef
125 }
126
127 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 pub fn covariance(&self, rep: usize) -> Option<&Array2<f64>> {
142 self.omega.get(rep)
143 }
144
145 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 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 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 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
240fn 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
257pub(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 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}