Skip to main content

dml_core/model/
quantile.rs

1//! Quantile treatment effects of a binary treatment, as DoubleML's
2//! `DoubleMLPQ`, `DoubleMLLPQ`, `DoubleMLCVAR` and `DoubleMLQTE`
3//! (Kallus, Mao and Uehara, 2024):
4//!
5//! - [`Pq`]: the potential quantile `θ` with `P(Y(d) ≤ θ) = τ`;
6//! - [`Lpq`]: the local (complier) potential quantile, with a binary instrument;
7//! - [`Cvar`]: the conditional value at risk `E[Y(d) | Y(d) ≥ q_τ]`;
8//! - [`Qte`]: treatment effects on quantiles (or CVaRs), `θ(1) − θ(0)` for a
9//!   grid of `τ`, with joint inference.
10//!
11//! The nuisances are localised at a preliminary IPW estimate of the quantile,
12//! computed by nested cross-fitting within each training fold: as in DoubleML,
13//! the training fold is split in halves (stratified, scikit-learn's
14//! `train_test_split` with `random_state=42`); the preliminary estimate comes
15//! from the first half and the outcome model is fitted on the second. The
16//! potential quantile solves a non-linear score with Brent's method; its
17//! standard error uses a kernel density estimate. The numerical routines are
18//! exact ports of NumPy, scikit-learn, SciPy and statsmodels (see `compat`),
19//! so results match DoubleML on the same sample splits.
20
21use 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/// Settings shared by the quantile models.
39#[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    /// Clip, optionally normalise with the indicator `ind`, and for treatment
77    /// 0 flip to `P(D = 0 | X)` (DoubleML's propensity adjustment).
78    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    /// Calibrate (optionally, against `ind`) and clip.
99    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
148/// Split a training fold into halves stratified by `strata`, as DoubleML's
149/// `train_test_split(train, test_size=0.5, random_state=42, stratify=…)`.
150/// Returns data indices, in scikit-learn's (shuffled) order.
151pub(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
160/// One fold's nested split: the two halves of the training fold (data
161/// indices) and the preliminary folds on the first half (positions within it).
162fn 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
186/// Fit a fresh copy of `learner` on `rows` with `labels`; predict `x_pred`.
187fn 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
199/// DoubleML's preliminary IPW estimate: minimise `|mean score|` with Brent's
200/// method from a bracket around `start`.
201fn 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
206/// Solve the non-linear score: bracket around `start`, then `brentq`.
207fn 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
218/// Default sample splits, stratified by `strata` codes.
219fn 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
234/// Builder methods shared by the quantile models (settings and splitting).
235macro_rules! quantile_api {
236    () => {
237        /// Treatment level `d` of the potential outcome (0 or 1, default 1).
238        pub fn treatment(mut self, treatment: u8) -> Self {
239            self.settings.treatment = f64::from(treatment);
240            self
241        }
242
243        /// Quantile level `τ` in (0, 1) (default 0.5).
244        pub fn quantile(mut self, quantile: f64) -> Self {
245            self.settings.quantile = quantile;
246            self
247        }
248
249        /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
250        pub fn clipping_threshold(mut self, t: f64) -> Self {
251            self.settings.clipping_threshold = t;
252            self
253        }
254
255        /// Normalise the inverse-probability weights (default `true`, as DoubleML).
256        pub fn normalize_ipw(mut self, normalize: bool) -> Self {
257            self.settings.normalize_ipw = normalize;
258            self
259        }
260
261        /// Calibrate the propensity scores before clipping (default: none), as
262        /// DoubleML's `ps_processor_config`.
263        pub fn calibration(mut self, calibration: Calibration) -> Self {
264            self.settings.calibration = calibration;
265            self
266        }
267    };
268}
269
270/// Builder methods for the sample splitting of the non-linear models.
271macro_rules! splitting_api {
272    () => {
273        /// Number of cross-fitting folds (default 5).
274        pub fn n_folds(mut self, n_folds: usize) -> Self {
275            self.splitting.n_folds = n_folds;
276            self
277        }
278
279        /// Number of independent sample splits (default 1).
280        pub fn n_rep(mut self, n_rep: usize) -> Self {
281            self.splitting.n_rep = n_rep;
282            self
283        }
284
285        /// Seed for the sample splits (default 0).
286        pub fn seed(mut self, seed: u64) -> Self {
287            self.splitting.seed = seed;
288            self
289        }
290    };
291}
292
293// ---------------------------------------------------------------------------
294// Potential quantile (PQ)
295// ---------------------------------------------------------------------------
296
297/// Potential quantile `θ` with `P(Y(d) ≤ θ) = τ`, as DoubleML's `DoubleMLPQ`.
298///
299/// `ml_g` is a classifier for `P(Y ≤ θ̃ | D = d, X)` at the preliminary
300/// estimate `θ̃`, `ml_m` a classifier for the propensity `P(D = 1 | X)`.
301/// Default sample splits are stratified by the treatment.
302pub 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        // DoubleML carries the start value across repetitions: each repetition's
336        // preliminary search starts at the previous one's mean IPW estimate.
337        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        // DoubleML: J is the mean of the (constant) score derivative.
421        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
436// ---------------------------------------------------------------------------
437// Local potential quantile (LPQ)
438// ---------------------------------------------------------------------------
439
440/// Local potential quantile of compliers, with a binary instrument, as
441/// DoubleML's `DoubleMLLPQ`. `ml_g` is a classifier for
442/// `P(1{D = d}·1{Y ≤ θ̃} = 1 | Z = z, X)`, `ml_m` a classifier used for the
443/// instrument propensity `P(Z = 1 | X)` and for `P(D = 1 | Z = z, X)`.
444/// Default sample splits are stratified by treatment and instrument.
445pub 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        // DoubleML carries the start value across repetitions: each repetition's
484        // preliminary search starts at the previous one's mean IPW estimate.
485        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            // P(D = 1 | Z = z, X) fitted and predicted on the first half.
527            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            // Final propensities on the whole training fold.
586            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        // DoubleML: J is the mean of the (constant) score derivative.
660        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
686// ---------------------------------------------------------------------------
687// Conditional value at risk (CVaR)
688// ---------------------------------------------------------------------------
689
690/// Conditional value at risk of the potential outcome,
691/// `E[Y(d) | Y(d) ≥ q_τ(Y(d))]`, as DoubleML's `DoubleMLCVAR`. `ml_g` is a
692/// regressor, `ml_m` a classifier for `P(D = 1 | X)`. The score is linear.
693pub 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        // The potential quantile is approximated by the mean of the IPW estimates.
795        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
812// ---------------------------------------------------------------------------
813// Quantile treatment effects (QTE)
814// ---------------------------------------------------------------------------
815
816/// Outcome learner of a [`Qte`]: classifiers for quantiles, a regressor for
817/// CVaRs.
818enum QteKind {
819    Pq(Box<dyn Classifier>),
820    Lpq(Box<dyn Classifier>),
821    Cvar(Box<dyn Regressor>),
822}
823
824/// Treatment effects on quantiles (or CVaRs), as DoubleML's `DoubleMLQTE`: for
825/// every `τ`, the potential quantiles of treatment 1 and 0 are fitted on the
826/// same sample splits and their difference is the effect, with joint
827/// inference across all quantiles.
828pub 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    /// Effects on quantiles (potential quantiles, [`Pq`]).
852    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    /// Effects on compliers' quantiles (local potential quantiles, [`Lpq`]).
861    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    /// Effects on the conditional value at risk ([`Cvar`]).
870    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    /// Boxed learners, e.g. from Python: `score` is `"PQ"`, `"LPQ"` or `"CVaR"`;
879    /// for `"CVaR"` pass `ml_g_regressor`, otherwise `ml_g_classifier`.
880    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    /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
903    pub fn clipping_threshold(mut self, t: f64) -> Self {
904        self.clipping_threshold = t;
905        self
906    }
907    /// Calibrate the propensity scores before clipping (default: none), as
908    /// DoubleML's `ps_processor_config`.
909    pub fn calibration(mut self, calibration: Calibration) -> Self {
910        self.calibration = calibration;
911        self
912    }
913
914    /// Normalise the inverse-probability weights (default `true`).
915    pub fn normalize_ipw(mut self, normalize: bool) -> Self {
916        self.normalize_ipw = normalize;
917        self
918    }
919
920    splitting_api!();
921
922    /// Draw sample splits (stratified by the treatment) and fit.
923    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    /// Fit on given sample splits (one `Vec<Fold>` per repetition).
930    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/// Fitted [`Qte`].
991#[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    /// The effect at every quantile (named by the quantile), with joint inference.
1004    pub fn effects(&self) -> &DmlFit {
1005        &self.effects
1006    }
1007
1008    /// The potential quantiles (or CVaRs) of treatment 0 and 1 at every
1009    /// quantile, in the order `τ₁ (d=0), τ₁ (d=1), τ₂ (d=0), …`.
1010    pub fn potential(&self) -> &DmlFit {
1011        &self.potential
1012    }
1013}