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::score::LinearScore;
36
37/// Settings shared by the quantile models.
38#[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    /// Clip, optionally normalise with the indicator `ind`, and for treatment
74    /// 0 flip to `P(D = 0 | X)` (DoubleML's propensity adjustment).
75    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
128/// Split a training fold into halves stratified by `strata`, as DoubleML's
129/// `train_test_split(train, test_size=0.5, random_state=42, stratify=…)`.
130/// Returns data indices, in scikit-learn's (shuffled) order.
131pub(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
140/// One fold's nested split: the two halves of the training fold (data
141/// indices) and the preliminary folds on the first half (positions within it).
142fn 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
166/// Fit a fresh copy of `learner` on `rows` with `labels`; predict `x_pred`.
167fn 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
179/// DoubleML's preliminary IPW estimate: minimise `|mean score|` with Brent's
180/// method from a bracket around `start`.
181fn 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
186/// Solve the non-linear score: bracket around `start`, then `brentq`.
187fn 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
198/// Default sample splits, stratified by `strata` codes.
199fn 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
214/// Builder methods shared by the quantile models (settings and splitting).
215macro_rules! quantile_api {
216    () => {
217        /// Treatment level `d` of the potential outcome (0 or 1, default 1).
218        pub fn treatment(mut self, treatment: u8) -> Self {
219            self.settings.treatment = f64::from(treatment);
220            self
221        }
222
223        /// Quantile level `τ` in (0, 1) (default 0.5).
224        pub fn quantile(mut self, quantile: f64) -> Self {
225            self.settings.quantile = quantile;
226            self
227        }
228
229        /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
230        pub fn clipping_threshold(mut self, t: f64) -> Self {
231            self.settings.clipping_threshold = t;
232            self
233        }
234
235        /// Normalise the inverse-probability weights (default `true`, as DoubleML).
236        pub fn normalize_ipw(mut self, normalize: bool) -> Self {
237            self.settings.normalize_ipw = normalize;
238            self
239        }
240    };
241}
242
243/// Builder methods for the sample splitting of the non-linear models.
244macro_rules! splitting_api {
245    () => {
246        /// Number of cross-fitting folds (default 5).
247        pub fn n_folds(mut self, n_folds: usize) -> Self {
248            self.splitting.n_folds = n_folds;
249            self
250        }
251
252        /// Number of independent sample splits (default 1).
253        pub fn n_rep(mut self, n_rep: usize) -> Self {
254            self.splitting.n_rep = n_rep;
255            self
256        }
257
258        /// Seed for the sample splits (default 0).
259        pub fn seed(mut self, seed: u64) -> Self {
260            self.splitting.seed = seed;
261            self
262        }
263    };
264}
265
266// ---------------------------------------------------------------------------
267// Potential quantile (PQ)
268// ---------------------------------------------------------------------------
269
270/// Potential quantile `θ` with `P(Y(d) ≤ θ) = τ`, as DoubleML's `DoubleMLPQ`.
271///
272/// `ml_g` is a classifier for `P(Y ≤ θ̃ | D = d, X)` at the preliminary
273/// estimate `θ̃`, `ml_m` a classifier for the propensity `P(D = 1 | X)`.
274/// Default sample splits are stratified by the treatment.
275pub 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        // DoubleML carries the start value across repetitions: each repetition's
309        // preliminary search starts at the previous one's mean IPW estimate.
310        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        // DoubleML: J is the mean of the (constant) score derivative.
394        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
409// ---------------------------------------------------------------------------
410// Local potential quantile (LPQ)
411// ---------------------------------------------------------------------------
412
413/// Local potential quantile of compliers, with a binary instrument, as
414/// DoubleML's `DoubleMLLPQ`. `ml_g` is a classifier for
415/// `P(1{D = d}·1{Y ≤ θ̃} = 1 | Z = z, X)`, `ml_m` a classifier used for the
416/// instrument propensity `P(Z = 1 | X)` and for `P(D = 1 | Z = z, X)`.
417/// Default sample splits are stratified by treatment and instrument.
418pub 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        // DoubleML carries the start value across repetitions: each repetition's
457        // preliminary search starts at the previous one's mean IPW estimate.
458        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            // P(D = 1 | Z = z, X) fitted and predicted on the first half.
500            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            // Final propensities on the whole training fold.
559            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        // DoubleML: J is the mean of the (constant) score derivative.
633        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
659// ---------------------------------------------------------------------------
660// Conditional value at risk (CVaR)
661// ---------------------------------------------------------------------------
662
663/// Conditional value at risk of the potential outcome,
664/// `E[Y(d) | Y(d) ≥ q_τ(Y(d))]`, as DoubleML's `DoubleMLCVAR`. `ml_g` is a
665/// regressor, `ml_m` a classifier for `P(D = 1 | X)`. The score is linear.
666pub 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        // The potential quantile is approximated by the mean of the IPW estimates.
768        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
785// ---------------------------------------------------------------------------
786// Quantile treatment effects (QTE)
787// ---------------------------------------------------------------------------
788
789/// Outcome learner of a [`Qte`]: classifiers for quantiles, a regressor for
790/// CVaRs.
791enum QteKind {
792    Pq(Box<dyn Classifier>),
793    Lpq(Box<dyn Classifier>),
794    Cvar(Box<dyn Regressor>),
795}
796
797/// Treatment effects on quantiles (or CVaRs), as DoubleML's `DoubleMLQTE`: for
798/// every `τ`, the potential quantiles of treatment 1 and 0 are fitted on the
799/// same sample splits and their difference is the effect, with joint
800/// inference across all quantiles.
801pub 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    /// Effects on quantiles (potential quantiles, [`Pq`]).
823    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    /// Effects on compliers' quantiles (local potential quantiles, [`Lpq`]).
832    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    /// Effects on the conditional value at risk ([`Cvar`]).
841    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    /// Boxed learners, e.g. from Python: `score` is `"PQ"`, `"LPQ"` or `"CVaR"`;
850    /// for `"CVaR"` pass `ml_g_regressor`, otherwise `ml_g_classifier`.
851    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    /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
874    pub fn clipping_threshold(mut self, t: f64) -> Self {
875        self.clipping_threshold = t;
876        self
877    }
878
879    /// Normalise the inverse-probability weights (default `true`).
880    pub fn normalize_ipw(mut self, normalize: bool) -> Self {
881        self.normalize_ipw = normalize;
882        self
883    }
884
885    splitting_api!();
886
887    /// Draw sample splits (stratified by the treatment) and fit.
888    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    /// Fit on given sample splits (one `Vec<Fold>` per repetition).
895    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/// Fitted [`Qte`].
953#[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    /// The effect at every quantile (named by the quantile), with joint inference.
966    pub fn effects(&self) -> &DmlFit {
967        &self.effects
968    }
969
970    /// The potential quantiles (or CVaRs) of treatment 0 and 1 at every
971    /// quantile, in the order `τ₁ (d=0), τ₁ (d=1), τ₂ (d=0), …`.
972    pub fn potential(&self) -> &DmlFit {
973        &self.potential
974    }
975}