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