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::{
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/// Settings shared by the quantile models.
42#[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    /// Clip, optionally normalise with the indicator `ind`, and for treatment
80    /// 0 flip to `P(D = 0 | X)` (DoubleML's propensity adjustment).
81    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    /// Calibrate (optionally, against `ind`) and clip.
102    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
151/// Split a training fold into halves stratified by `strata`, as DoubleML's
152/// `train_test_split(train, test_size=0.5, random_state=42, stratify=…)`.
153/// Returns data indices, in scikit-learn's (shuffled) order.
154pub(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
163/// One fold's nested split: the two halves of the training fold (data
164/// indices) and the preliminary folds on the first half (positions within it).
165fn 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
189/// Fit a fresh copy of `learner` on `rows` with `labels`; predict `x_pred`.
190fn 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
202/// DoubleML's preliminary IPW estimate: minimise `|mean score|` with Brent's
203/// method from a bracket around `start`.
204fn 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
209/// Solve the non-linear score: bracket around `start`, then `brentq`.
210fn 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
221/// Default sample splits, stratified by `strata` codes.
222fn 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
237/// Builder methods shared by the quantile models (settings and splitting).
238macro_rules! quantile_api {
239    () => {
240        /// Treatment level `d` of the potential outcome (0 or 1, default 1).
241        pub fn treatment(mut self, treatment: u8) -> Self {
242            self.settings.treatment = f64::from(treatment);
243            self
244        }
245
246        /// Quantile level `τ` in (0, 1) (default 0.5).
247        pub fn quantile(mut self, quantile: f64) -> Self {
248            self.settings.quantile = quantile;
249            self
250        }
251
252        /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
253        pub fn clipping_threshold(mut self, t: f64) -> Self {
254            self.settings.clipping_threshold = t;
255            self
256        }
257
258        /// Normalise the inverse-probability weights (default `true`, as DoubleML).
259        pub fn normalize_ipw(mut self, normalize: bool) -> Self {
260            self.settings.normalize_ipw = normalize;
261            self
262        }
263
264        /// Calibrate the propensity scores before clipping (default: none), as
265        /// DoubleML's `ps_processor_config`.
266        pub fn calibration(mut self, calibration: Calibration) -> Self {
267            self.settings.calibration = calibration;
268            self
269        }
270    };
271}
272
273/// External predictions of the single treatment in repetition `rep`.
274fn 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
282/// The learners of [`Lpq`], in the order of its external predictions.
283const 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
291/// Builder methods for the sample splitting of the non-linear models.
292macro_rules! splitting_api {
293    () => {
294        /// Number of cross-fitting folds (default 5).
295        pub fn n_folds(mut self, n_folds: usize) -> Self {
296            self.splitting.n_folds = n_folds;
297            self
298        }
299
300        /// Number of independent sample splits (default 1).
301        pub fn n_rep(mut self, n_rep: usize) -> Self {
302            self.splitting.n_rep = n_rep;
303            self
304        }
305
306        /// Seed for the sample splits (default 0).
307        pub fn seed(mut self, seed: u64) -> Self {
308            self.splitting.seed = seed;
309            self
310        }
311    };
312}
313
314// ---------------------------------------------------------------------------
315// Potential quantile (PQ)
316// ---------------------------------------------------------------------------
317
318/// Potential quantile `θ` with `P(Y(d) ≤ θ) = τ`, as DoubleML's `DoubleMLPQ`.
319///
320/// `ml_g` is a classifier for `P(Y ≤ θ̃ | D = d, X)` at the preliminary
321/// estimate `θ̃`, `ml_m` a classifier for the propensity `P(D = 1 | X)`.
322/// Default sample splits are stratified by the treatment.
323pub 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        // DoubleML carries the start value across repetitions: each repetition's
357        // preliminary search starts at the previous one's mean IPW estimate.
358        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        // With both nuisances supplied there is no preliminary estimate (DoubleML).
398        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        // DoubleML: J is the mean of the (constant) score derivative.
469        let jacobian = np_mean(&vec![kde_at_zero(&u, &weights); n]);
470        Ok(RepFit {
471            next_start,
472            theta,
473            psi,
474            jacobian,
475            // Externally predicted learners have no out-of-sample loss.
476            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
490// ---------------------------------------------------------------------------
491// Local potential quantile (LPQ)
492// ---------------------------------------------------------------------------
493
494/// Local potential quantile of compliers, with a binary instrument, as
495/// DoubleML's `DoubleMLLPQ`. `ml_g` is a classifier for
496/// `P(1{D = d}·1{Y ≤ θ̃} = 1 | Z = z, X)`, `ml_m` a classifier used for the
497/// instrument propensity `P(Z = 1 | X)` and for `P(D = 1 | Z = z, X)`.
498/// Default sample splits are stratified by treatment and instrument.
499pub 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        // DoubleML carries the start value across repetitions: each repetition's
538        // preliminary search starts at the previous one's mean IPW estimate.
539        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        // DoubleML: either all five nuisances are supplied or none.
580        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            // P(D = 1 | Z = z, X) fitted and predicted on the first half.
601            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            // Final propensities on the whole training fold.
660            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        // DoubleML: J is the mean of the (constant) score derivative.
743        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            // Externally predicted learners have no out-of-sample loss.
751            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
777// ---------------------------------------------------------------------------
778// Conditional value at risk (CVaR)
779// ---------------------------------------------------------------------------
780
781/// Conditional value at risk of the potential outcome,
782/// `E[Y(d) | Y(d) ≥ q_τ(Y(d))]`, as DoubleML's `DoubleMLCVAR`. `ml_g` is a
783/// regressor, `ml_m` a classifier for `P(D = 1 | X)`. The score is linear.
784pub 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        // The potential quantile is approximated by the mean of the IPW estimates.
886        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
903// ---------------------------------------------------------------------------
904// Quantile treatment effects (QTE)
905// ---------------------------------------------------------------------------
906
907/// Outcome learner of a [`Qte`]: classifiers for quantiles, a regressor for
908/// CVaRs.
909enum QteKind {
910    Pq(Box<dyn Classifier>),
911    Lpq(Box<dyn Classifier>),
912    Cvar(Box<dyn Regressor>),
913}
914
915/// Treatment effects on quantiles (or CVaRs), as DoubleML's `DoubleMLQTE`: for
916/// every `τ`, the potential quantiles of treatment 1 and 0 are fitted on the
917/// same sample splits and their difference is the effect, with joint
918/// inference across all quantiles.
919pub 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    /// Effects on quantiles (potential quantiles, [`Pq`]).
943    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    /// Effects on compliers' quantiles (local potential quantiles, [`Lpq`]).
952    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    /// Effects on the conditional value at risk ([`Cvar`]).
961    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    /// Boxed learners, e.g. from Python: `score` is `"PQ"`, `"LPQ"` or `"CVaR"`;
970    /// for `"CVaR"` pass `ml_g_regressor`, otherwise `ml_g_classifier`.
971    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    /// Propensity scores are clipped to `[t, 1 − t]` (default `t = 0.01`).
994    pub fn clipping_threshold(mut self, t: f64) -> Self {
995        self.clipping_threshold = t;
996        self
997    }
998    /// Calibrate the propensity scores before clipping (default: none), as
999    /// DoubleML's `ps_processor_config`.
1000    pub fn calibration(mut self, calibration: Calibration) -> Self {
1001        self.calibration = calibration;
1002        self
1003    }
1004
1005    /// Normalise the inverse-probability weights (default `true`).
1006    pub fn normalize_ipw(mut self, normalize: bool) -> Self {
1007        self.normalize_ipw = normalize;
1008        self
1009    }
1010
1011    splitting_api!();
1012
1013    /// Draw sample splits (stratified by the treatment) and fit.
1014    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    /// Fit on given sample splits (one `Vec<Fold>` per repetition).
1021    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/// Fitted [`Qte`].
1082#[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    /// The effect at every quantile (named by the quantile), with joint inference.
1095    pub fn effects(&self) -> &DmlFit {
1096        &self.effects
1097    }
1098
1099    /// The potential quantiles (or CVaRs) of treatment 0 and 1 at every
1100    /// quantile, in the order `τ₁ (d=0), τ₁ (d=1), τ₂ (d=0), …`.
1101    pub fn potential(&self) -> &DmlFit {
1102        &self.potential
1103    }
1104}