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