1use std::fmt;
4
5use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3, Axis};
6
7use crate::blp::Blp;
8use crate::crossfit::Fold;
9use crate::data::DmlData;
10use crate::error::{DmlError, Result};
11use crate::model::{LinearScoreModel, ScoreInput};
12use crate::stats::{median, norm_ppf, norm_sf};
13
14pub fn fit_model<M: LinearScoreModel + ?Sized>(
22 model: &M,
23 data: &DmlData,
24 splits: &[Vec<Fold>],
25) -> Result<DmlFit> {
26 model.validate(data)?;
27 let n = data.n_obs();
28 check_splits(splits, n)?;
29 let (k, n_rep) = (data.n_treat(), splits.len());
30
31 let mut all_coef = Array2::<f64>::zeros((k, n_rep));
32 let mut all_se = Array2::<f64>::zeros((k, n_rep));
33 let mut psi = Array3::<f64>::zeros((n, k, n_rep));
34 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
35 let mut psi_a = Array3::<f64>::zeros((n, k, n_rep));
36 let mut psi_b = Array3::<f64>::zeros((n, k, n_rep));
37 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
38
39 for j in 0..k {
40 let x_j = data.covariates_for(j);
41 let input = ScoreInput {
42 y: data.y(),
43 d: data.treatment(j),
44 x: x_j.view(),
45 z: data.z(),
46 t: data.time(),
47 };
48 for (r, folds) in splits.iter().enumerate() {
49 let est = model.estimate_score(&input, folds)?;
50 let sol = est.score.solve()?;
51 if !(sol.theta.is_finite() && sol.se.is_finite()) {
52 return Err(DmlError::InvalidArgument(format!(
53 "non-finite estimate for treatment {j}, repetition {r}"
54 )));
55 }
56 all_coef[[j, r]] = sol.theta;
57 all_se[[j, r]] = sol.se;
58 psi_deriv[[j, r]] = sol.j;
59 psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
60 psi_a
61 .slice_mut(ndarray::s![.., j, r])
62 .assign(&est.score.psi_a);
63 psi_b
64 .slice_mut(ndarray::s![.., j, r])
65 .assign(&est.score.psi_b);
66 for (name, pred) in est.predictions {
67 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
68 Some(i) => i,
69 None => {
70 predictions
71 .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
72 predictions.len() - 1
73 }
74 };
75 predictions[slot]
76 .1
77 .slice_mut(ndarray::s![.., r, j])
78 .assign(&pred);
79 }
80 }
81 }
82
83 let (coef, se) = aggregate(&all_coef, &all_se);
84 Ok(DmlFit {
85 model: model.name(),
86 treatment_names: data.treatment_names().to_vec(),
87 n_obs: n,
88 n_folds: splits[0].len(),
89 coef,
90 se,
91 all_coef,
92 all_se,
93 psi,
94 psi_a,
95 psi_b,
96 psi_deriv,
97 predictions,
98 })
99}
100
101fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
102 if splits.is_empty() {
103 return Err(DmlError::InvalidArgument("no sample splits given".into()));
104 }
105 for (r, folds) in splits.iter().enumerate() {
106 let mut seen = vec![false; n];
107 for fold in folds {
108 for &i in &fold.test {
109 if i >= n || seen[i] {
110 return Err(DmlError::InvalidArgument(format!(
111 "repetition {r}: test sets must partition 0..{n}"
112 )));
113 }
114 seen[i] = true;
115 }
116 if fold.train.iter().any(|&i| i >= n) {
117 return Err(DmlError::InvalidArgument(format!(
118 "repetition {r}: training index out of range"
119 )));
120 }
121 }
122 if seen.iter().any(|s| !s) {
123 return Err(DmlError::InvalidArgument(format!(
124 "repetition {r}: test sets must partition 0..{n}"
125 )));
126 }
127 }
128 Ok(())
129}
130
131pub(crate) fn aggregate(
134 all_coef: &Array2<f64>,
135 all_se: &Array2<f64>,
136) -> (Array1<f64>, Array1<f64>) {
137 const CRIT: f64 = 1.96;
138 let k = all_coef.nrows();
139 let mut coef = Array1::zeros(k);
140 let mut se = Array1::zeros(k);
141 for j in 0..k {
142 let c = all_coef.row(j);
143 coef[j] = median(&c.to_vec());
144 let upper: Vec<f64> = c
145 .iter()
146 .zip(all_se.row(j))
147 .map(|(&t, &s)| t + CRIT * s)
148 .collect();
149 se[j] = (median(&upper) - coef[j]) / CRIT;
150 }
151 (coef, se)
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum PAdjust {
157 Bonferroni,
158 Holm,
160}
161
162#[derive(Debug, Clone)]
168pub struct DmlFit {
169 pub(crate) model: String,
170 pub(crate) treatment_names: Vec<String>,
171 pub(crate) n_obs: usize,
172 pub(crate) n_folds: usize,
173 pub(crate) coef: Array1<f64>,
174 pub(crate) se: Array1<f64>,
175 pub(crate) all_coef: Array2<f64>,
176 pub(crate) all_se: Array2<f64>,
177 pub(crate) psi: Array3<f64>,
179 pub(crate) psi_a: Array3<f64>,
181 pub(crate) psi_b: Array3<f64>,
182 pub(crate) psi_deriv: Array2<f64>,
184 pub(crate) predictions: Vec<(String, Array3<f64>)>,
186}
187
188impl DmlFit {
189 pub fn model_name(&self) -> &str {
190 &self.model
191 }
192
193 pub fn treatment_names(&self) -> &[String] {
194 &self.treatment_names
195 }
196
197 pub fn n_obs(&self) -> usize {
198 self.n_obs
199 }
200
201 pub fn n_treat(&self) -> usize {
202 self.coef.len()
203 }
204
205 pub fn n_rep(&self) -> usize {
206 self.all_coef.ncols()
207 }
208
209 pub fn n_folds(&self) -> usize {
210 self.n_folds
211 }
212
213 pub fn coef(&self) -> ArrayView1<'_, f64> {
215 self.coef.view()
216 }
217
218 pub fn se(&self) -> ArrayView1<'_, f64> {
220 self.se.view()
221 }
222
223 pub fn t_stat(&self) -> Array1<f64> {
224 &self.coef / &self.se
225 }
226
227 pub fn p_value(&self) -> Array1<f64> {
231 self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
232 }
233
234 fn per_rep_pvals(&self) -> Array2<f64> {
235 let mut p = &self.all_coef / &self.all_se;
236 p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
237 p
238 }
239
240 pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
243 check_level(level)?;
244 let z = norm_ppf(0.5 + level / 2.0);
245 Ok(confint_from(
246 &self.all_coef,
247 &self.all_se,
248 &Array1::from_elem(self.n_rep(), z),
249 ))
250 }
251
252 pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
256 let raw = self.per_rep_pvals();
257 let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
258 for (r, col) in raw.columns().into_iter().enumerate() {
259 let adj = match method {
260 PAdjust::Bonferroni => {
261 let m = col.len() as f64;
262 col.mapv(|p| (m * p).min(1.0))
263 }
264 PAdjust::Holm => holm(col),
265 };
266 adjusted.column_mut(r).assign(&adj);
267 }
268 adjusted
269 .rows()
270 .into_iter()
271 .map(|row| median(&row.to_vec()))
272 .collect()
273 }
274
275 pub fn all_coef(&self) -> ArrayView2<'_, f64> {
277 self.all_coef.view()
278 }
279
280 pub fn all_se(&self) -> ArrayView2<'_, f64> {
281 self.all_se.view()
282 }
283
284 pub fn psi(&self) -> ArrayView3<'_, f64> {
286 self.psi.view()
287 }
288
289 pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
291 (self.psi_a.view(), self.psi_b.view())
292 }
293
294 pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
298 Blp::fit(self.orthogonal_signal()?.view(), basis)
299 }
300
301 pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
304 Blp::fit_gate(self.orthogonal_signal()?.view(), groups)
305 }
306
307 fn orthogonal_signal(&self) -> Result<Array2<f64>> {
308 if self.n_treat() != 1 || self.psi_a.iter().any(|&a| a != -1.0) {
309 return Err(DmlError::InvalidArgument(
310 "CATE / GATE need a single treatment and a score with psi_a = -1 (IRM with the ATE score)"
311 .into(),
312 ));
313 }
314 Ok(self.psi_b.index_axis(Axis(1), 0).to_owned())
315 }
316
317 pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
319 self.predictions.iter().map(|(n, _)| n.as_str())
320 }
321
322 pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
324 self.predictions
325 .iter()
326 .find(|(n, _)| n == name)
327 .map(|(_, p)| p.view())
328 }
329
330 pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
332 let p = self.predictions(name)?;
333 (rep < self.n_rep() && treatment < self.n_treat())
334 .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
335 }
336}
337
338pub(crate) fn confint_from(
341 all_coef: &Array2<f64>,
342 all_se: &Array2<f64>,
343 crit: &Array1<f64>,
344) -> Array2<f64> {
345 let (k, n_rep) = all_coef.dim();
346 let mut ci = Array2::zeros((k, 2));
347 for j in 0..k {
348 let lo: Vec<f64> = (0..n_rep)
349 .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
350 .collect();
351 let hi: Vec<f64> = (0..n_rep)
352 .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
353 .collect();
354 ci[[j, 0]] = median(&lo);
355 ci[[j, 1]] = median(&hi);
356 }
357 ci
358}
359
360impl DmlFit {
361 #[allow(clippy::too_many_arguments)]
366 pub(crate) fn from_scores(
367 model: String,
368 treatment_names: Vec<String>,
369 n_folds: usize,
370 all_coef: Array2<f64>,
371 psi: Array3<f64>,
372 psi_a: Array3<f64>,
373 psi_b: Array3<f64>,
374 predictions: Vec<(String, Array3<f64>)>,
375 ) -> Self {
376 let (n, k, n_rep) = psi.dim();
377 let mut psi_deriv = Array2::<f64>::zeros((k, n_rep));
378 let mut all_se = Array2::<f64>::zeros((k, n_rep));
379 for j in 0..k {
380 for r in 0..n_rep {
381 let jac = psi_a.slice(ndarray::s![.., j, r]).sum() / n as f64;
382 let col = psi.slice(ndarray::s![.., j, r]);
383 psi_deriv[[j, r]] = jac;
384 all_se[[j, r]] = (col.dot(&col) / n as f64 / (jac * jac) / n as f64).sqrt();
385 }
386 }
387 let (coef, se) = aggregate(&all_coef, &all_se);
388 Self {
389 model,
390 treatment_names,
391 n_obs: n,
392 n_folds,
393 coef,
394 se,
395 all_coef,
396 all_se,
397 psi,
398 psi_a,
399 psi_b,
400 psi_deriv,
401 predictions,
402 }
403 }
404
405 pub(crate) fn linear_combination(
409 &self,
410 model: String,
411 names: Vec<String>,
412 weights: &Array2<f64>,
413 ) -> Self {
414 let (n, k, n_rep) = self.psi.dim();
415 assert_eq!(weights.ncols(), k, "one weight per estimate");
416 let n_comb = weights.nrows();
417 let mut scaled = Array3::<f64>::zeros((n, n_comb, n_rep));
418 let mut all_coef = Array2::<f64>::zeros((n_comb, n_rep));
419 let mut all_se = Array2::<f64>::zeros((n_comb, n_rep));
420 for r in 0..n_rep {
421 for c in 0..n_comb {
422 let mut col = Array1::<f64>::zeros(n);
423 let mut theta = 0.0;
424 for j in 0..k {
425 let w = weights[[c, j]];
426 if w != 0.0 {
427 col.scaled_add(
428 w / self.psi_deriv[[j, r]],
429 &self.psi.slice(ndarray::s![.., j, r]),
430 );
431 theta += w * self.all_coef[[j, r]];
432 }
433 }
434 all_coef[[c, r]] = theta;
435 all_se[[c, r]] = (col.dot(&col) / n as f64 / n as f64).sqrt();
436 scaled.slice_mut(ndarray::s![.., c, r]).assign(&col);
437 }
438 }
439 let (coef, se) = aggregate(&all_coef, &all_se);
440 Self {
441 model,
442 treatment_names: names,
443 n_obs: n,
444 n_folds: self.n_folds,
445 coef,
446 se,
447 all_coef,
448 all_se,
449 psi: scaled,
452 psi_a: Array3::zeros((n, n_comb, n_rep)),
453 psi_b: Array3::zeros((n, n_comb, n_rep)),
454 psi_deriv: Array2::ones((n_comb, n_rep)),
455 predictions: Vec::new(),
456 }
457 }
458}
459
460pub(crate) fn check_level(level: f64) -> Result<()> {
461 if level > 0.0 && level < 1.0 {
462 Ok(())
463 } else {
464 Err(DmlError::InvalidArgument(
465 "confidence level must be in (0, 1)".into(),
466 ))
467 }
468}
469
470fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
472 let m = p.len();
473 let mut order: Vec<usize> = (0..m).collect();
474 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
475 let mut out = Array1::zeros(m);
476 let mut running = 0.0_f64;
477 for (rank, &i) in order.iter().enumerate() {
478 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
479 out[i] = running;
480 }
481 out
482}
483
484impl fmt::Display for DmlFit {
485 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486 writeln!(
487 f,
488 "{} | n_obs = {}, n_folds = {}, n_rep = {}",
489 self.model,
490 self.n_obs,
491 self.n_folds,
492 self.n_rep()
493 )?;
494 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
495 let (t, p) = (self.t_stat(), self.p_value());
496 let width = self
497 .treatment_names
498 .iter()
499 .map(String::len)
500 .max()
501 .unwrap_or(1)
502 .max(4);
503 write!(
504 f,
505 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
506 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
507 )?;
508 for j in 0..self.n_treat() {
509 write!(
510 f,
511 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
512 self.treatment_names[j],
513 self.coef[j],
514 self.se[j],
515 t[j],
516 p[j],
517 ci[[j, 0]],
518 ci[[j, 1]]
519 )?;
520 }
521 Ok(())
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use ndarray::array;
529
530 #[test]
531 fn aggregation_matches_doubleml_definition() {
532 let coef = array![[1.0, 2.0, 4.0]];
533 let se = array![[0.5, 0.1, 0.2]];
534 let (c, s) = aggregate(&coef, &se);
535 assert_eq!(c[0], 2.0);
536 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
538 }
539
540 #[test]
541 fn holm_matches_statsmodels() {
542 let adj = holm(array![0.01, 0.04, 0.03].view());
544 assert_eq!(adj, array![0.03, 0.06, 0.06]);
545 }
546}