Skip to main content

dml_core/model/
rdflex.rs

1//! Regression discontinuity designs with flexible covariate adjustment, as
2//! DoubleML's `RDFlex` (Noack, Olma and Rothe, 2024).
3//!
4//! The outcome (and, in a fuzzy design, the treatment) is adjusted by a
5//! cross-fitted prediction from the covariates `X` evaluated at the cutoff,
6//! `η(X) = (μ(X; left) + μ(X; right)) / 2`, learned with kernel weights
7//! around the cutoff. A local polynomial RD estimator ([`rd_estimate`], with
8//! `rdrobust`'s options in [`RdOptions`]) is then run on the adjusted
9//! outcome. The kernel bandwidth of the first stage is updated from the RD
10//! bandwidth over `n_iterations` rounds. A binary outcome can be learned with
11//! a classifier ([`RdFlex::sharp_binary`], [`RdFlex::fuzzy_binary`]).
12
13use std::fmt;
14
15use ndarray::{concatenate, Array1, Array2, ArrayView1, ArrayView2, Axis};
16
17use crate::crossfit::{Fold, SampleSplitting};
18use crate::data::{gather, DmlData};
19use crate::error::{DmlError, Result};
20use crate::learner::{Classifier, Regressor};
21use crate::rd::{rd_estimate, rdbwselect, RdBandwidths, RdData, RdEstimate, RdOptions};
22use crate::stats::{median, norm_cdf, norm_ppf};
23
24/// Regressors of the first stage besides the covariates.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum FirstStage {
27    /// The cutoff indicator `1{score ≥ cutoff}`.
28    #[default]
29    Cutoff,
30    /// The cutoff indicator and the score.
31    CutoffAndScore,
32    /// The cutoff indicator, the score and their interaction.
33    InteractedCutoffAndScore,
34}
35
36/// Kernel of the first-stage weights.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum RdKernel {
39    Uniform,
40    #[default]
41    Triangular,
42    Epanechnikov,
43}
44
45impl RdKernel {
46    /// DoubleML's first-stage kernel weight at distance `x` from the cutoff.
47    fn weight(self, x: f64, h: f64) -> f64 {
48        match self {
49            RdKernel::Uniform => f64::from(x.abs() <= h),
50            RdKernel::Triangular => 0.0_f64.max((h - x.abs()) / h),
51            RdKernel::Epanechnikov => {
52                if x.abs() < h {
53                    0.75 * (1.0 - (x / h) * (x / h))
54                } else {
55                    0.0
56                }
57            }
58        }
59    }
60
61    fn name(self) -> &'static str {
62        match self {
63            RdKernel::Uniform => "uniform",
64            RdKernel::Triangular => "triangular",
65            RdKernel::Epanechnikov => "epanechnikov",
66        }
67    }
68}
69
70/// Learner of the outcome: a regressor, or a classifier for a binary outcome.
71enum OutcomeLearner {
72    Regressor(Box<dyn Regressor>),
73    Classifier(Box<dyn Classifier>),
74}
75
76/// RD with flexible covariate adjustment. A fuzzy design ([`RdFlex::fuzzy`])
77/// also adjusts the treatment with a classifier.
78pub struct RdFlex {
79    ml_g: OutcomeLearner,
80    ml_m: Option<Box<dyn Classifier>>,
81    cutoff: f64,
82    h_fs: Option<f64>,
83    first_stage: FirstStage,
84    kernel: RdKernel,
85    n_iterations: usize,
86    rd_options: RdOptions,
87    rd_covariates: Option<Array2<f64>>,
88    rd_clusters: Option<Vec<i64>>,
89    splitting: SampleSplitting,
90}
91
92impl RdFlex {
93    /// Sharp design: treatment is `1{score ≥ cutoff}`. `ml_g` learns the
94    /// outcome and must support sample weights.
95    pub fn sharp(ml_g: impl Regressor + 'static) -> Self {
96        Self::from_boxed(Box::new(ml_g), None)
97    }
98
99    /// Fuzzy design: `ml_m` (supporting sample weights) learns the treatment.
100    pub fn fuzzy(ml_g: impl Regressor + 'static, ml_m: impl Classifier + 'static) -> Self {
101        Self::from_boxed(Box::new(ml_g), Some(Box::new(ml_m)))
102    }
103
104    /// `ml_m = Some(_)` fits a fuzzy design.
105    pub fn from_boxed(ml_g: Box<dyn Regressor>, ml_m: Option<Box<dyn Classifier>>) -> Self {
106        Self::with_outcome(OutcomeLearner::Regressor(ml_g), ml_m)
107    }
108
109    /// Sharp design with a binary (0/1) outcome learned by the classifier
110    /// `ml_g` (its class-1 probability), as DoubleML with a classifier.
111    pub fn sharp_binary(ml_g: impl Classifier + 'static) -> Self {
112        Self::from_boxed_binary(Box::new(ml_g), None)
113    }
114
115    /// Fuzzy design with a binary outcome learned by the classifier `ml_g`.
116    pub fn fuzzy_binary(ml_g: impl Classifier + 'static, ml_m: impl Classifier + 'static) -> Self {
117        Self::from_boxed_binary(Box::new(ml_g), Some(Box::new(ml_m)))
118    }
119
120    /// Binary outcome with a boxed classifier; `ml_m = Some(_)` fits a fuzzy
121    /// design.
122    pub fn from_boxed_binary(ml_g: Box<dyn Classifier>, ml_m: Option<Box<dyn Classifier>>) -> Self {
123        Self::with_outcome(OutcomeLearner::Classifier(ml_g), ml_m)
124    }
125
126    fn with_outcome(ml_g: OutcomeLearner, ml_m: Option<Box<dyn Classifier>>) -> Self {
127        Self {
128            ml_g,
129            ml_m,
130            cutoff: 0.0,
131            h_fs: None,
132            first_stage: FirstStage::default(),
133            kernel: RdKernel::default(),
134            n_iterations: 2,
135            rd_options: RdOptions::default(),
136            rd_covariates: None,
137            rd_clusters: None,
138            splitting: SampleSplitting::default(),
139        }
140    }
141
142    /// Options of the RD estimator (`rdrobust`'s keyword arguments in
143    /// DoubleML: polynomial order, kernel, bandwidth selector, variance, ...).
144    /// The initial first-stage bandwidth is always selected with the defaults.
145    pub fn rd_options(mut self, options: RdOptions) -> Self {
146        self.rd_options = options;
147        self
148    }
149
150    /// Covariates for the RD estimator itself (`rdrobust`'s `covs`), one row
151    /// per observation; the first stage uses the data's covariates.
152    pub fn rd_covariates(mut self, covs: Array2<f64>) -> Self {
153        self.rd_covariates = Some(covs);
154        self
155    }
156
157    /// Cluster labels for cluster-robust RD standard errors (`rdrobust`'s
158    /// `cluster`); the sample splits are not clustered, as in DoubleML.
159    pub fn rd_clusters(mut self, labels: Vec<i64>) -> Self {
160        self.rd_clusters = Some(labels);
161        self
162    }
163
164    pub fn cutoff(mut self, cutoff: f64) -> Self {
165        self.cutoff = cutoff;
166        self
167    }
168
169    /// Initial first-stage bandwidth (default: `rdbwselect` without covariates).
170    pub fn h_fs(mut self, h: f64) -> Self {
171        self.h_fs = Some(h);
172        self
173    }
174
175    pub fn first_stage(mut self, spec: FirstStage) -> Self {
176        self.first_stage = spec;
177        self
178    }
179
180    pub fn kernel(mut self, kernel: RdKernel) -> Self {
181        self.kernel = kernel;
182        self
183    }
184
185    /// Rounds of first-stage fitting and bandwidth updating (default 2).
186    pub fn n_iterations(mut self, n: usize) -> Self {
187        self.n_iterations = n;
188        self
189    }
190
191    /// Number of cross-fitting folds (default 5).
192    pub fn n_folds(mut self, n_folds: usize) -> Self {
193        self.splitting.n_folds = n_folds;
194        self
195    }
196
197    /// Number of independent sample splits (default 1), aggregated by the median.
198    pub fn n_rep(mut self, n_rep: usize) -> Self {
199        self.splitting.n_rep = n_rep;
200        self
201    }
202
203    /// Seed for the sample splits (default 0).
204    pub fn seed(mut self, seed: u64) -> Self {
205        self.splitting.seed = seed;
206        self
207    }
208
209    /// Fit with sample splits stratified by the treatment; `score` is the
210    /// running variable.
211    pub fn fit(&self, data: &DmlData, score: ArrayView1<'_, f64>) -> Result<RdFlexFit> {
212        self.validate(data, score)?;
213        let codes: Vec<usize> = data.treatment(0).iter().map(|&v| v as usize).collect();
214        let splits = self.splitting.draw_stratified(&codes)?;
215        self.fit_with_folds(data, score, &splits)
216    }
217
218    /// Fit on given sample splits (one `Vec<Fold>` per repetition).
219    pub fn fit_with_folds(
220        &self,
221        data: &DmlData,
222        score: ArrayView1<'_, f64>,
223        splits: &[Vec<Fold>],
224    ) -> Result<RdFlexFit> {
225        self.validate(data, score)?;
226        if splits.is_empty() {
227            return Err(DmlError::InvalidArgument(
228                "need at least one repetition".into(),
229            ));
230        }
231        let (y, d, x) = (data.y(), data.treatment(0), data.x());
232        let score: Array1<f64> = score.mapv(|s| s - self.cutoff);
233        let fuzzy = self.ml_m.is_some();
234        let h_fs = match self.h_fs {
235            Some(h) => h,
236            None => {
237                let (h, b) = rdbwselect(y, score.view(), fuzzy.then_some(d), 0.0)?;
238                h.max(b)
239            }
240        };
241        let (zx, zx_left, zx_right) = self.design(score.view(), x);
242        let w0: Array1<f64> = score.mapv(|s| self.kernel.weight(s, h_fs));
243
244        let mut reps = Vec::with_capacity(splits.len());
245        let outcome = match &self.ml_g {
246            OutcomeLearner::Regressor(r) => Nuisance::Regressor(r.as_ref()),
247            OutcomeLearner::Classifier(c) => Nuisance::Classifier(c.as_ref()),
248        };
249        for folds in splits {
250            let mut weights = w0.clone();
251            let mut bandwidth = None;
252            let mut last = None;
253            for iteration in 0..self.n_iterations {
254                let eta_y = adjust(outcome, &zx, &zx_left, &zx_right, y, &weights, folds)?;
255                let m_y = &y - &eta_y;
256                let m_d = match &self.ml_m {
257                    Some(m) => {
258                        let eta_d = adjust(
259                            Nuisance::Classifier(m.as_ref()),
260                            &zx,
261                            &zx_left,
262                            &zx_right,
263                            d,
264                            &weights,
265                            folds,
266                        )?;
267                        Some(&d - &eta_d)
268                    }
269                    None => None,
270                };
271                let mut rd_data = RdData::new(m_y.view(), score.view());
272                rd_data.fuzzy = m_d.as_ref().map(|t| t.view());
273                rd_data.covs = self.rd_covariates.as_ref().map(|z| z.view());
274                rd_data.cluster = self.rd_clusters.as_deref();
275                if iteration + 1 < self.n_iterations {
276                    // Intermediate rounds select the RD bandwidth afresh and
277                    // update the first-stage weights with it (DoubleML).
278                    let est = rd_estimate(&rd_data, &self.rd_options, None)?;
279                    let (h, b) = (est.h.0.max(est.h.1), est.b.0.max(est.b.1));
280                    weights = score.mapv(|s| self.kernel.weight(s, h));
281                    bandwidth = Some(RdBandwidths::common(h, b));
282                } else {
283                    last = Some(rd_estimate(&rd_data, &self.rd_options, bandwidth)?);
284                }
285            }
286            reps.push(last.expect("n_iterations >= 1"));
287        }
288        Ok(RdFlexFit::new(reps, h_fs, fuzzy, self.cutoff, self.kernel))
289    }
290
291    fn validate(&self, data: &DmlData, score: ArrayView1<'_, f64>) -> Result<()> {
292        if data.n_treat() != 1 || !data.is_binary_treatment(0) {
293            return Err(DmlError::InvalidArgument(
294                "RDFlex needs exactly one binary (0/1) treatment".into(),
295            ));
296        }
297        if data.clusters().is_some() || data.z().is_some() {
298            return Err(DmlError::InvalidArgument(
299                "RDFlex does not support clusters or instruments".into(),
300            ));
301        }
302        if score.len() != data.n_obs() || !score.iter().all(|s| s.is_finite()) {
303            return Err(DmlError::InvalidArgument(
304                "the score needs one finite value per observation".into(),
305            ));
306        }
307        let (lo, hi) = score
308            .iter()
309            .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &s| {
310                (a.min(s), b.max(s))
311            });
312        if !(lo <= self.cutoff && self.cutoff <= hi) {
313            return Err(DmlError::InvalidArgument(
314                "the cutoff is not within the range of the score".into(),
315            ));
316        }
317        if self.n_iterations == 0 {
318            return Err(DmlError::InvalidArgument(
319                "n_iterations must be at least 1".into(),
320            ));
321        }
322        if self.h_fs.is_some_and(|h| !(h.is_finite() && h > 0.0)) {
323            return Err(DmlError::InvalidArgument("h_fs must be positive".into()));
324        }
325        if matches!(self.ml_g, OutcomeLearner::Classifier(_))
326            && !data.y().iter().all(|&v| v == 0.0 || v == 1.0)
327        {
328            return Err(DmlError::InvalidArgument(
329                "a classifier for the outcome needs a binary (0/1) outcome".into(),
330            ));
331        }
332        let n = data.n_obs();
333        if self.rd_covariates.as_ref().is_some_and(|z| z.nrows() != n)
334            || self.rd_clusters.as_ref().is_some_and(|c| c.len() != n)
335        {
336            return Err(DmlError::DimensionMismatch(
337                "RD covariates and clusters need one row per observation".into(),
338            ));
339        }
340        Ok(())
341    }
342
343    /// First-stage design `[Z, X]` and its values at the cutoff from the left
344    /// (`Z = 0`) and from the right (cutoff indicator 1, score 0).
345    fn design(
346        &self,
347        score: ArrayView1<'_, f64>,
348        x: ArrayView2<'_, f64>,
349    ) -> (Array2<f64>, Array2<f64>, Array2<f64>) {
350        let n = score.len();
351        let z_ind = score.mapv(|s| f64::from(s >= 0.0));
352        let z = match self.first_stage {
353            FirstStage::Cutoff => z_ind.insert_axis(Axis(1)),
354            FirstStage::CutoffAndScore => ndarray::stack![Axis(1), z_ind, score],
355            FirstStage::InteractedCutoffAndScore => {
356                ndarray::stack![Axis(1), z_ind, &z_ind * &score, score]
357            }
358        };
359        let k = z.ncols();
360        let mut z_right = Array2::zeros((n, k));
361        z_right.column_mut(0).fill(1.0);
362        let zx = concatenate![Axis(1), z, x];
363        let zx_left = concatenate![Axis(1), Array2::zeros((n, k)), x];
364        let zx_right = concatenate![Axis(1), z_right, x];
365        (zx, zx_left, zx_right)
366    }
367}
368
369#[derive(Clone, Copy)]
370enum Nuisance<'a> {
371    Regressor(&'a dyn Regressor),
372    Classifier(&'a dyn Classifier),
373}
374
375/// Cross-fitted `(μ(left) + μ(right)) / 2` at the cutoff, fitted with
376/// `weights`.
377fn adjust(
378    learner: Nuisance<'_>,
379    zx: &Array2<f64>,
380    zx_left: &Array2<f64>,
381    zx_right: &Array2<f64>,
382    target: ArrayView1<'_, f64>,
383    weights: &Array1<f64>,
384    folds: &[Fold],
385) -> Result<Array1<f64>> {
386    let n = target.len();
387    let fit_fold = |fold: &Fold| -> Result<(Array1<f64>, Array1<f64>)> {
388        let xt = zx.select(Axis(0), &fold.train);
389        let (yt, wt) = (
390            gather(target, &fold.train),
391            gather(weights.view(), &fold.train),
392        );
393        let (xl, xr) = (
394            zx_left.select(Axis(0), &fold.test),
395            zx_right.select(Axis(0), &fold.test),
396        );
397        match learner {
398            Nuisance::Regressor(r) => {
399                let mut m = r.fresh();
400                m.fit_weighted(xt.view(), yt.view(), wt.view())?;
401                Ok((m.predict(xl.view())?, m.predict(xr.view())?))
402            }
403            Nuisance::Classifier(c) => {
404                let mut m = c.fresh();
405                m.fit_weighted(xt.view(), yt.view(), wt.view())?;
406                Ok((m.predict_proba(xl.view())?, m.predict_proba(xr.view())?))
407            }
408        }
409    };
410    let per_fold: Vec<_> = crate::crossfit::map_folds(folds.len(), |k| fit_fold(&folds[k]))?;
411    let mut eta = Array1::from_elem(n, f64::NAN);
412    for (fold, (left, right)) in folds.iter().zip(per_fold) {
413        for (k, &i) in fold.test.iter().enumerate() {
414            eta[i] = (left[k] + right[k]) / 2.0;
415        }
416    }
417    Ok(eta)
418}
419
420/// Fitted [`RdFlex`]: the conventional, bias-corrected and robust RD
421/// estimates (rows), aggregated over repetitions as DoubleML.
422#[derive(Debug, Clone)]
423pub struct RdFlexFit {
424    reps: Vec<RdEstimate>,
425    coef: [f64; 3],
426    se: [f64; 3],
427    ci: [[f64; 2]; 3],
428    h_fs: f64,
429    fuzzy: bool,
430    cutoff: f64,
431    kernel: RdKernel,
432}
433
434const LABELS: [&str; 3] = ["Conventional", "Bias-Corrected", "Robust"];
435
436impl RdFlexFit {
437    fn new(reps: Vec<RdEstimate>, h_fs: f64, fuzzy: bool, cutoff: f64, kernel: RdKernel) -> Self {
438        // DoubleML: median estimate; se² = median(se_r² + (θ_r − θ)² / N_h,r).
439        let coef: [f64; 3] =
440            std::array::from_fn(|k| median(&reps.iter().map(|r| r.coef[k]).collect::<Vec<_>>()));
441        let se = std::array::from_fn(|k| {
442            let v: Vec<f64> = reps
443                .iter()
444                .map(|r| {
445                    let n_h = (r.n_h.0 + r.n_h.1) as f64;
446                    r.se[k] * r.se[k] + (r.coef[k] - coef[k]).powi(2) / n_h
447                })
448                .collect();
449            median(&v).sqrt()
450        });
451        let ci = std::array::from_fn(|k| {
452            std::array::from_fn(|j| median(&reps.iter().map(|r| r.ci[k][j]).collect::<Vec<_>>()))
453        });
454        Self {
455            reps,
456            coef,
457            se,
458            ci,
459            h_fs,
460            fuzzy,
461            cutoff,
462            kernel,
463        }
464    }
465
466    /// Conventional, bias-corrected and robust estimates.
467    pub fn coef(&self) -> [f64; 3] {
468        self.coef
469    }
470
471    pub fn se(&self) -> [f64; 3] {
472        self.se
473    }
474
475    pub fn t_stat(&self) -> [f64; 3] {
476        std::array::from_fn(|k| self.coef[k] / self.se[k])
477    }
478
479    pub fn p_value(&self) -> [f64; 3] {
480        std::array::from_fn(|k| 2.0 * norm_cdf(-(self.coef[k] / self.se[k]).abs()))
481    }
482
483    /// 95% intervals: the median of each repetition's `rdrobust` interval.
484    pub fn ci(&self) -> [[f64; 2]; 3] {
485        self.ci
486    }
487
488    /// Intervals at `level` from each repetition's estimate and standard
489    /// error, aggregated by the median (DoubleML's `confint`).
490    pub fn confint(&self, level: f64) -> Result<[[f64; 2]; 3]> {
491        crate::fit::check_level(level)?;
492        let z = norm_ppf(1.0 - (1.0 - level) / 2.0);
493        Ok(std::array::from_fn(|k| {
494            let lo: Vec<f64> = self.reps.iter().map(|r| r.coef[k] - r.se[k] * z).collect();
495            let hi: Vec<f64> = self.reps.iter().map(|r| r.coef[k] + r.se[k] * z).collect();
496            [median(&lo), median(&hi)]
497        }))
498    }
499
500    /// Estimates per repetition, shape `(3, n_rep)`.
501    pub fn all_coef(&self) -> Array2<f64> {
502        Array2::from_shape_fn((3, self.reps.len()), |(k, r)| self.reps[r].coef[k])
503    }
504
505    pub fn all_se(&self) -> Array2<f64> {
506        Array2::from_shape_fn((3, self.reps.len()), |(k, r)| self.reps[r].se[k])
507    }
508
509    /// Final RD bandwidth `h` of each repetition.
510    pub fn h(&self) -> Vec<f64> {
511        self.reps.iter().map(|r| r.h.0).collect()
512    }
513
514    /// Bias bandwidth `b` of each repetition.
515    pub fn b(&self) -> Vec<f64> {
516        self.reps.iter().map(|r| r.b.0).collect()
517    }
518
519    /// Initial first-stage bandwidth.
520    pub fn h_fs(&self) -> f64 {
521        self.h_fs
522    }
523
524    /// The `rdrobust` result of every repetition.
525    pub fn repetitions(&self) -> &[RdEstimate] {
526        &self.reps
527    }
528
529    pub fn is_fuzzy(&self) -> bool {
530        self.fuzzy
531    }
532}
533
534impl fmt::Display for RdFlexFit {
535    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536        let (t, p, ci) = (self.t_stat(), self.p_value(), self.ci);
537        writeln!(
538            f,
539            "{:<16}{:>10}{:>10}{:>9}{:>11}{:>24}",
540            "Method", "Coef.", "S.E.", "t-stat", "P>|t|", "95% CI"
541        )?;
542        writeln!(f, "{}", "-".repeat(80))?;
543        for k in 0..3 {
544            writeln!(
545                f,
546                "{:<16}{:>10.4}{:>10.4}{:>9.3}{:>11.3e}   [{:.4}, {:.4}]",
547                LABELS[k], self.coef[k], self.se[k], t[k], p[k], ci[k][0], ci[k][1]
548            )?;
549        }
550        writeln!(
551            f,
552            "Design:             {}",
553            if self.fuzzy { "fuzzy" } else { "sharp" }
554        )?;
555        writeln!(f, "Cutoff:             {}", self.cutoff)?;
556        writeln!(f, "First-stage kernel: {}", self.kernel.name())?;
557        write!(f, "Final bandwidth:    {:?}", self.h())
558    }
559}