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 };
47 for (r, folds) in splits.iter().enumerate() {
48 let est = model.estimate_score(&input, folds)?;
49 let sol = est.score.solve()?;
50 if !(sol.theta.is_finite() && sol.se.is_finite()) {
51 return Err(DmlError::InvalidArgument(format!(
52 "non-finite estimate for treatment {j}, repetition {r}"
53 )));
54 }
55 all_coef[[j, r]] = sol.theta;
56 all_se[[j, r]] = sol.se;
57 psi_deriv[[j, r]] = sol.j;
58 psi.slice_mut(ndarray::s![.., j, r]).assign(&sol.psi);
59 psi_a
60 .slice_mut(ndarray::s![.., j, r])
61 .assign(&est.score.psi_a);
62 psi_b
63 .slice_mut(ndarray::s![.., j, r])
64 .assign(&est.score.psi_b);
65 for (name, pred) in est.predictions {
66 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
67 Some(i) => i,
68 None => {
69 predictions
70 .push((name.to_string(), Array3::from_elem((n, n_rep, k), f64::NAN)));
71 predictions.len() - 1
72 }
73 };
74 predictions[slot]
75 .1
76 .slice_mut(ndarray::s![.., r, j])
77 .assign(&pred);
78 }
79 }
80 }
81
82 let (coef, se) = aggregate(&all_coef, &all_se);
83 Ok(DmlFit {
84 model: model.name(),
85 treatment_names: data.treatment_names().to_vec(),
86 n_obs: n,
87 n_folds: splits[0].len(),
88 coef,
89 se,
90 all_coef,
91 all_se,
92 psi,
93 psi_a,
94 psi_b,
95 psi_deriv,
96 predictions,
97 })
98}
99
100fn check_splits(splits: &[Vec<Fold>], n: usize) -> Result<()> {
101 if splits.is_empty() {
102 return Err(DmlError::InvalidArgument("no sample splits given".into()));
103 }
104 for (r, folds) in splits.iter().enumerate() {
105 let mut seen = vec![false; n];
106 for fold in folds {
107 for &i in &fold.test {
108 if i >= n || seen[i] {
109 return Err(DmlError::InvalidArgument(format!(
110 "repetition {r}: test sets must partition 0..{n}"
111 )));
112 }
113 seen[i] = true;
114 }
115 if fold.train.iter().any(|&i| i >= n) {
116 return Err(DmlError::InvalidArgument(format!(
117 "repetition {r}: training index out of range"
118 )));
119 }
120 }
121 if seen.iter().any(|s| !s) {
122 return Err(DmlError::InvalidArgument(format!(
123 "repetition {r}: test sets must partition 0..{n}"
124 )));
125 }
126 }
127 Ok(())
128}
129
130pub(crate) fn aggregate(
133 all_coef: &Array2<f64>,
134 all_se: &Array2<f64>,
135) -> (Array1<f64>, Array1<f64>) {
136 const CRIT: f64 = 1.96;
137 let k = all_coef.nrows();
138 let mut coef = Array1::zeros(k);
139 let mut se = Array1::zeros(k);
140 for j in 0..k {
141 let c = all_coef.row(j);
142 coef[j] = median(&c.to_vec());
143 let upper: Vec<f64> = c
144 .iter()
145 .zip(all_se.row(j))
146 .map(|(&t, &s)| t + CRIT * s)
147 .collect();
148 se[j] = (median(&upper) - coef[j]) / CRIT;
149 }
150 (coef, se)
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum PAdjust {
156 Bonferroni,
157 Holm,
159}
160
161#[derive(Debug, Clone)]
167pub struct DmlFit {
168 pub(crate) model: String,
169 pub(crate) treatment_names: Vec<String>,
170 pub(crate) n_obs: usize,
171 pub(crate) n_folds: usize,
172 pub(crate) coef: Array1<f64>,
173 pub(crate) se: Array1<f64>,
174 pub(crate) all_coef: Array2<f64>,
175 pub(crate) all_se: Array2<f64>,
176 pub(crate) psi: Array3<f64>,
178 pub(crate) psi_a: Array3<f64>,
180 pub(crate) psi_b: Array3<f64>,
181 pub(crate) psi_deriv: Array2<f64>,
183 pub(crate) predictions: Vec<(String, Array3<f64>)>,
185}
186
187impl DmlFit {
188 pub fn model_name(&self) -> &str {
189 &self.model
190 }
191
192 pub fn treatment_names(&self) -> &[String] {
193 &self.treatment_names
194 }
195
196 pub fn n_obs(&self) -> usize {
197 self.n_obs
198 }
199
200 pub fn n_treat(&self) -> usize {
201 self.coef.len()
202 }
203
204 pub fn n_rep(&self) -> usize {
205 self.all_coef.ncols()
206 }
207
208 pub fn n_folds(&self) -> usize {
209 self.n_folds
210 }
211
212 pub fn coef(&self) -> ArrayView1<'_, f64> {
214 self.coef.view()
215 }
216
217 pub fn se(&self) -> ArrayView1<'_, f64> {
219 self.se.view()
220 }
221
222 pub fn t_stat(&self) -> Array1<f64> {
223 &self.coef / &self.se
224 }
225
226 pub fn p_value(&self) -> Array1<f64> {
230 self.t_stat().mapv(|t| 2.0 * norm_sf(t.abs()))
231 }
232
233 fn per_rep_pvals(&self) -> Array2<f64> {
234 let mut p = &self.all_coef / &self.all_se;
235 p.mapv_inplace(|t| 2.0 * norm_sf(t.abs()));
236 p
237 }
238
239 pub fn confint(&self, level: f64) -> Result<Array2<f64>> {
242 check_level(level)?;
243 let z = norm_ppf(0.5 + level / 2.0);
244 Ok(confint_from(
245 &self.all_coef,
246 &self.all_se,
247 &Array1::from_elem(self.n_rep(), z),
248 ))
249 }
250
251 pub fn p_adjust(&self, method: PAdjust) -> Array1<f64> {
255 let raw = self.per_rep_pvals();
256 let mut adjusted = Array2::<f64>::zeros(raw.raw_dim());
257 for (r, col) in raw.columns().into_iter().enumerate() {
258 let adj = match method {
259 PAdjust::Bonferroni => {
260 let m = col.len() as f64;
261 col.mapv(|p| (m * p).min(1.0))
262 }
263 PAdjust::Holm => holm(col),
264 };
265 adjusted.column_mut(r).assign(&adj);
266 }
267 adjusted
268 .rows()
269 .into_iter()
270 .map(|row| median(&row.to_vec()))
271 .collect()
272 }
273
274 pub fn all_coef(&self) -> ArrayView2<'_, f64> {
276 self.all_coef.view()
277 }
278
279 pub fn all_se(&self) -> ArrayView2<'_, f64> {
280 self.all_se.view()
281 }
282
283 pub fn psi(&self) -> ArrayView3<'_, f64> {
285 self.psi.view()
286 }
287
288 pub fn psi_elements(&self) -> (ArrayView3<'_, f64>, ArrayView3<'_, f64>) {
290 (self.psi_a.view(), self.psi_b.view())
291 }
292
293 pub fn cate(&self, basis: ArrayView2<'_, f64>) -> Result<Blp> {
297 Blp::fit(self.orthogonal_signal()?.view(), basis)
298 }
299
300 pub fn gate(&self, groups: ArrayView2<'_, f64>) -> Result<Blp> {
303 Blp::fit_gate(self.orthogonal_signal()?.view(), groups)
304 }
305
306 fn orthogonal_signal(&self) -> Result<Array2<f64>> {
307 if self.n_treat() != 1 || self.psi_a.iter().any(|&a| a != -1.0) {
308 return Err(DmlError::InvalidArgument(
309 "CATE / GATE need a single treatment and a score with psi_a = -1 (IRM with the ATE score)"
310 .into(),
311 ));
312 }
313 Ok(self.psi_b.index_axis(Axis(1), 0).to_owned())
314 }
315
316 pub fn nuisance_names(&self) -> impl Iterator<Item = &str> {
318 self.predictions.iter().map(|(n, _)| n.as_str())
319 }
320
321 pub fn predictions(&self, name: &str) -> Option<ArrayView3<'_, f64>> {
323 self.predictions
324 .iter()
325 .find(|(n, _)| n == name)
326 .map(|(_, p)| p.view())
327 }
328
329 pub fn prediction(&self, name: &str, rep: usize, treatment: usize) -> Option<Array1<f64>> {
331 let p = self.predictions(name)?;
332 (rep < self.n_rep() && treatment < self.n_treat())
333 .then(|| p.index_axis(Axis(2), treatment).column(rep).to_owned())
334 }
335}
336
337pub(crate) fn confint_from(
340 all_coef: &Array2<f64>,
341 all_se: &Array2<f64>,
342 crit: &Array1<f64>,
343) -> Array2<f64> {
344 let (k, n_rep) = all_coef.dim();
345 let mut ci = Array2::zeros((k, 2));
346 for j in 0..k {
347 let lo: Vec<f64> = (0..n_rep)
348 .map(|r| all_coef[[j, r]] - crit[r] * all_se[[j, r]])
349 .collect();
350 let hi: Vec<f64> = (0..n_rep)
351 .map(|r| all_coef[[j, r]] + crit[r] * all_se[[j, r]])
352 .collect();
353 ci[[j, 0]] = median(&lo);
354 ci[[j, 1]] = median(&hi);
355 }
356 ci
357}
358
359pub(crate) fn check_level(level: f64) -> Result<()> {
360 if level > 0.0 && level < 1.0 {
361 Ok(())
362 } else {
363 Err(DmlError::InvalidArgument(
364 "confidence level must be in (0, 1)".into(),
365 ))
366 }
367}
368
369fn holm(p: ArrayView1<'_, f64>) -> Array1<f64> {
371 let m = p.len();
372 let mut order: Vec<usize> = (0..m).collect();
373 order.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
374 let mut out = Array1::zeros(m);
375 let mut running = 0.0_f64;
376 for (rank, &i) in order.iter().enumerate() {
377 running = running.max(((m - rank) as f64 * p[i]).min(1.0));
378 out[i] = running;
379 }
380 out
381}
382
383impl fmt::Display for DmlFit {
384 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385 writeln!(
386 f,
387 "{} | n_obs = {}, n_folds = {}, n_rep = {}",
388 self.model,
389 self.n_obs,
390 self.n_folds,
391 self.n_rep()
392 )?;
393 let ci = self.confint(0.95).map_err(|_| fmt::Error)?;
394 let (t, p) = (self.t_stat(), self.p_value());
395 let width = self
396 .treatment_names
397 .iter()
398 .map(String::len)
399 .max()
400 .unwrap_or(1)
401 .max(4);
402 write!(
403 f,
404 "{:<width$} {:>10} {:>10} {:>8} {:>8} {:>10} {:>10}",
405 "", "coef", "std err", "t", "P>|t|", "2.5 %", "97.5 %"
406 )?;
407 for j in 0..self.n_treat() {
408 write!(
409 f,
410 "\n{:<width$} {:>10.4} {:>10.4} {:>8.3} {:>8.4} {:>10.4} {:>10.4}",
411 self.treatment_names[j],
412 self.coef[j],
413 self.se[j],
414 t[j],
415 p[j],
416 ci[[j, 0]],
417 ci[[j, 1]]
418 )?;
419 }
420 Ok(())
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use ndarray::array;
428
429 #[test]
430 fn aggregation_matches_doubleml_definition() {
431 let coef = array![[1.0, 2.0, 4.0]];
432 let se = array![[0.5, 0.1, 0.2]];
433 let (c, s) = aggregate(&coef, &se);
434 assert_eq!(c[0], 2.0);
435 assert!((s[0] - (2.196 - 2.0) / 1.96).abs() < 1e-12);
437 }
438
439 #[test]
440 fn holm_matches_statsmodels() {
441 let adj = holm(array![0.01, 0.04, 0.03].view());
443 assert_eq!(adj, array![0.03, 0.06, 0.06]);
444 }
445}