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