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