1use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, Axis};
22
23use super::irm::normalize_propensity;
24use super::{dml_model_api, LinearScoreModel, NuisanceTarget, ScoreEstimate, ScoreInput};
25use crate::compat::{
26 bracket_guess, brent_minimize, brentq, kde_at_zero, np_mean, np_quantile, stratified_kfold,
27 stratified_train_test_split,
28};
29use crate::crossfit::{cross_fit_predict_proba, Fold, SampleSplitting};
30use crate::data::DmlData;
31use crate::error::{DmlError, Result};
32use crate::fit::{aggregate, DmlFit, NuisanceLoss};
33use crate::learner::{Classifier, Regressor};
34use crate::score::LinearScore;
35
36#[derive(Debug, Clone, Copy)]
38struct Settings {
39 treatment: f64,
40 quantile: f64,
41 clipping_threshold: f64,
42 normalize_ipw: bool,
43}
44
45impl Settings {
46 fn new() -> Self {
47 Self {
48 treatment: 1.0,
49 quantile: 0.5,
50 clipping_threshold: 1e-2,
51 normalize_ipw: true,
52 }
53 }
54
55 fn check(&self) -> Result<()> {
56 if self.treatment != 0.0 && self.treatment != 1.0 {
57 return Err(DmlError::InvalidArgument("treatment must be 0 or 1".into()));
58 }
59 if !(self.quantile > 0.0 && self.quantile < 1.0) {
60 return Err(DmlError::InvalidArgument(
61 "quantile must be in (0, 1)".into(),
62 ));
63 }
64 if !(self.clipping_threshold > 0.0 && self.clipping_threshold < 0.5) {
65 return Err(DmlError::InvalidArgument(
66 "clipping_threshold must be in (0, 0.5)".into(),
67 ));
68 }
69 Ok(())
70 }
71
72 fn adjust(&self, m: &Array1<f64>, ind: &Array1<f64>, flip: bool) -> Array1<f64> {
75 let clipped = self.clip(m);
76 let normalized = if self.normalize_ipw {
77 normalize_propensity(&clipped, ind)
78 } else {
79 clipped
80 };
81 if flip && self.treatment == 0.0 {
82 1.0 - normalized
83 } else {
84 normalized
85 }
86 }
87
88 fn clip(&self, m: &Array1<f64>) -> Array1<f64> {
89 let t = self.clipping_threshold;
90 m.mapv(|p| p.clamp(t, 1.0 - t))
91 }
92}
93
94fn check_data(data: &DmlData, instrument: bool) -> Result<()> {
95 if data.n_treat() != 1 || !data.is_binary_treatment(0) {
96 return Err(DmlError::InvalidArgument(
97 "quantile models need exactly one binary (0/1) treatment".into(),
98 ));
99 }
100 if data.clusters().is_some() {
101 return Err(DmlError::InvalidArgument(
102 "quantile models do not support clustered data".into(),
103 ));
104 }
105 match (instrument, data.z()) {
106 (true, None) => Err(DmlError::InvalidArgument(
107 "LPQ needs a binary instrument (DmlData::with_instrument)".into(),
108 )),
109 (true, Some(z)) if !z.iter().all(|&v| v == 0.0 || v == 1.0) => Err(
110 DmlError::InvalidArgument("the instrument must be binary (0/1)".into()),
111 ),
112 _ => Ok(()),
113 }
114}
115
116fn take(a: ArrayView1<'_, f64>, idx: &[usize]) -> Array1<f64> {
117 idx.iter().map(|&i| a[i]).collect()
118}
119
120fn min_max(y: ArrayView1<'_, f64>) -> (f64, f64) {
121 y.iter()
122 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
123 (lo.min(v), hi.max(v))
124 })
125}
126
127fn nested_split(
130 train: &[usize],
131 strata: &[f64],
132 n_folds: usize,
133) -> Result<(Vec<usize>, Vec<usize>, Vec<Fold>)> {
134 let labels: Vec<f64> = train.iter().map(|&i| strata[i]).collect();
135 let (p1, p2) = stratified_train_test_split(&labels, 42)?;
136 let train1: Vec<usize> = p1.iter().map(|&p| train[p]).collect();
137 let train2: Vec<usize> = p2.iter().map(|&p| train[p]).collect();
138 let labels1: Vec<f64> = train1.iter().map(|&i| strata[i]).collect();
139 let n1 = train1.len();
140 let folds = stratified_kfold(&labels1, n_folds)?
141 .into_iter()
142 .map(|test| {
143 let mut in_test = vec![false; n1];
144 for &i in &test {
145 in_test[i] = true;
146 }
147 Fold {
148 train: (0..n1).filter(|&i| !in_test[i]).collect(),
149 test,
150 }
151 })
152 .collect();
153 Ok((train1, train2, folds))
154}
155
156fn fit_proba(
158 learner: &dyn Classifier,
159 x: ArrayView2<'_, f64>,
160 rows: &[usize],
161 labels: &[f64],
162 x_pred: ArrayView2<'_, f64>,
163) -> Result<Array1<f64>> {
164 let mut m = learner.fresh();
165 m.fit(x.select(Axis(0), rows).view(), ArrayView1::from(labels))?;
166 m.predict_proba(x_pred)
167}
168
169fn ipw_estimate(score: &mut dyn FnMut(f64) -> f64, start: f64, bounds: (f64, f64)) -> f64 {
172 let (guess, _) = bracket_guess(score, start, bounds);
173 brent_minimize(&mut |theta| score(theta).abs(), guess.0, guess.1)
174}
175
176struct RepFit {
178 next_start: f64,
180 theta: f64,
181 psi: Array1<f64>,
182 jacobian: f64,
184 predictions: Vec<(&'static str, Array1<f64>)>,
185 targets: Vec<NuisanceTarget>,
186}
187
188fn solve_score(score: &mut dyn FnMut(f64) -> f64, start: f64, bounds: (f64, f64)) -> Result<f64> {
190 let (guess, different) = bracket_guess(score, start, bounds);
191 if !different {
192 return Err(DmlError::InvalidArgument(
193 "could not find a root of the quantile score (no sign change within the outcome range)"
194 .into(),
195 ));
196 }
197 brentq(score, guess.0, guess.1)
198}
199
200fn assemble(name: String, data: &DmlData, n_folds: usize, reps: Vec<RepFit>) -> DmlFit {
204 let n = data.n_obs();
205 let n_rep = reps.len();
206 let mut all_coef = Array2::zeros((1, n_rep));
207 let mut all_se = Array2::zeros((1, n_rep));
208 let mut psi = Array3::zeros((n, 1, n_rep));
209 let mut psi_a = Array3::zeros((n, 1, n_rep));
210 let mut psi_b = Array3::zeros((n, 1, n_rep));
211 let mut psi_deriv = Array2::zeros((1, n_rep));
212 let mut predictions: Vec<(String, Array3<f64>)> = Vec::new();
213 let mut losses: Vec<NuisanceLoss> = Vec::new();
214 for (r, rep) in reps.into_iter().enumerate() {
215 let jac = np_mean(&vec![rep.jacobian; n]);
217 let squares: Vec<f64> = rep.psi.iter().map(|v| v * v).collect();
218 let gamma = np_mean(&squares);
219 let sigma2 = 1.0 / (n as f64 * (jac * jac)) * gamma;
220 all_coef[[0, r]] = rep.theta;
221 all_se[[0, r]] = sigma2.sqrt();
222 psi_deriv[[0, r]] = jac;
223 psi.slice_mut(ndarray::s![.., 0, r]).assign(&rep.psi);
224 psi_a.slice_mut(ndarray::s![.., 0, r]).fill(jac);
225 psi_b
226 .slice_mut(ndarray::s![.., 0, r])
227 .assign(&(&rep.psi - jac * rep.theta));
228 for target in &rep.targets {
229 let Some((_, pred)) = rep.predictions.iter().find(|(nm, _)| *nm == target.name) else {
230 continue;
231 };
232 let slot = match losses.iter().position(|l| l.name == target.name) {
233 Some(i) => i,
234 None => {
235 losses.push(NuisanceLoss {
236 name: target.name.to_string(),
237 kind: target.kind,
238 loss: Array2::from_elem((1, n_rep), f64::NAN),
239 targets: Array3::from_elem((n, n_rep, 1), f64::NAN),
240 });
241 losses.len() - 1
242 }
243 };
244 losses[slot].loss[[0, r]] = target.loss(pred);
245 losses[slot]
246 .targets
247 .slice_mut(ndarray::s![.., r, 0])
248 .assign(&target.target);
249 }
250 for (name, pred) in rep.predictions {
251 let slot = match predictions.iter().position(|(nm, _)| nm == name) {
252 Some(i) => i,
253 None => {
254 predictions
255 .push((name.to_string(), Array3::from_elem((n, n_rep, 1), f64::NAN)));
256 predictions.len() - 1
257 }
258 };
259 predictions[slot]
260 .1
261 .slice_mut(ndarray::s![.., r, 0])
262 .assign(&pred);
263 }
264 }
265 let (coef, se) = aggregate(&all_coef, &all_se);
266 DmlFit {
267 model: name,
268 treatment_names: data.treatment_names().to_vec(),
269 n_obs: n,
270 n_folds,
271 coef,
272 se,
273 all_coef,
274 all_se,
275 psi,
276 psi_a,
277 psi_b,
278 psi_deriv,
279 predictions,
280 sensitivity: None,
281 cate: None,
282 losses,
283 cluster: None,
284 }
285}
286
287fn draw_splits(splitting: &SampleSplitting, strata: &[f64]) -> Result<Vec<Vec<Fold>>> {
289 let mut levels = strata.to_vec();
290 levels.sort_by(f64::total_cmp);
291 levels.dedup();
292 let codes: Vec<usize> = strata
293 .iter()
294 .map(|v| {
295 levels
296 .binary_search_by(|l| l.total_cmp(v))
297 .expect("present")
298 })
299 .collect();
300 splitting.draw_stratified(&codes)
301}
302
303macro_rules! quantile_api {
305 () => {
306 pub fn treatment(mut self, treatment: u8) -> Self {
308 self.settings.treatment = f64::from(treatment);
309 self
310 }
311
312 pub fn quantile(mut self, quantile: f64) -> Self {
314 self.settings.quantile = quantile;
315 self
316 }
317
318 pub fn clipping_threshold(mut self, t: f64) -> Self {
320 self.settings.clipping_threshold = t;
321 self
322 }
323
324 pub fn normalize_ipw(mut self, normalize: bool) -> Self {
326 self.settings.normalize_ipw = normalize;
327 self
328 }
329 };
330}
331
332macro_rules! splitting_api {
334 () => {
335 pub fn n_folds(mut self, n_folds: usize) -> Self {
337 self.splitting.n_folds = n_folds;
338 self
339 }
340
341 pub fn n_rep(mut self, n_rep: usize) -> Self {
343 self.splitting.n_rep = n_rep;
344 self
345 }
346
347 pub fn seed(mut self, seed: u64) -> Self {
349 self.splitting.seed = seed;
350 self
351 }
352 };
353}
354
355pub struct Pq {
365 ml_g: Box<dyn Classifier>,
366 ml_m: Box<dyn Classifier>,
367 settings: Settings,
368 splitting: SampleSplitting,
369}
370
371impl Pq {
372 pub fn new(ml_g: impl Classifier + 'static, ml_m: impl Classifier + 'static) -> Self {
373 Self::from_boxed(Box::new(ml_g), Box::new(ml_m))
374 }
375
376 pub fn from_boxed(ml_g: Box<dyn Classifier>, ml_m: Box<dyn Classifier>) -> Self {
377 Self {
378 ml_g,
379 ml_m,
380 settings: Settings::new(),
381 splitting: SampleSplitting::default(),
382 }
383 }
384
385 quantile_api!();
386 splitting_api!();
387
388 pub fn fit(&self, data: &DmlData) -> Result<DmlFit> {
389 check_data(data, false)?;
390 let splits = draw_splits(&self.splitting, &data.treatment(0).to_vec())?;
391 self.fit_with_folds(data, &splits)
392 }
393
394 pub fn fit_with_folds(&self, data: &DmlData, splits: &[Vec<Fold>]) -> Result<DmlFit> {
395 check_data(data, false)?;
396 self.settings.check()?;
397 let mut start = None;
400 let mut reps = Vec::with_capacity(splits.len());
401 for folds in splits {
402 let rep = self.fit_rep(data, folds, start)?;
403 start = Some(rep.next_start);
404 reps.push(rep);
405 }
406 let name = format!(
407 "PQ (treatment {}, quantile {})",
408 self.settings.treatment, self.settings.quantile
409 );
410 Ok(assemble(name, data, splits[0].len(), reps))
411 }
412
413 fn fit_rep(&self, data: &DmlData, folds: &[Fold], start: Option<f64>) -> Result<RepFit> {
414 let s = self.settings;
415 let (y, d, x) = (data.y(), data.treatment(0), data.x());
416 let n = y.len();
417 let bounds = min_max(y);
418 let y_treat: Vec<f64> = (0..n)
419 .filter(|&i| d[i] == s.treatment)
420 .map(|i| y[i])
421 .collect();
422 let start = start.unwrap_or_else(|| np_quantile(&y_treat, s.quantile));
423 let strata = d.to_vec();
424 let (mut g, mut g_target, mut m) = (
425 Array1::from_elem(n, f64::NAN),
426 Array1::from_elem(n, f64::NAN),
427 Array1::from_elem(n, f64::NAN),
428 );
429 let mut ipw_vec = Vec::with_capacity(folds.len());
430 for fold in folds {
431 let (train1, train2, prelim) = nested_split(&fold.train, &strata, folds.len())?;
432 let (d1, y1) = (take(d, &train1), take(y, &train1));
433 let x1 = x.select(Axis(0), &train1);
434 let m1 = cross_fit_predict_proba(self.ml_m.as_ref(), x1.view(), d1.view(), &prelim)?;
435 let m1 = s.adjust(&m1, &d1, true);
436 let mut ipw_score = |theta: f64| {
437 let terms: Vec<f64> = (0..d1.len())
438 .map(|i| {
439 f64::from(d1[i] == s.treatment) / m1[i] * f64::from(y1[i] <= theta)
440 - s.quantile
441 })
442 .collect();
443 np_mean(&terms)
444 };
445 let ipw = ipw_estimate(&mut ipw_score, start, bounds);
446 ipw_vec.push(ipw);
447
448 let x_test = x.select(Axis(0), &fold.test);
449 let rows: Vec<usize> = train2
450 .iter()
451 .copied()
452 .filter(|&i| d[i] == s.treatment)
453 .collect();
454 let labels: Vec<f64> = rows.iter().map(|&i| f64::from(y[i] <= ipw)).collect();
455 let g_test = fit_proba(self.ml_g.as_ref(), x, &rows, &labels, x_test.view())?;
456 let d_train: Vec<f64> = fold.train.iter().map(|&i| d[i]).collect();
457 let m_test = fit_proba(self.ml_m.as_ref(), x, &fold.train, &d_train, x_test.view())?;
458 for (k, &i) in fold.test.iter().enumerate() {
459 g[i] = g_test[k];
460 g_target[i] = f64::from(y[i] <= ipw);
461 m[i] = m_test[k];
462 }
463 }
464 let d_owned = d.to_owned();
465 let m_clipped = s.clip(&m);
466 let m_adj = s.adjust(&m, &d_owned, true);
467 let ind: Array1<f64> = d.mapv(|v| f64::from(v == s.treatment));
468 let terms = |theta: f64| -> Array1<f64> {
469 (0..n)
470 .map(|i| ind[i] * (f64::from(y[i] <= theta) - g[i]) / m_adj[i] + g[i] - s.quantile)
471 .collect()
472 };
473 let next_start = np_mean(&ipw_vec);
474 let theta = solve_score(
475 &mut |theta| np_mean(terms(theta).as_slice().expect("contiguous")),
476 next_start,
477 bounds,
478 )?;
479 let psi = terms(theta);
480 let u: Vec<f64> = y.iter().map(|&v| v - theta).collect();
481 let weights: Vec<f64> = (0..n).map(|i| ind[i] / m_adj[i]).collect();
482 let jacobian = kde_at_zero(&u, &weights);
483 Ok(RepFit {
484 next_start,
485 theta,
486 psi,
487 jacobian,
488 targets: vec![
489 NuisanceTarget::classification("ml_g", g_target).only_where(|i| ind[i] == 1.0),
490 NuisanceTarget::classification("ml_m", d_owned),
491 ],
492 predictions: vec![("ml_g", g), ("ml_m", m_clipped)],
493 })
494 }
495}
496
497pub struct Lpq {
507 ml_g: Box<dyn Classifier>,
508 ml_m: Box<dyn Classifier>,
509 settings: Settings,
510 splitting: SampleSplitting,
511}
512
513impl Lpq {
514 pub fn new(ml_g: impl Classifier + 'static, ml_m: impl Classifier + 'static) -> Self {
515 Self::from_boxed(Box::new(ml_g), Box::new(ml_m))
516 }
517
518 pub fn from_boxed(ml_g: Box<dyn Classifier>, ml_m: Box<dyn Classifier>) -> Self {
519 Self {
520 ml_g,
521 ml_m,
522 settings: Settings::new(),
523 splitting: SampleSplitting::default(),
524 }
525 }
526
527 quantile_api!();
528 splitting_api!();
529
530 fn strata(data: &DmlData) -> Vec<f64> {
531 let (d, z) = (data.treatment(0), data.z().expect("checked"));
532 d.iter().zip(z.iter()).map(|(&a, &b)| a + 2.0 * b).collect()
533 }
534
535 pub fn fit(&self, data: &DmlData) -> Result<DmlFit> {
536 check_data(data, true)?;
537 let splits = draw_splits(&self.splitting, &Self::strata(data))?;
538 self.fit_with_folds(data, &splits)
539 }
540
541 pub fn fit_with_folds(&self, data: &DmlData, splits: &[Vec<Fold>]) -> Result<DmlFit> {
542 check_data(data, true)?;
543 self.settings.check()?;
544 let mut start = None;
547 let mut reps = Vec::with_capacity(splits.len());
548 for folds in splits {
549 let rep = self.fit_rep(data, folds, start)?;
550 start = Some(rep.next_start);
551 reps.push(rep);
552 }
553 let name = format!(
554 "LPQ (treatment {}, quantile {})",
555 self.settings.treatment, self.settings.quantile
556 );
557 Ok(assemble(name, data, splits[0].len(), reps))
558 }
559
560 fn fit_rep(&self, data: &DmlData, folds: &[Fold], start: Option<f64>) -> Result<RepFit> {
561 let s = self.settings;
562 let (y, d, x, z) = (
563 data.y(),
564 data.treatment(0),
565 data.x(),
566 data.z().expect("checked"),
567 );
568 let n = y.len();
569 let sign = 2.0 * s.treatment - 1.0;
570 let bounds = min_max(y);
571 let y_treat: Vec<f64> = (0..n)
572 .filter(|&i| d[i] == s.treatment)
573 .map(|i| y[i])
574 .collect();
575 let start = start.unwrap_or_else(|| np_quantile(&y_treat, s.quantile));
576 let strata = Self::strata(data);
577 let nan = || Array1::from_elem(n, f64::NAN);
578 let (mut m_z, mut m_d_z0, mut m_d_z1, mut g0, mut g1) = (nan(), nan(), nan(), nan(), nan());
579 let (mut g0_target, mut g1_target) = (nan(), nan());
580 let mut ipw_vec = Vec::with_capacity(folds.len());
581 for fold in folds {
582 let (train1, train2, prelim) = nested_split(&fold.train, &strata, folds.len())?;
583 let (d1, y1, z1) = (take(d, &train1), take(y, &train1), take(z, &train1));
584 let x1 = x.select(Axis(0), &train1);
585 let mz1 = cross_fit_predict_proba(self.ml_m.as_ref(), x1.view(), z1.view(), &prelim)?;
586 let mz1 = s.adjust(&mz1, &z1, false);
587 let pos =
589 |zv: f64| -> Vec<usize> { (0..train1.len()).filter(|&p| z1[p] == zv).collect() };
590 let (pos0, pos1) = (pos(0.0), pos(1.0));
591 let md0 = fit_proba(
592 self.ml_m.as_ref(),
593 x1.view(),
594 &pos0,
595 &pos0.iter().map(|&p| d1[p]).collect::<Vec<_>>(),
596 x1.view(),
597 )?;
598 let md1 = fit_proba(
599 self.ml_m.as_ref(),
600 x1.view(),
601 &pos1,
602 &pos1.iter().map(|&p| d1[p]).collect::<Vec<_>>(),
603 x1.view(),
604 )?;
605 let comp_terms: Vec<f64> = (0..train1.len())
606 .map(|p| {
607 md1[p] - md0[p] + z1[p] / mz1[p] * (d1[p] - md1[p])
608 - (1.0 - z1[p]) / (1.0 - mz1[p]) * (d1[p] - md0[p])
609 })
610 .collect();
611 let comp_prelim = np_mean(&comp_terms);
612 let mut ipw_score = |theta: f64| {
613 let terms: Vec<f64> = (0..train1.len())
614 .map(|p| {
615 let w =
616 sign * (z1[p] / mz1[p] - (1.0 - z1[p]) / (1.0 - mz1[p])) / comp_prelim;
617 let u = f64::from(d1[p] == s.treatment) * f64::from(y1[p] <= theta);
618 w * u + -s.quantile
619 })
620 .collect();
621 np_mean(&terms)
622 };
623 let ipw = ipw_estimate(&mut ipw_score, start, bounds);
624 ipw_vec.push(ipw);
625
626 let x_test = x.select(Axis(0), &fold.test);
627 let du = |i: usize| f64::from(d[i] == s.treatment) * f64::from(y[i] <= ipw);
628 let rows2 = |zv: f64| -> Vec<usize> {
629 train2.iter().copied().filter(|&i| z[i] == zv).collect()
630 };
631 let (r0, r1) = (rows2(0.0), rows2(1.0));
632 let g0_test = fit_proba(
633 self.ml_g.as_ref(),
634 x,
635 &r0,
636 &r0.iter().map(|&i| du(i)).collect::<Vec<_>>(),
637 x_test.view(),
638 )?;
639 let g1_test = fit_proba(
640 self.ml_g.as_ref(),
641 x,
642 &r1,
643 &r1.iter().map(|&i| du(i)).collect::<Vec<_>>(),
644 x_test.view(),
645 )?;
646 let train = &fold.train;
648 let mz_test = fit_proba(
649 self.ml_m.as_ref(),
650 x,
651 train,
652 &train.iter().map(|&i| z[i]).collect::<Vec<_>>(),
653 x_test.view(),
654 )?;
655 let t0: Vec<usize> = train.iter().copied().filter(|&i| z[i] == 0.0).collect();
656 let t1: Vec<usize> = train.iter().copied().filter(|&i| z[i] == 1.0).collect();
657 let md0_test = fit_proba(
658 self.ml_m.as_ref(),
659 x,
660 &t0,
661 &t0.iter().map(|&i| d[i]).collect::<Vec<_>>(),
662 x_test.view(),
663 )?;
664 let md1_test = fit_proba(
665 self.ml_m.as_ref(),
666 x,
667 &t1,
668 &t1.iter().map(|&i| d[i]).collect::<Vec<_>>(),
669 x_test.view(),
670 )?;
671 for (k, &i) in fold.test.iter().enumerate() {
672 g0[i] = g0_test[k];
673 g1[i] = g1_test[k];
674 m_z[i] = mz_test[k];
675 m_d_z0[i] = md0_test[k];
676 m_d_z1[i] = md1_test[k];
677 if z[i] == 0.0 {
678 g0_target[i] = du(i);
679 } else {
680 g1_target[i] = du(i);
681 }
682 }
683 }
684 let z_owned = z.to_owned();
685 let m_z_clipped = s.clip(&m_z);
686 let m_z_adj = s.adjust(&m_z, &z_owned, false);
687 let comp_terms: Vec<f64> = (0..n)
688 .map(|i| {
689 m_d_z1[i] - m_d_z0[i] + z[i] / m_z_adj[i] * (d[i] - m_d_z1[i])
690 - (1.0 - z[i]) / (1.0 - m_z_adj[i]) * (d[i] - m_d_z0[i])
691 })
692 .collect();
693 let comp_prob = np_mean(&comp_terms);
694 let ind: Array1<f64> = d.mapv(|v| f64::from(v == s.treatment));
695 let terms = |theta: f64| -> Array1<f64> {
696 (0..n)
697 .map(|i| {
698 let yi = ind[i] * f64::from(y[i] <= theta);
699 let score1 = g1[i] - g0[i];
700 let score2 = (z[i] / m_z_adj[i]) * (yi - g1[i]);
701 let score3 = (1.0 - z[i]) / (1.0 - m_z_adj[i]) * (yi - g0[i]);
702 sign * (score1 + score2 - score3) / comp_prob - s.quantile
703 })
704 .collect()
705 };
706 let next_start = np_mean(&ipw_vec);
707 let theta = solve_score(
708 &mut |theta| np_mean(terms(theta).as_slice().expect("contiguous")),
709 next_start,
710 bounds,
711 )?;
712 let psi = terms(theta);
713 let u: Vec<f64> = y.iter().map(|&v| v - theta).collect();
714 let weights: Vec<f64> = (0..n)
715 .map(|i| {
716 sign * ((z[i] / m_z_adj[i]) - (1.0 - z[i]) / (1.0 - m_z_adj[i])) * ind[i]
717 / comp_prob
718 })
719 .collect();
720 let jacobian = kde_at_zero(&u, &weights);
721 let d_owned = d.to_owned();
722 Ok(RepFit {
723 next_start,
724 theta,
725 psi,
726 jacobian,
727 targets: vec![
728 NuisanceTarget::classification("ml_m_z", z_owned.clone()),
729 NuisanceTarget::classification("ml_m_d_z0", d_owned.clone())
730 .only_where(|i| z[i] == 0.0),
731 NuisanceTarget::classification("ml_m_d_z1", d_owned).only_where(|i| z[i] == 1.0),
732 NuisanceTarget::classification("ml_g_du_z0", g0_target).only_where(|i| z[i] == 0.0),
733 NuisanceTarget::classification("ml_g_du_z1", g1_target).only_where(|i| z[i] == 1.0),
734 ],
735 predictions: vec![
736 ("ml_m_z", m_z_clipped),
737 ("ml_m_d_z0", m_d_z0),
738 ("ml_m_d_z1", m_d_z1),
739 ("ml_g_du_z0", g0),
740 ("ml_g_du_z1", g1),
741 ],
742 })
743 }
744}
745
746pub struct Cvar {
754 ml_g: Box<dyn Regressor>,
755 ml_m: Box<dyn Classifier>,
756 settings: Settings,
757 splitting: SampleSplitting,
758}
759
760impl Cvar {
761 pub fn new(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
762 Self::from_boxed(Box::new(ml_g), Box::new(ml_m))
763 }
764
765 pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Box<dyn Classifier>) -> Self {
766 Self {
767 ml_g,
768 ml_m,
769 settings: Settings::new(),
770 splitting: SampleSplitting::default(),
771 }
772 }
773
774 quantile_api!();
775 dml_model_api!();
776}
777
778impl LinearScoreModel for Cvar {
779 fn name(&self) -> String {
780 format!(
781 "CVaR (treatment {}, quantile {})",
782 self.settings.treatment, self.settings.quantile
783 )
784 }
785
786 fn validate(&self, data: &DmlData) -> Result<()> {
787 check_data(data, false)?;
788 self.settings.check()
789 }
790
791 fn strata(&self, data: &DmlData) -> Option<Vec<usize>> {
792 Some(data.treatment(0).iter().map(|&v| v as usize).collect())
793 }
794
795 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
796 let s = self.settings;
797 let ScoreInput { y, d, x, .. } = *input;
798 let n = y.len();
799 let bounds = min_max(y);
800 let y_treat: Vec<f64> = (0..n)
801 .filter(|&i| d[i] == s.treatment)
802 .map(|i| y[i])
803 .collect();
804 let q_treat = np_quantile(&y_treat, s.quantile);
805 let upper: Vec<f64> = y_treat.iter().copied().filter(|&v| v >= q_treat).collect();
806 let start = np_mean(&upper);
807 let strata = d.to_vec();
808 let (mut g, mut g_target, mut m) = (
809 Array1::from_elem(n, f64::NAN),
810 Array1::from_elem(n, f64::NAN),
811 Array1::from_elem(n, f64::NAN),
812 );
813 let cvar_target = |yi: f64, pq: f64| pq.max((yi - s.quantile * pq) / (1.0 - s.quantile));
814 let mut ipw_vec = Vec::with_capacity(folds.len());
815 for fold in folds {
816 let (train1, train2, prelim) = nested_split(&fold.train, &strata, folds.len())?;
817 let (d1, y1) = (take(d, &train1), take(y, &train1));
818 let x1 = x.select(Axis(0), &train1);
819 let m1 = cross_fit_predict_proba(self.ml_m.as_ref(), x1.view(), d1.view(), &prelim)?;
820 let m1 = s.adjust(&m1, &d1, true);
821 let mut ipw_score = |theta: f64| {
822 let terms: Vec<f64> = (0..d1.len())
823 .map(|i| {
824 f64::from(d1[i] == s.treatment) / m1[i] * f64::from(y1[i] <= theta)
825 - s.quantile
826 })
827 .collect();
828 np_mean(&terms)
829 };
830 let ipw = ipw_estimate(&mut ipw_score, start, bounds);
831 ipw_vec.push(ipw);
832
833 let x_test = x.select(Axis(0), &fold.test);
834 let rows: Vec<usize> = train2
835 .iter()
836 .copied()
837 .filter(|&i| d[i] == s.treatment)
838 .collect();
839 let targets: Array1<f64> = rows.iter().map(|&i| cvar_target(y[i], ipw)).collect();
840 let mut model = self.ml_g.fresh();
841 model.fit(x.select(Axis(0), &rows).view(), targets.view())?;
842 let g_test = model.predict(x_test.view())?;
843 let d_train: Vec<f64> = fold.train.iter().map(|&i| d[i]).collect();
844 let m_test = fit_proba(self.ml_m.as_ref(), x, &fold.train, &d_train, x_test.view())?;
845 for (k, &i) in fold.test.iter().enumerate() {
846 g[i] = g_test[k];
847 g_target[i] = cvar_target(y[i], ipw);
848 m[i] = m_test[k];
849 }
850 }
851 let d_owned = d.to_owned();
852 let m_clipped = s.clip(&m);
853 let m_adj = s.adjust(&m, &d_owned, true);
854 let pq = np_mean(&ipw_vec);
856 let ind: Array1<f64> = d.mapv(|v| f64::from(v == s.treatment));
857 let psi_b: Array1<f64> = (0..n)
858 .map(|i| ind[i] * (cvar_target(y[i], pq) - g[i]) / m_adj[i] + g[i])
859 .collect();
860 let psi_a = Array1::from_elem(n, -1.0);
861 Ok(ScoreEstimate::new(
862 LinearScore::new(psi_a, psi_b)?,
863 vec![("ml_g", g), ("ml_m", m_clipped)],
864 )
865 .with_targets(vec![
866 NuisanceTarget::regression("ml_g", g_target).only_where(|i| ind[i] == 1.0),
867 NuisanceTarget::classification("ml_m", d_owned),
868 ]))
869 }
870}
871
872enum QteKind {
879 Pq(Box<dyn Classifier>),
880 Lpq(Box<dyn Classifier>),
881 Cvar(Box<dyn Regressor>),
882}
883
884pub struct Qte {
889 kind: QteKind,
890 ml_m: Box<dyn Classifier>,
891 quantiles: Vec<f64>,
892 clipping_threshold: f64,
893 normalize_ipw: bool,
894 splitting: SampleSplitting,
895}
896
897impl Qte {
898 fn with_kind(kind: QteKind, ml_m: Box<dyn Classifier>, quantiles: Vec<f64>) -> Self {
899 Self {
900 kind,
901 ml_m,
902 quantiles,
903 clipping_threshold: 1e-2,
904 normalize_ipw: true,
905 splitting: SampleSplitting::default(),
906 }
907 }
908
909 pub fn pq(
911 ml_g: impl Classifier + 'static,
912 ml_m: impl Classifier + 'static,
913 quantiles: Vec<f64>,
914 ) -> Self {
915 Self::with_kind(QteKind::Pq(Box::new(ml_g)), Box::new(ml_m), quantiles)
916 }
917
918 pub fn lpq(
920 ml_g: impl Classifier + 'static,
921 ml_m: impl Classifier + 'static,
922 quantiles: Vec<f64>,
923 ) -> Self {
924 Self::with_kind(QteKind::Lpq(Box::new(ml_g)), Box::new(ml_m), quantiles)
925 }
926
927 pub fn cvar(
929 ml_g: impl Regressor + 'static,
930 ml_m: impl Classifier + 'static,
931 quantiles: Vec<f64>,
932 ) -> Self {
933 Self::with_kind(QteKind::Cvar(Box::new(ml_g)), Box::new(ml_m), quantiles)
934 }
935
936 pub fn from_boxed(
939 score: &str,
940 ml_g_classifier: Option<Box<dyn Classifier>>,
941 ml_g_regressor: Option<Box<dyn Regressor>>,
942 ml_m: Box<dyn Classifier>,
943 quantiles: Vec<f64>,
944 ) -> Result<Self> {
945 let missing =
946 || DmlError::InvalidArgument(format!("score {score:?} needs a different ml_g"));
947 let kind = match score {
948 "PQ" => QteKind::Pq(ml_g_classifier.ok_or_else(missing)?),
949 "LPQ" => QteKind::Lpq(ml_g_classifier.ok_or_else(missing)?),
950 "CVaR" => QteKind::Cvar(ml_g_regressor.ok_or_else(missing)?),
951 other => {
952 return Err(DmlError::InvalidArgument(format!(
953 "unknown QTE score {other:?} (use 'PQ', 'LPQ' or 'CVaR')"
954 )))
955 }
956 };
957 Ok(Self::with_kind(kind, ml_m, quantiles))
958 }
959
960 pub fn clipping_threshold(mut self, t: f64) -> Self {
962 self.clipping_threshold = t;
963 self
964 }
965
966 pub fn normalize_ipw(mut self, normalize: bool) -> Self {
968 self.normalize_ipw = normalize;
969 self
970 }
971
972 splitting_api!();
973
974 pub fn fit(&self, data: &DmlData) -> Result<QteFit> {
976 check_data(data, matches!(self.kind, QteKind::Lpq(_)))?;
977 let splits = draw_splits(&self.splitting, &data.treatment(0).to_vec())?;
978 self.fit_with_folds(data, &splits)
979 }
980
981 pub fn fit_with_folds(&self, data: &DmlData, splits: &[Vec<Fold>]) -> Result<QteFit> {
983 if self.quantiles.is_empty() {
984 return Err(DmlError::InvalidArgument(
985 "at least one quantile is required".into(),
986 ));
987 }
988 let mut fits = Vec::with_capacity(2 * self.quantiles.len());
989 let mut names = Vec::with_capacity(2 * self.quantiles.len());
990 for &q in &self.quantiles {
991 for t in [0u8, 1u8] {
992 let fit = match &self.kind {
993 QteKind::Pq(g) => Pq::from_boxed(g.fresh(), self.ml_m.fresh())
994 .treatment(t)
995 .quantile(q)
996 .clipping_threshold(self.clipping_threshold)
997 .normalize_ipw(self.normalize_ipw)
998 .fit_with_folds(data, splits)?,
999 QteKind::Lpq(g) => Lpq::from_boxed(g.fresh(), self.ml_m.fresh())
1000 .treatment(t)
1001 .quantile(q)
1002 .clipping_threshold(self.clipping_threshold)
1003 .normalize_ipw(self.normalize_ipw)
1004 .fit_with_folds(data, splits)?,
1005 QteKind::Cvar(g) => Cvar::from_boxed(g.fresh(), self.ml_m.fresh())
1006 .treatment(t)
1007 .quantile(q)
1008 .clipping_threshold(self.clipping_threshold)
1009 .normalize_ipw(self.normalize_ipw)
1010 .fit_with_folds(data, splits)?,
1011 };
1012 fits.push(fit);
1013 names.push(format!("{q} (d={t})"));
1014 }
1015 }
1016 let mut potential = DmlFit::concat(&fits)?;
1017 potential.treatment_names = names;
1018 potential.model = match self.kind {
1019 QteKind::Pq(_) => "potential quantiles".into(),
1020 QteKind::Lpq(_) => "local potential quantiles".into(),
1021 QteKind::Cvar(_) => "potential CVaRs".into(),
1022 };
1023 let k = self.quantiles.len();
1024 let mut weights = Array2::zeros((k, 2 * k));
1025 for i in 0..k {
1026 weights[[i, 2 * i]] = -1.0;
1027 weights[[i, 2 * i + 1]] = 1.0;
1028 }
1029 let effect_names = self.quantiles.iter().map(|q| q.to_string()).collect();
1030 let effects = potential.linear_combination("QTE".into(), effect_names, &weights);
1031 Ok(QteFit {
1032 quantiles: self.quantiles.clone(),
1033 potential,
1034 effects,
1035 })
1036 }
1037}
1038
1039#[derive(Debug, Clone)]
1041pub struct QteFit {
1042 quantiles: Vec<f64>,
1043 potential: DmlFit,
1044 effects: DmlFit,
1045}
1046
1047impl QteFit {
1048 pub fn quantiles(&self) -> &[f64] {
1049 &self.quantiles
1050 }
1051
1052 pub fn effects(&self) -> &DmlFit {
1054 &self.effects
1055 }
1056
1057 pub fn potential(&self) -> &DmlFit {
1060 &self.potential
1061 }
1062}