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