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 (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 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 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 pub fn coef(&self) -> &Array1<f64> {
98 &self.coef
99 }
100
101 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 pub fn covariance(&self, rep: usize) -> Option<&Array2<f64>> {
116 self.omega.get(rep)
117 }
118
119 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 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 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 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
214fn 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
231pub(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 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}