Skip to main content

dml_core/
rd.rs

1//! Local polynomial regression discontinuity (RD) estimation: a port of the
2//! `rdrobust` package 2.1.1 (Calonico, Cattaneo and Titiunik, 2014;
3//! Calonico, Cattaneo and Farrell, 2020), used by [`RdFlex`](crate::RdFlex).
4//!
5//! - [`rd_bandwidths`]: the MSE- or CER-optimal bandwidths of `rdbwselect`
6//!   for the point estimate (`h`) and the bias correction (`b`).
7//! - [`rd_estimate`]: the local polynomial estimate at the cutoff with its
8//!   bias correction and the conventional, bias-corrected and robust
9//!   confidence intervals.
10//!
11//! [`RdOptions`] holds `rdrobust`'s options (polynomial orders, kernel,
12//! bandwidth selector, variance estimator, mass-point handling, ...) with its
13//! defaults; [`RdData`] the outcome, running variable and optional treatment
14//! (fuzzy design), covariates and cluster labels. With clusters the variance
15//! is cluster-robust (`rdrobust`'s `cr1`). [`rdbwselect`] and [`rdrobust`]
16//! are shortcuts with the default options.
17
18use ndarray::{s, Array1, Array2, ArrayView1, ArrayView2, Axis};
19
20use crate::compat::{np_mean, np_sum};
21use crate::error::{DmlError, Result};
22use crate::linalg::{spd_inverse, sym_pinv};
23use crate::stats::norm_ppf;
24
25/// Kernel of the local polynomial fits.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum Kernel {
28    #[default]
29    Triangular,
30    Epanechnikov,
31    Uniform,
32}
33
34impl Kernel {
35    /// `rdrobust_kweight`.
36    fn weight(self, x: f64, c: f64, h: f64) -> f64 {
37        let u = (x - c) / h;
38        let inside = f64::from(u.abs() <= 1.0);
39        match self {
40            Kernel::Epanechnikov => (0.75 * (1.0 - u * u) * inside) / h,
41            Kernel::Uniform => (0.5 * inside) / h,
42            Kernel::Triangular => ((1.0 - u.abs()) * inside) / h,
43        }
44    }
45
46    /// The constant `C_c` of the pilot bandwidth.
47    fn c_c(self) -> f64 {
48        match self {
49            Kernel::Epanechnikov => 2.34,
50            Kernel::Uniform => 1.843,
51            Kernel::Triangular => 2.576,
52        }
53    }
54}
55
56/// Bandwidth selector, as `rdrobust`'s `bwselect`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum BwSelect {
59    /// One common MSE-optimal bandwidth for the RD effect.
60    #[default]
61    Mserd,
62    /// Separate MSE-optimal bandwidths below and above the cutoff.
63    Msetwo,
64    /// One common MSE-optimal bandwidth for the sum of the two regression
65    /// estimates.
66    Msesum,
67    /// `min(mserd, msesum)`.
68    Msecomb1,
69    /// `median(msetwo, mserd, msesum)` on each side.
70    Msecomb2,
71    /// Coverage-error-rate optimal versions of the above.
72    Cerrd,
73    Certwo,
74    Cersum,
75    Cercomb1,
76    Cercomb2,
77}
78
79impl BwSelect {
80    fn needs_two(self) -> bool {
81        matches!(
82            self,
83            BwSelect::Msetwo | BwSelect::Certwo | BwSelect::Msecomb2 | BwSelect::Cercomb2
84        )
85    }
86
87    fn needs_sum(self) -> bool {
88        matches!(
89            self,
90            BwSelect::Msesum
91                | BwSelect::Cersum
92                | BwSelect::Msecomb1
93                | BwSelect::Msecomb2
94                | BwSelect::Cercomb1
95                | BwSelect::Cercomb2
96        )
97    }
98
99    fn needs_rd(self) -> bool {
100        matches!(
101            self,
102            BwSelect::Mserd
103                | BwSelect::Cerrd
104                | BwSelect::Msecomb1
105                | BwSelect::Msecomb2
106                | BwSelect::Cercomb1
107                | BwSelect::Cercomb2
108        )
109    }
110
111    fn is_cer(self) -> bool {
112        matches!(
113            self,
114            BwSelect::Cerrd
115                | BwSelect::Certwo
116                | BwSelect::Cersum
117                | BwSelect::Cercomb1
118                | BwSelect::Cercomb2
119        )
120    }
121}
122
123/// Variance estimator, as `rdrobust`'s `vce`. With cluster labels, `Nn`,
124/// `Hc0` and `Hc1` all give the cluster-robust `cr1`; the leverage-adjusted
125/// cluster estimators (`rdrobust`'s `cr2` / `cr3`, from `Hc2` / `Hc3`) are
126/// not available.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
128pub enum Vce {
129    /// Nearest-neighbour residuals (with at least `nnmatch` neighbours).
130    #[default]
131    Nn,
132    Hc0,
133    Hc1,
134    Hc2,
135    Hc3,
136}
137
138/// Treatment of repeated values of the running variable, as `rdrobust`'s
139/// `masspoints`.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
141pub enum MassPoints {
142    /// With many repeated values, enlarge the pilot bandwidths to contain at
143    /// least `bwcheck` (default 10) distinct values, and count distinct
144    /// values in the pilot rule.
145    #[default]
146    Adjust,
147    /// Count distinct values only for detection (no adjustment).
148    Check,
149    /// Ignore repeated values.
150    Off,
151}
152
153/// Options of [`rd_bandwidths`] and [`rd_estimate`], with `rdrobust`'s
154/// defaults.
155#[derive(Debug, Clone, PartialEq)]
156pub struct RdOptions {
157    /// Polynomial order of the point estimate (default 1).
158    pub p: usize,
159    /// Polynomial order of the bias correction (default `p + 1`).
160    pub q: Option<usize>,
161    pub kernel: Kernel,
162    pub bwselect: BwSelect,
163    pub vce: Vce,
164    /// Minimum number of neighbours for `Vce::Nn` (default 3).
165    pub nnmatch: usize,
166    /// Confidence level of the intervals (default 0.95).
167    pub level: f64,
168    pub masspoints: MassPoints,
169    /// Minimum number of distinct running-variable values in the pilot
170    /// bandwidths (default: none, or 10 with detected mass points).
171    pub bwcheck: Option<usize>,
172    /// Restrict bandwidths to the range of the running variable (default true).
173    pub bwrestrict: bool,
174    /// Standardise the outcome and running variable for bandwidth selection
175    /// (default true).
176    pub stdvars: bool,
177    /// Scale of the regularisation term of the bandwidth selectors (default 1).
178    pub scaleregul: f64,
179    /// Fuzzy design with the bandwidth selector of the sharp design (also
180    /// used when the treatment does not vary on one side).
181    pub sharpbw: bool,
182    /// Bias bandwidth `b = h / rho` (default: selected separately).
183    pub rho: Option<f64>,
184    /// Multiplier of the estimate (default 1).
185    pub scalepar: f64,
186    /// Drop collinear covariates (default true).
187    pub covs_drop: bool,
188}
189
190impl Default for RdOptions {
191    fn default() -> Self {
192        Self {
193            p: 1,
194            q: None,
195            kernel: Kernel::Triangular,
196            bwselect: BwSelect::Mserd,
197            vce: Vce::Nn,
198            nnmatch: 3,
199            level: 0.95,
200            masspoints: MassPoints::Adjust,
201            bwcheck: None,
202            bwrestrict: true,
203            stdvars: true,
204            scaleregul: 1.0,
205            sharpbw: false,
206            rho: None,
207            scalepar: 1.0,
208            covs_drop: true,
209        }
210    }
211}
212
213impl RdOptions {
214    fn q(&self) -> usize {
215        self.q.unwrap_or(self.p + 1)
216    }
217
218    fn validate(&self) -> Result<()> {
219        let q = self.q();
220        let bad = |msg: &str| Err(DmlError::InvalidArgument(format!("rdrobust: {msg}")));
221        if self.p > 20 || q > 20 || q <= self.p {
222            return bad("polynomial orders need p <= 20 and p < q <= 20");
223        }
224        if self.nnmatch == 0 {
225            return bad("nnmatch must be at least 1");
226        }
227        if !(self.level > 0.0 && self.level < 1.0) {
228            return bad("level must be in (0, 1)");
229        }
230        if self.bwcheck == Some(0) {
231            return bad("bwcheck must be a positive integer");
232        }
233        if self.rho.is_some_and(|r| !(r.is_finite() && r > 0.0)) {
234            return bad("rho must be positive");
235        }
236        if !(self.scaleregul.is_finite() && self.scaleregul >= 0.0) || !self.scalepar.is_finite() {
237            return bad("scaleregul and scalepar must be finite (scaleregul >= 0)");
238        }
239        Ok(())
240    }
241}
242
243/// Data of an RD design: outcome `y`, running variable `x` and cutoff, and
244/// optionally the treatment (fuzzy design), covariates and cluster labels.
245#[derive(Debug, Clone, Copy)]
246pub struct RdData<'a> {
247    pub y: ArrayView1<'a, f64>,
248    pub x: ArrayView1<'a, f64>,
249    pub cutoff: f64,
250    pub fuzzy: Option<ArrayView1<'a, f64>>,
251    pub covs: Option<ArrayView2<'a, f64>>,
252    pub cluster: Option<&'a [i64]>,
253}
254
255impl<'a> RdData<'a> {
256    /// A sharp design with cutoff 0.
257    pub fn new(y: ArrayView1<'a, f64>, x: ArrayView1<'a, f64>) -> Self {
258        Self {
259            y,
260            x,
261            cutoff: 0.0,
262            fuzzy: None,
263            covs: None,
264            cluster: None,
265        }
266    }
267
268    pub fn cutoff(mut self, c: f64) -> Self {
269        self.cutoff = c;
270        self
271    }
272
273    /// Fuzzy design with treatment `t`.
274    pub fn fuzzy(mut self, t: ArrayView1<'a, f64>) -> Self {
275        self.fuzzy = Some(t);
276        self
277    }
278
279    /// Covariates for covariate-adjusted estimation (`rdrobust`'s `covs`).
280    pub fn covariates(mut self, z: ArrayView2<'a, f64>) -> Self {
281        self.covs = Some(z);
282        self
283    }
284
285    /// Cluster labels for cluster-robust standard errors.
286    pub fn clusters(mut self, labels: &'a [i64]) -> Self {
287        self.cluster = Some(labels);
288        self
289    }
290}
291
292/// Bandwidths below (`.0`) and above (`.1`) the cutoff.
293#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct RdBandwidths {
295    /// Main bandwidth.
296    pub h: (f64, f64),
297    /// Bias bandwidth.
298    pub b: (f64, f64),
299}
300
301impl RdBandwidths {
302    /// The same `h` and `b` on both sides.
303    pub fn common(h: f64, b: f64) -> Self {
304        Self {
305            h: (h, h),
306            b: (b, b),
307        }
308    }
309}
310
311/// Result of [`rd_estimate`]: rows are the conventional, bias-corrected and
312/// robust (bias-corrected with robust standard error) estimates.
313#[derive(Debug, Clone)]
314pub struct RdEstimate {
315    pub coef: [f64; 3],
316    pub se: [f64; 3],
317    /// Confidence intervals `[lower, upper]` at the options' level.
318    pub ci: [[f64; 2]; 3],
319    /// Main bandwidth `(left, right)`.
320    pub h: (f64, f64),
321    /// Bias bandwidth `(left, right)`.
322    pub b: (f64, f64),
323    /// Observations within the main bandwidth `(left, right)`.
324    pub n_h: (usize, usize),
325    /// Observations on each side of the cutoff.
326    pub n: (usize, usize),
327}
328
329// ---------------------------------------------------------------------------
330// Data preparation
331// ---------------------------------------------------------------------------
332
333/// One side of the cutoff, sorted by the running variable.
334#[derive(Clone)]
335struct Side {
336    x: Vec<f64>,
337    y: Vec<f64>,
338    t: Option<Vec<f64>>,
339    z: Option<Array2<f64>>,
340    cl: Option<Vec<i64>>,
341    dups: Vec<usize>,
342    dupsid: Vec<usize>,
343}
344
345impl Side {
346    fn new(
347        x: Vec<f64>,
348        y: Vec<f64>,
349        t: Option<Vec<f64>>,
350        z: Option<Array2<f64>>,
351        cl: Option<Vec<i64>>,
352    ) -> Self {
353        let (dups, dupsid) = duplicates(&x);
354        Self {
355            x,
356            y,
357            t,
358            z,
359            cl,
360            dups,
361            dupsid,
362        }
363    }
364
365    fn n(&self) -> usize {
366        self.x.len()
367    }
368
369    /// The observations in `keep`, with duplicates counted within them.
370    fn select(&self, keep: &[bool]) -> Side {
371        let rows: Vec<usize> = (0..self.n()).filter(|&i| keep[i]).collect();
372        let pick = |v: &[f64]| -> Vec<f64> { rows.iter().map(|&i| v[i]).collect() };
373        Side::new(
374            pick(&self.x),
375            pick(&self.y),
376            self.t.as_ref().map(|t| pick(t)),
377            self.z.as_ref().map(|z| z.select(Axis(0), &rows)),
378            self.cl
379                .as_ref()
380                .map(|c| rows.iter().map(|&i| c[i]).collect()),
381        )
382    }
383
384    fn d_t(&self) -> usize {
385        usize::from(self.t.is_some())
386    }
387
388    fn d_z(&self) -> usize {
389        self.z.as_ref().map_or(0, |z| z.ncols())
390    }
391
392    /// Outcome, treatment and covariate columns.
393    fn d(&self) -> Array2<f64> {
394        let (dt, dz) = (self.d_t(), self.d_z());
395        let mut d = Array2::zeros((self.n(), 1 + dt + dz));
396        for i in 0..self.n() {
397            d[[i, 0]] = self.y[i];
398            if let Some(t) = &self.t {
399                d[[i, 1]] = t[i];
400            }
401            if let Some(z) = &self.z {
402                for j in 0..dz {
403                    d[[i, 1 + dt + j]] = z[[i, j]];
404                }
405            }
406        }
407        d
408    }
409}
410
411/// Size of each run of equal values and the position within it, as
412/// pandas' `groupby(x).transform("sum" / "cumsum")` on sorted `x`.
413fn duplicates(x: &[f64]) -> (Vec<usize>, Vec<usize>) {
414    let n = x.len();
415    let (mut dups, mut dupsid) = (vec![0; n], vec![0; n]);
416    let mut start = 0;
417    while start < n {
418        let mut end = start + 1;
419        while end < n && x[end] == x[start] {
420            end += 1;
421        }
422        for (k, i) in (start..end).enumerate() {
423            dups[i] = end - start;
424            dupsid[i] = k + 1;
425        }
426        start = end;
427    }
428    (dups, dupsid)
429}
430
431/// `[1, u, u², …, u^p]` by successive multiplication.
432fn vander(u: &[f64], p: usize) -> Array2<f64> {
433    let mut out = Array2::zeros((u.len(), p + 1));
434    for (i, &v) in u.iter().enumerate() {
435        out[[i, 0]] = 1.0;
436        for j in 1..=p {
437            out[[i, j]] = out[[i, j - 1]] * v;
438        }
439    }
440    out
441}
442
443/// NumPy's `x ** k` for a float base and integer exponent.
444fn powk(x: f64, k: usize) -> f64 {
445    match k {
446        0 => 1.0,
447        1 => x,
448        2 => x * x,
449        _ => x.powf(k as f64),
450    }
451}
452
453fn scale_rows(m: &Array2<f64>, w: &[f64]) -> Array2<f64> {
454    let mut out = m.clone();
455    for (mut row, &wi) in out.rows_mut().into_iter().zip(w) {
456        row *= wi;
457    }
458    out
459}
460
461fn crossprod(a: &Array2<f64>, b: &Array2<f64>) -> Array2<f64> {
462    a.t().dot(b)
463}
464
465fn n_unique(x: &[f64]) -> usize {
466    let mut v = x.to_vec();
467    v.sort_by(f64::total_cmp);
468    v.dedup();
469    v.len()
470}
471
472fn factorial(k: usize) -> f64 {
473    (1..=k).product::<usize>() as f64
474}
475
476/// NumPy's `std(ddof=1)`.
477fn std_ddof1(v: &[f64]) -> f64 {
478    let mean = np_sum(v) / v.len() as f64;
479    let sq: Vec<f64> = v.iter().map(|x| (x - mean) * (x - mean)).collect();
480    (np_sum(&sq) / (v.len() - 1) as f64).sqrt()
481}
482
483/// NumPy's `var` (population variance).
484fn var(v: &[f64]) -> f64 {
485    let mean = np_mean(v);
486    let sq: Vec<f64> = v.iter().map(|x| (x - mean) * (x - mean)).collect();
487    np_mean(&sq)
488}
489
490/// R's `quantile(type = 2)` of sorted `x`.
491fn quantile_type2(x: &[f64], prob: f64) -> f64 {
492    let n = x.len();
493    let h = n as f64 * prob;
494    let j = h.floor() as usize;
495    if h == j as f64 {
496        0.5 * (x[j - 1] + x[j])
497    } else {
498        x[j]
499    }
500}
501
502/// Covariate columns to keep (`covs_drop_fun`): all of them if the QR
503/// factorisation has no diagonal entry below `1e-5`, else the columns a
504/// column-pivoted QR finds independent, in pivot order.
505fn independent_columns(z: &Array2<f64>) -> Vec<usize> {
506    const TOL: f64 = 1e-5;
507    let k = z.ncols();
508    let (diag, _) = householder_qr(z, false);
509    if diag.iter().all(|d| d.abs() > TOL) {
510        return (0..k).collect();
511    }
512    let (diag, pivot) = householder_qr(z, true);
513    pivot
514        .into_iter()
515        .zip(diag)
516        .filter(|(_, d)| d.abs() > TOL)
517        .map(|(j, _)| j)
518        .collect()
519}
520
521/// Diagonal of `R` in a Householder QR of `a` (with column pivoting by the
522/// largest remaining norm, as LAPACK's `geqp3`) and the column order.
523fn householder_qr(a: &Array2<f64>, pivoting: bool) -> (Vec<f64>, Vec<usize>) {
524    let (n, k) = a.dim();
525    let mut m = a.clone();
526    let mut order: Vec<usize> = (0..k).collect();
527    let mut diag = Vec::with_capacity(k.min(n));
528    for j in 0..k.min(n) {
529        if pivoting {
530            let norm =
531                |c: usize, m: &Array2<f64>| (j..n).map(|i| m[[i, c]] * m[[i, c]]).sum::<f64>();
532            let best = (j..k)
533                .max_by(|&a, &b| norm(a, &m).total_cmp(&norm(b, &m)))
534                .expect("columns left");
535            if best != j {
536                for i in 0..n {
537                    m.swap([i, j], [i, best]);
538                }
539                order.swap(j, best);
540            }
541        }
542        let alpha = (j..n).map(|i| m[[i, j]] * m[[i, j]]).sum::<f64>().sqrt();
543        let alpha = if m[[j, j]] > 0.0 { -alpha } else { alpha };
544        diag.push(alpha);
545        if alpha == 0.0 {
546            continue;
547        }
548        let mut v: Vec<f64> = (j..n).map(|i| m[[i, j]]).collect();
549        v[0] -= alpha;
550        let vnorm2: f64 = v.iter().map(|x| x * x).sum();
551        if vnorm2 == 0.0 {
552            continue;
553        }
554        for c in j..k {
555            let dot: f64 = (j..n).map(|i| v[i - j] * m[[i, c]]).sum();
556            let f = 2.0 * dot / vnorm2;
557            for i in j..n {
558                m[[i, c]] -= f * v[i - j];
559            }
560        }
561    }
562    (diag, order)
563}
564
565/// Sorted, validated sides of the cutoff, with collinear covariates dropped.
566fn prepare(data: &RdData<'_>, opts: &RdOptions) -> Result<(Side, Side)> {
567    let (y, x, c) = (data.y, data.x, data.cutoff);
568    let n = x.len();
569    let lengths_ok = y.len() == n
570        && data.fuzzy.is_none_or_len(n)
571        && data.covs.map_or(true, |z| z.nrows() == n)
572        && data.cluster.map_or(true, |cl| cl.len() == n);
573    if !lengths_ok {
574        return Err(DmlError::DimensionMismatch(
575            "rdrobust: y, x, fuzzy, covs and cluster need the same number of observations".into(),
576        ));
577    }
578    let finite = x.iter().chain(y.iter()).all(|v| v.is_finite())
579        && data.fuzzy.map_or(true, |t| t.iter().all(|v| v.is_finite()))
580        && data.covs.map_or(true, |z| z.iter().all(|v| v.is_finite()));
581    if !finite {
582        return Err(DmlError::InvalidArgument(
583            "rdrobust: data contain NaN or infinite values".into(),
584        ));
585    }
586    if !c.is_finite() {
587        return Err(DmlError::InvalidArgument(
588            "rdrobust: the cutoff must be finite".into(),
589        ));
590    }
591    let covs = match data.covs {
592        Some(z) if opts.covs_drop => {
593            let z = z.to_owned();
594            let keep = independent_columns(&z);
595            Some(z.select(Axis(1), &keep))
596        }
597        Some(z) => Some(z.to_owned()),
598        None => None,
599    };
600    let mut order: Vec<usize> = (0..n).collect();
601    order.sort_by(|&a, &b| x[a].total_cmp(&x[b]));
602    let (x_min, x_max) = (x[order[0]], x[order[n - 1]]);
603    if c <= x_min || c >= x_max {
604        return Err(DmlError::InvalidArgument(
605            "the cutoff must lie within the range of the running variable".into(),
606        ));
607    }
608    let side = |left: bool| -> Side {
609        let rows: Vec<usize> = order
610            .iter()
611            .copied()
612            .filter(|&i| (x[i] < c) == left)
613            .collect();
614        Side::new(
615            rows.iter().map(|&i| x[i]).collect(),
616            rows.iter().map(|&i| y[i]).collect(),
617            data.fuzzy.map(|t| rows.iter().map(|&i| t[i]).collect()),
618            covs.as_ref().map(|z| z.select(Axis(0), &rows)),
619            data.cluster.map(|cl| rows.iter().map(|&i| cl[i]).collect()),
620        )
621    };
622    Ok((side(true), side(false)))
623}
624
625/// `Option<ArrayView1>` has the expected length (or is absent).
626trait LenCheck {
627    fn is_none_or_len(&self, n: usize) -> bool;
628}
629
630impl LenCheck for Option<ArrayView1<'_, f64>> {
631    fn is_none_or_len(&self, n: usize) -> bool {
632        self.map_or(true, |t| t.len() == n)
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Residuals and variance
638// ---------------------------------------------------------------------------
639
640/// `rdrobust_res`: nearest-neighbour residuals, or plug-in residuals
641/// `w · (D − fitted)` with the HC weight `w` (`d` coefficients in the fit).
642fn residuals(
643    e: &Side,
644    vce: Vce,
645    fitted: Option<&Array2<f64>>,
646    hii: Option<&Array1<f64>>,
647    nnmatch: usize,
648    d: usize,
649) -> Array2<f64> {
650    let dmat = e.d();
651    if vce == Vce::Nn {
652        return nn_residuals(e, &dmat, nnmatch);
653    }
654    let n = e.n();
655    let clustered = e.cl.is_some();
656    let fitted = fitted.expect("plug-in residuals need fitted values");
657    let mut res = &dmat - fitted;
658    for i in 0..n {
659        let w = match vce {
660            Vce::Hc0 => 1.0,
661            Vce::Hc1 if clustered => 1.0,
662            Vce::Hc1 => (n as f64 / (n - d) as f64).sqrt(),
663            Vce::Hc2 => (1.0 / (1.0 - hii.expect("leverages")[i]).max(1e-8)).sqrt(),
664            Vce::Hc3 => 1.0 / (1.0 - hii.expect("leverages")[i]).max(1e-8),
665            Vce::Nn => unreachable!(),
666        };
667        res.row_mut(i).mapv_inplace(|r| w * r);
668    }
669    res
670}
671
672/// Nearest-neighbour residuals (`vce = "nn"`): each observation minus the
673/// mean of its (at least) `nnmatch` nearest neighbours, scaled by
674/// `√(J / (J + 1))`. `x` is sorted.
675fn nn_residuals(s: &Side, dmat: &Array2<f64>, nnmatch: usize) -> Array2<f64> {
676    let n = s.n();
677    let mut res = Array2::zeros(dmat.dim());
678    let tol_eps = f64::EPSILON.sqrt();
679    let (x, dups, dupsid) = (&s.x, &s.dups, &s.dupsid);
680    for pos in 0..n {
681        let p = pos as i64;
682        let mut rpos = (dups[pos] - dupsid[pos]) as i64;
683        let mut lpos = dupsid[pos] as i64 - 1;
684        let cap = nnmatch.min(n.saturating_sub(1)) as i64;
685        while lpos + rpos < cap {
686            if p - lpos - 1 < 0 {
687                rpos += dups[(p + rpos + 1) as usize] as i64;
688            } else if p + rpos + 1 >= n as i64 {
689                lpos += dups[(p - lpos - 1) as usize] as i64;
690            } else {
691                let dleft = x[pos] - x[(p - lpos - 1) as usize];
692                let dright = x[(p + rpos + 1) as usize] - x[pos];
693                let nn_tol = dleft.max(dright) * tol_eps;
694                if dleft - dright > nn_tol {
695                    rpos += dups[(p + rpos + 1) as usize] as i64;
696                } else if dright - dleft > nn_tol {
697                    lpos += dups[(p - lpos - 1) as usize] as i64;
698                } else {
699                    rpos += dups[(p + rpos + 1) as usize] as i64;
700                    lpos += dups[(p - lpos - 1) as usize] as i64;
701                }
702            }
703        }
704        let (lo, hi) = ((p - lpos) as usize, (p + rpos + 1) as usize);
705        let ji = (hi - lo - 1) as f64;
706        let sf = (ji / (ji + 1.0)).powf(0.5);
707        for col in 0..dmat.ncols() {
708            let mut sum = 0.0;
709            for j in lo..hi {
710                sum += dmat[[j, col]];
711            }
712            sum -= dmat[[pos, col]];
713            res[[pos, col]] = sf * (dmat[[pos, col]] - sum / ji);
714        }
715    }
716    res
717}
718
719/// Meat of the sandwich (`rdrobust_vce`): `Σᵢ (sᵀrᵢ)² RXᵢ RXᵢᵀ`, or with
720/// clusters the CR1 sum over clusters of `(Σᵢ∈g RXᵢ sᵀrᵢ)(…)ᵀ` times
721/// `(n − 1)/(n − k) · G/(G − 1)` (`k_df` coefficients).
722fn vce_meat(
723    s: &[f64],
724    rx: &Array2<f64>,
725    res: &Array2<f64>,
726    cl: Option<&[i64]>,
727    k_df: usize,
728) -> Array2<f64> {
729    let k = rx.ncols();
730    let combined: Vec<f64> = res
731        .rows()
732        .into_iter()
733        .map(|row| row.iter().zip(s).map(|(r, si)| r * si).sum())
734        .collect();
735    match cl {
736        None => {
737            let scaled = scale_rows(rx, &combined);
738            crossprod(&scaled, &scaled)
739        }
740        Some(cl) => {
741            let n = cl.len();
742            let mut order: Vec<usize> = (0..n).collect();
743            order.sort_by_key(|&i| cl[i]);
744            let mut meat = Array2::<f64>::zeros((k, k));
745            let mut g = 0usize;
746            let mut start = 0;
747            while start < n {
748                let mut end = start + 1;
749                while end < n && cl[order[end]] == cl[order[start]] {
750                    end += 1;
751                }
752                let mut sv = Array1::<f64>::zeros(k);
753                for &i in &order[start..end] {
754                    sv.scaled_add(combined[i], &rx.row(i));
755                }
756                for a in 0..k {
757                    for b in 0..k {
758                        meat[[a, b]] += sv[a] * sv[b];
759                    }
760                }
761                g += 1;
762                start = end;
763            }
764            let w = if g > 1 {
765                ((n - 1) as f64 / (n - k_df) as f64) * (g as f64 / (g - 1) as f64)
766            } else {
767                f64::NAN
768            };
769            meat * w
770        }
771    }
772}
773
774/// Leverages `hᵢᵢ = Σⱼ (R G⁻¹)ᵢⱼ (RW)ᵢⱼ`.
775fn leverages(r: &Array2<f64>, inv_g: &Array2<f64>, rw: &Array2<f64>) -> Array1<f64> {
776    (r.dot(inv_g) * rw).sum_axis(Axis(1))
777}
778
779/// Partialling the covariates out of a weighted local fit: `γ` (rows:
780/// covariates, columns: outcome and treatment).
781fn covariate_gamma(rw: &Array2<f64>, inv_g: &Array2<f64>, e: &Side, w: &[f64]) -> Array2<f64> {
782    let (dt, dz) = (e.d_t(), e.d_z());
783    let d = e.d();
784    let u = crossprod(rw, &d);
785    let zw = scale_rows(e.z.as_ref().expect("covariates"), w);
786    let zwd = crossprod(&zw, &d);
787    let cols_z = s![.., 1 + dt..1 + dt + dz];
788    let uigu = u.slice(cols_z).t().dot(&inv_g.dot(&u));
789    let zwz = &zwd.slice(cols_z) - &uigu.slice(cols_z);
790    let zwy = &zwd.slice(s![.., ..1 + dt]) - &uigu.slice(s![.., ..1 + dt]);
791    sym_pinv(&zwz).dot(&zwy)
792}
793
794// ---------------------------------------------------------------------------
795// Bandwidth selection (rdbwselect)
796// ---------------------------------------------------------------------------
797
798/// `(V, B, R, rate)` of one bandwidth step on one side, NaN when a pilot
799/// fit is not identified.
800type BwParts = (f64, f64, f64, f64);
801
802/// The (standardised) data and settings of a bandwidth computation.
803struct BwCtx<'o> {
804    opts: &'o RdOptions,
805    vce: Vce,
806    c: f64,
807}
808
809impl BwCtx<'_> {
810    /// Plug-in or NN residuals of a pilot fit.
811    fn pilot_residuals(
812        &self,
813        e: &Side,
814        r: &Array2<f64>,
815        inv_g: &Array2<f64>,
816        rw: &Array2<f64>,
817        beta: &Array2<f64>,
818        d: usize,
819    ) -> Array2<f64> {
820        match self.vce {
821            Vce::Nn => residuals(e, Vce::Nn, None, None, self.opts.nnmatch, d),
822            v => {
823                let fitted = r.dot(beta);
824                let hii = matches!(v, Vce::Hc2 | Vce::Hc3).then(|| leverages(r, inv_g, rw));
825                residuals(e, v, Some(&fitted), hii.as_ref(), self.opts.nnmatch, d)
826            }
827        }
828    }
829
830    /// Pilot fit of `rdrobust_bw` (`_rdrobust_bw_Vfit`): `(V_V, BConst, s)`.
831    fn vfit(&self, side: &Side, o: usize, nu: usize, h_v: f64) -> Option<(f64, f64, Vec<f64>)> {
832        let (c, kernel) = (self.c, self.opts.kernel);
833        let keep: Vec<bool> = side
834            .x
835            .iter()
836            .map(|&x| kernel.weight(x, c, h_v) > 0.0)
837            .collect();
838        let e = side.select(&keep);
839        if n_unique(&e.x) < o + 1 {
840            return None;
841        }
842        let ew: Vec<f64> = e.x.iter().map(|&x| kernel.weight(x, c, h_v)).collect();
843        let r = vander(&e.x.iter().map(|x| x - c).collect::<Vec<_>>(), o);
844        let rws = scale_rows(&r, &ew.iter().map(|w| w.sqrt()).collect::<Vec<_>>());
845        let inv_g = spd_inverse(&crossprod(&rws, &rws)).ok()?;
846        let rw = scale_rows(&r, &ew);
847        let beta = inv_g.dot(&crossprod(&rw, &e.d()));
848        let fact = factorial(nu);
849        let dt = e.d_t();
850        let s = match (e.z.is_some(), e.t.is_some()) {
851            (false, false) => vec![1.0],
852            (false, true) => {
853                let (tau_y, tau_t) = (fact * beta[[nu, 0]], fact * beta[[nu, 1]]);
854                vec![1.0 / tau_t, -(tau_y / (tau_t * tau_t))]
855            }
856            (true, has_t) => {
857                let gamma = covariate_gamma(&rw, &inv_g, &e, &ew);
858                let dz = e.d_z();
859                let s_y: Vec<f64> = std::iter::once(1.0)
860                    .chain(gamma.column(0).iter().map(|g| -g))
861                    .collect();
862                if has_t {
863                    let s_t: Vec<f64> = std::iter::once(1.0)
864                        .chain(gamma.column(1).iter().map(|g| -g))
865                        .collect();
866                    let pick = |col: usize| -> Vec<f64> {
867                        std::iter::once(beta[[nu, col]])
868                            .chain((0..dz).map(|j| beta[[nu, 1 + dt + j]]))
869                            .collect()
870                    };
871                    let dot =
872                        |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>();
873                    let tau_y = fact * dot(&s_y, &pick(0));
874                    let tau_t = fact * dot(&s_t, &pick(1));
875                    let mut s = vec![1.0 / tau_t, -(tau_y / (tau_t * tau_t))];
876                    s.extend((0..dz).map(|j| {
877                        -(1.0 / tau_t) * gamma[[j, 0]] + (tau_y / (tau_t * tau_t)) * gamma[[j, 1]]
878                    }));
879                    s
880                } else {
881                    s_y
882                }
883            }
884        };
885        let res = self.pilot_residuals(&e, &r, &inv_g, &rw, &beta, o + 1);
886        let meat = vce_meat(&s, &rw, &res, e.cl.as_deref(), rw.ncols());
887        let v_v = inv_g.dot(&meat).dot(&inv_g)[[nu, nu]];
888        let pw: Array1<f64> = e.x.iter().map(|x| powk((x - c) / h_v, o + 1)).collect();
889        let ig_v = inv_g.dot(&rw.t().dot(&pw));
890        // BConst = (h^j · (G⁻¹v)_j)[nu]
891        let b_const = powk(h_v, nu) * ig_v[nu];
892        Some((v_v, b_const, s))
893    }
894
895    /// `rdrobust_bw`.
896    #[allow(clippy::too_many_arguments)]
897    fn bw(
898        &self,
899        side: &Side,
900        o: usize,
901        nu: usize,
902        o_b: usize,
903        h_v: f64,
904        h_b: f64,
905        scale: f64,
906    ) -> BwParts {
907        let (c, kernel) = (self.c, self.opts.kernel);
908        let nan = (f64::NAN, f64::NAN, f64::NAN, 1.0 / (2 * o + 3) as f64);
909        let Some((v_v, b_const, s)) = self.vfit(side, o, nu, h_v) else {
910            return nan;
911        };
912        if !v_v.is_finite() || !(h_b.is_finite() && h_b > 0.0) {
913            return nan;
914        }
915        let keep: Vec<bool> = side
916            .x
917            .iter()
918            .map(|&x| kernel.weight(x, c, h_b) > 0.0)
919            .collect();
920        let e = side.select(&keep);
921        if n_unique(&e.x) < o_b + 1 {
922            return nan;
923        }
924        let ew: Vec<f64> = e.x.iter().map(|&x| kernel.weight(x, c, h_b)).collect();
925        let r = vander(&e.x.iter().map(|x| x - c).collect::<Vec<_>>(), o_b);
926        let rws = scale_rows(&r, &ew.iter().map(|w| w.sqrt()).collect::<Vec<_>>());
927        let Ok(inv_g) = spd_inverse(&crossprod(&rws, &rws)) else {
928            return nan;
929        };
930        let rw = scale_rows(&r, &ew);
931        let beta = inv_g.dot(&crossprod(&rw, &e.d()));
932        let mut bw_reg = 0.0;
933        if scale > 0.0 {
934            let res = self.pilot_residuals(&e, &r, &inv_g, &rw, &beta, o_b + 1);
935            let meat = vce_meat(&s, &rw, &res, e.cl.as_deref(), rw.ncols());
936            let v_b = inv_g.dot(&meat).dot(&inv_g)[[o_b, o_b]];
937            bw_reg = 3.0 * b_const * b_const * v_b;
938        }
939        let last = beta.row(o_b);
940        let s_beta: f64 = s.iter().zip(last.iter()).map(|(a, b)| a * b).sum();
941        let b = (2.0 * (o + 1 - nu) as f64).sqrt() * b_const * s_beta;
942        let v = (2 * nu + 1) as f64 * h_v.powf((2 * nu + 1) as f64) * v_v;
943        let r = scale * (2 * (o + 1 - nu)) as f64 * bw_reg;
944        (v, b, r, 1.0 / (2 * o + 3) as f64)
945    }
946}
947
948/// The inline bandwidth selection of `rdrobust` (`_rdbwselect_compute`).
949fn select_bandwidths(left: &Side, right: &Side, c: f64, opts: &RdOptions) -> Result<RdBandwidths> {
950    let (p, q) = (opts.p, opts.q());
951    let x_all: Vec<f64> = left.x.iter().chain(&right.x).copied().collect();
952    let y_all: Vec<f64> = left.y.iter().chain(&right.y).copied().collect();
953    let n = x_all.len();
954    let x_iq = quantile_type2(&x_all, 0.75) - quantile_type2(&x_all, 0.25);
955    let (x_sd, y_sd, bwp) = if opts.stdvars {
956        let x_sd = std_ddof1(&x_all);
957        (x_sd, std_ddof1(&y_all), 1.0_f64.min((x_iq / x_sd) / 1.349))
958    } else {
959        (1.0, 1.0, std_ddof1(&x_all).min(x_iq / 1.349))
960    };
961    // A first stage without variation on a side falls back to a sharp bandwidth.
962    let no_variation = |t: &Option<Vec<f64>>| t.as_ref().is_some_and(|t| var(t) == 0.0);
963    let sharp_bw = opts.sharpbw || no_variation(&left.t) || no_variation(&right.t);
964    let standardise = |s: &Side| -> Side {
965        Side::new(
966            s.x.iter().map(|x| x / x_sd).collect(),
967            s.y.iter().map(|y| y / y_sd).collect(),
968            if sharp_bw { None } else { s.t.clone() },
969            s.z.clone(),
970            s.cl.clone(),
971        )
972    };
973    let (l, r) = (standardise(left), standardise(right));
974    let c = c / x_sd;
975    let (x_min, x_max) = (l.x[0], r.x[r.n() - 1]);
976    let range_l = (c - l.x[0]).abs();
977    let range_r = (c - r.x[r.n() - 1]).abs();
978
979    let mut uniq_l = l.x.clone();
980    uniq_l.dedup();
981    uniq_l.reverse();
982    let mut uniq_r = r.x.clone();
983    uniq_r.dedup();
984    let inspect = opts.masspoints != MassPoints::Off;
985    let (m_l, m_r) = if inspect || opts.bwcheck.is_some() {
986        (uniq_l.len(), uniq_r.len())
987    } else {
988        (l.n(), r.n())
989    };
990    let mut bwcheck = opts.bwcheck;
991    if inspect {
992        let mass = |m: usize, n: usize| 1.0 - m as f64 / n as f64;
993        let detected = mass(m_l, l.n()) >= 0.2 || mass(m_r, r.n()) >= 0.2;
994        if detected && bwcheck.is_none() && opts.masspoints == MassPoints::Adjust {
995            bwcheck = Some(10);
996        }
997    }
998    let m_total = if inspect { m_l + m_r } else { n };
999
1000    let c_c = opts.kernel.c_c();
1001    let mut c_bw = c_c * bwp * (n as f64).powf(-1.0 / 5.0);
1002    if opts.masspoints == MassPoints::Adjust {
1003        c_bw = c_c * bwp * (m_total as f64).powf(-1.0 / 5.0);
1004    }
1005    let (bw_max_l, bw_max_r) = ((c - x_min).abs(), (c - x_max).abs());
1006    let bw_max = bw_max_l.max(bw_max_r);
1007    if opts.bwrestrict {
1008        c_bw = c_bw.min(bw_max);
1009    }
1010    let pad = 1.0 + f64::EPSILON.sqrt();
1011    let bw_min = bwcheck.map(|k| {
1012        (
1013            (uniq_l[k.min(m_l) - 1] - c).abs() * pad,
1014            (uniq_r[k.min(m_r) - 1] - c).abs() * pad,
1015        )
1016    });
1017    if let Some((lo_l, lo_r)) = bw_min {
1018        c_bw = c_bw.max(lo_l).max(lo_r);
1019    }
1020    let restrict = |v: f64, max: f64| if opts.bwrestrict { v.min(max) } else { v };
1021    let floor = |v: f64| match bw_min {
1022        Some((lo_l, lo_r)) => v.max(lo_l).max(lo_r),
1023        None => v,
1024    };
1025
1026    let ctx = BwCtx {
1027        opts,
1028        vce: effective_vce(opts.vce, left.cl.is_some())?,
1029        c,
1030    };
1031    let reg = opts.scaleregul;
1032    let c_d_l = ctx.bw(&l, q + 1, q + 1, q + 2, c_bw, range_l * pad, 0.0);
1033    let c_d_r = ctx.bw(&r, q + 1, q + 1, q + 2, c_bw, range_r * pad, 0.0);
1034
1035    let (mut two, mut sum, mut rd) = (None, None, None);
1036    let sel = opts.bwselect;
1037    if sel.needs_two() {
1038        let side = |s: &Side, cd: BwParts, max: f64, lo: Option<f64>| -> (f64, f64) {
1039            let mut d_bw = restrict((cd.0 / (cd.1 * cd.1)).powf(cd.3), max);
1040            if let Some(lo) = lo {
1041                d_bw = d_bw.max(lo);
1042            }
1043            let cb = ctx.bw(s, q, p + 1, q + 1, c_bw, d_bw, reg);
1044            let b_bw = restrict((cb.0 / (cb.1 * cb.1 + reg * cb.2)).powf(cb.3), max);
1045            let ch = ctx.bw(s, p, 0, q, c_bw, b_bw, reg);
1046            let h_bw = restrict((ch.0 / (ch.1 * ch.1 + reg * ch.2)).powf(ch.3), max);
1047            (x_sd * h_bw, x_sd * b_bw)
1048        };
1049        let (h_l, b_l) = side(&l, c_d_l, bw_max_l, bw_min.map(|m| m.0));
1050        let (h_r, b_r) = side(&r, c_d_r, bw_max_r, bw_min.map(|m| m.1));
1051        two = Some(RdBandwidths {
1052            h: (h_l, h_r),
1053            b: (b_l, b_r),
1054        });
1055    }
1056    // Common bandwidths for the difference (`sign = −1`) or the sum (`+1`)
1057    // of the two sides' estimates.
1058    let common = |sign: f64| -> (f64, f64) {
1059        let d_bw = floor(restrict(
1060            ((c_d_l.0 + c_d_r.0) / (c_d_r.1 + sign * c_d_l.1).powi(2)).powf(c_d_l.3),
1061            bw_max,
1062        ));
1063        let cb_l = ctx.bw(&l, q, p + 1, q + 1, c_bw, d_bw, reg);
1064        let cb_r = ctx.bw(&r, q, p + 1, q + 1, c_bw, d_bw, reg);
1065        let b_bw = restrict(
1066            ((cb_l.0 + cb_r.0) / ((cb_r.1 + sign * cb_l.1).powi(2) + reg * (cb_r.2 + cb_l.2)))
1067                .powf(cb_l.3),
1068            bw_max,
1069        );
1070        let ch_l = ctx.bw(&l, p, 0, q, c_bw, b_bw, reg);
1071        let ch_r = ctx.bw(&r, p, 0, q, c_bw, b_bw, reg);
1072        let h_bw = restrict(
1073            ((ch_l.0 + ch_r.0) / ((ch_r.1 + sign * ch_l.1).powi(2) + reg * (ch_r.2 + ch_l.2)))
1074                .powf(ch_l.3),
1075            bw_max,
1076        );
1077        (x_sd * h_bw, x_sd * b_bw)
1078    };
1079    if sel.needs_sum() {
1080        sum = Some(common(1.0));
1081    }
1082    if sel.needs_rd() {
1083        rd = Some(common(-1.0));
1084    }
1085    // np.min / np.median propagate NaN.
1086    let min_nan = |a: f64, b: f64| {
1087        if a.is_nan() || b.is_nan() {
1088            f64::NAN
1089        } else {
1090            a.min(b)
1091        }
1092    };
1093    let median3 = |a: f64, b: f64, c: f64| {
1094        if a.is_nan() || b.is_nan() || c.is_nan() {
1095            return f64::NAN;
1096        }
1097        let mut v = [a, b, c];
1098        v.sort_by(f64::total_cmp);
1099        v[1]
1100    };
1101    let mse = match sel {
1102        BwSelect::Mserd | BwSelect::Cerrd => {
1103            let (h, b) = rd.expect("computed");
1104            RdBandwidths::common(h, b)
1105        }
1106        BwSelect::Msetwo | BwSelect::Certwo => two.expect("computed"),
1107        BwSelect::Msesum | BwSelect::Cersum => {
1108            let (h, b) = sum.expect("computed");
1109            RdBandwidths::common(h, b)
1110        }
1111        BwSelect::Msecomb1 | BwSelect::Cercomb1 => {
1112            let ((h_d, b_d), (h_s, b_s)) = (rd.expect("computed"), sum.expect("computed"));
1113            RdBandwidths::common(min_nan(h_d, h_s), min_nan(b_d, b_s))
1114        }
1115        BwSelect::Msecomb2 | BwSelect::Cercomb2 => {
1116            let ((h_d, b_d), (h_s, b_s)) = (rd.expect("computed"), sum.expect("computed"));
1117            let t = two.expect("computed");
1118            RdBandwidths {
1119                h: (median3(h_d, h_s, t.h.0), median3(h_d, h_s, t.h.1)),
1120                b: (median3(b_d, b_s, t.b.0), median3(b_d, b_s, t.b.1)),
1121            }
1122        }
1123    };
1124    let bws = if sel.is_cer() {
1125        let base = match (left.cl.as_ref(), right.cl.as_ref()) {
1126            (Some(a), Some(b)) => (n_unique_i64(a) + n_unique_i64(b)) as f64,
1127            _ => n as f64,
1128        };
1129        let pf = p as f64;
1130        let cer_h = base.powf(-(pf / ((3.0 + pf) * (3.0 + 2.0 * pf))));
1131        RdBandwidths {
1132            h: (mse.h.0 * cer_h, mse.h.1 * cer_h),
1133            b: mse.b,
1134        }
1135    } else {
1136        mse
1137    };
1138    let all = [bws.h.0, bws.h.1, bws.b.0, bws.b.1];
1139    if !all.iter().all(|v| v.is_finite() && *v > 0.0) {
1140        return Err(DmlError::InvalidArgument(
1141            "not enough variability in the running variable to compute the bandwidth".into(),
1142        ));
1143    }
1144    Ok(match opts.rho {
1145        Some(rho) => RdBandwidths {
1146            h: bws.h,
1147            b: (bws.h.0 / rho, bws.h.1 / rho),
1148        },
1149        None => bws,
1150    })
1151}
1152
1153fn n_unique_i64(v: &[i64]) -> usize {
1154    let mut v = v.to_vec();
1155    v.sort_unstable();
1156    v.dedup();
1157    v.len()
1158}
1159
1160/// The variance estimator actually used: with clusters `rdrobust` switches
1161/// `nn`, `hc0` and `hc1` to the cluster-robust `cr1` (internally `hc1`).
1162fn effective_vce(vce: Vce, clustered: bool) -> Result<Vce> {
1163    match (vce, clustered) {
1164        (Vce::Nn | Vce::Hc0 | Vce::Hc1, true) => Ok(Vce::Hc1),
1165        (Vce::Hc2 | Vce::Hc3, true) => Err(DmlError::InvalidArgument(
1166            "rdrobust: the leverage-adjusted cluster-robust variances (cr2, cr3) are not available; use Vce::Hc1 (cr1) with clusters".into(),
1167        )),
1168        (v, false) => Ok(v),
1169    }
1170}
1171
1172fn check_support(left: &Side, right: &Side, order: usize) -> Result<()> {
1173    if n_unique(&left.x).min(n_unique(&right.x)) < order + 1 {
1174        return Err(DmlError::InvalidArgument(format!(
1175            "not enough distinct running-variable values on each side of the cutoff for a polynomial of order {order}"
1176        )));
1177    }
1178    Ok(())
1179}
1180
1181/// Bandwidths of `rdrobust`'s `rdbwselect` for the RD effect at the cutoff,
1182/// with the selector and options in `opts`.
1183pub fn rd_bandwidths(data: &RdData<'_>, opts: &RdOptions) -> Result<RdBandwidths> {
1184    opts.validate()?;
1185    let (left, right) = prepare(data, opts)?;
1186    check_support(&left, &right, opts.p)?;
1187    select_bandwidths(&left, &right, data.cutoff, opts)
1188}
1189
1190/// The MSE-optimal common bandwidths `(h, b)` of `rdrobust`'s `rdbwselect`
1191/// (default options) for the RD effect at cutoff `c`; pass the treatment as
1192/// `fuzzy` for a fuzzy design.
1193pub fn rdbwselect<'a>(
1194    y: ArrayView1<'a, f64>,
1195    x: ArrayView1<'a, f64>,
1196    fuzzy: Option<ArrayView1<'a, f64>>,
1197    c: f64,
1198) -> Result<(f64, f64)> {
1199    let mut data = RdData::new(y, x).cutoff(c);
1200    data.fuzzy = fuzzy;
1201    let bws = rd_bandwidths(&data, &RdOptions::default())?;
1202    Ok((bws.h.0, bws.b.0))
1203}
1204
1205// ---------------------------------------------------------------------------
1206// Estimation (rdrobust)
1207// ---------------------------------------------------------------------------
1208
1209/// One side's local polynomial fits at bandwidths `h` (order `p`) and `b`
1210/// (order `q`), and the residuals for the variance.
1211struct SideFit {
1212    beta_p: Array2<f64>,
1213    beta_bc: Array2<f64>,
1214    inv_g_p: Array2<f64>,
1215    inv_g_q: Array2<f64>,
1216    rw_p: Array2<f64>,
1217    rw_q_h: Array2<f64>,
1218    q_q: Array2<f64>,
1219    res_h: Array2<f64>,
1220    res_b: Array2<f64>,
1221    /// `Σ ZW D` and `Σ U` terms for covariate adjustment.
1222    zwd: Option<Array2<f64>>,
1223    u_p: Option<Array2<f64>>,
1224    cl: Option<Vec<i64>>,
1225    n_h: usize,
1226}
1227
1228fn fit_side(side: &Side, c: f64, h: f64, b: f64, opts: &RdOptions, vce: Vce) -> Result<SideFit> {
1229    let (p, q, kernel) = (opts.p, opts.q(), opts.kernel);
1230    let w_h: Vec<f64> = side.x.iter().map(|&x| kernel.weight(x, c, h)).collect();
1231    let w_b: Vec<f64> = side.x.iter().map(|&x| kernel.weight(x, c, b)).collect();
1232    let in_h: Vec<bool> = w_h.iter().map(|&w| w > 0.0).collect();
1233    let in_b: Vec<bool> = w_b.iter().map(|&w| w > 0.0).collect();
1234    let n_h = in_h.iter().filter(|&&k| k).count();
1235    let uniq_in = |keep: &[bool]| {
1236        n_unique(
1237            &side
1238                .x
1239                .iter()
1240                .zip(keep)
1241                .filter(|(_, &k)| k)
1242                .map(|(x, _)| *x)
1243                .collect::<Vec<_>>(),
1244        )
1245    };
1246    if uniq_in(&in_h) < p + 1 || uniq_in(&in_b) < q + 1 {
1247        return Err(DmlError::InvalidArgument(
1248            "not enough distinct running-variable values with positive weight on each side of the cutoff"
1249                .into(),
1250        ));
1251    }
1252    let keep = if h > b { &in_h } else { &in_b };
1253    let pick = |v: &[f64]| -> Vec<f64> {
1254        v.iter()
1255            .zip(keep.iter())
1256            .filter(|(_, &k)| k)
1257            .map(|(a, _)| *a)
1258            .collect()
1259    };
1260    let (ww_h, ww_b) = (pick(&w_h), pick(&w_b));
1261    // Nearest neighbours are counted within the estimation window.
1262    let e = side.select(keep);
1263    let u: Vec<f64> = e.x.iter().map(|x| (x - c) / h).collect();
1264    let r_q = vander(&e.x.iter().map(|x| x - c).collect::<Vec<_>>(), q);
1265    let r_p = r_q.slice(s![.., ..=p]).to_owned();
1266    let rw_p = scale_rows(&r_p, &ww_h);
1267    let rw_q_b = scale_rows(&r_q, &ww_b);
1268    let l: Array1<f64> = rw_p
1269        .t()
1270        .dot(&Array1::from_iter(u.iter().map(|&v| powk(v, p + 1))));
1271    let sq = |w: &[f64]| w.iter().map(|v| v.sqrt()).collect::<Vec<_>>();
1272    let inv_g_q = {
1273        let m = scale_rows(&r_q, &sq(&ww_b));
1274        spd_inverse(&crossprod(&m, &m))?
1275    };
1276    let inv_g_p = {
1277        let m = scale_rows(&r_p, &sq(&ww_h));
1278        spd_inverse(&crossprod(&m, &m))?
1279    };
1280    let m: Vec<f64> = r_q
1281        .dot(&inv_g_q.row(p + 1))
1282        .iter()
1283        .zip(&ww_b)
1284        .map(|(a, w)| a * w)
1285        .collect();
1286    let hp = powk(h, p + 1);
1287    let mut q_q = rw_p.clone();
1288    for i in 0..q_q.nrows() {
1289        for j in 0..q_q.ncols() {
1290            q_q[[i, j]] -= hp * m[i] * l[j];
1291        }
1292    }
1293    let d = e.d();
1294    let beta_p = inv_g_p.dot(&crossprod(&rw_p, &d));
1295    let beta_q = inv_g_q.dot(&crossprod(&rw_q_b, &d));
1296    let beta_bc = inv_g_p.dot(&crossprod(&q_q, &d));
1297    let (res_h, res_b) = if vce == Vce::Nn {
1298        let res = residuals(&e, Vce::Nn, None, None, opts.nnmatch, p + 1);
1299        (res.clone(), res)
1300    } else {
1301        let (fit_p, fit_q) = (r_p.dot(&beta_p), r_q.dot(&beta_q));
1302        let (hii_p, hii_q) = if matches!(vce, Vce::Hc2 | Vce::Hc3) {
1303            (
1304                Some(leverages(&r_p, &inv_g_p, &rw_p)),
1305                Some(leverages(&r_q, &inv_g_q, &rw_q_b)),
1306            )
1307        } else {
1308            (None, None)
1309        };
1310        (
1311            residuals(&e, vce, Some(&fit_p), hii_p.as_ref(), opts.nnmatch, p + 1),
1312            residuals(&e, vce, Some(&fit_q), hii_q.as_ref(), opts.nnmatch, q + 1),
1313        )
1314    };
1315    let (zwd, u_p) = match &e.z {
1316        Some(z) => (
1317            Some(crossprod(&scale_rows(z, &ww_h), &d)),
1318            Some(crossprod(&rw_p, &d)),
1319        ),
1320        None => (None, None),
1321    };
1322    Ok(SideFit {
1323        rw_q_h: scale_rows(&r_q, &ww_h),
1324        beta_p,
1325        beta_bc,
1326        inv_g_p,
1327        inv_g_q,
1328        rw_p,
1329        q_q,
1330        res_h,
1331        res_b,
1332        zwd,
1333        u_p,
1334        cl: e.cl,
1335        n_h,
1336    })
1337}
1338
1339/// Local polynomial RD estimate at the cutoff with its bias correction, as
1340/// `rdrobust` with the options in `opts`. `bandwidths` fixes `h` and `b`
1341/// (with `opts.rho`, `b = h / rho`); `None` selects them with
1342/// [`rd_bandwidths`]. A fuzzy design estimates the ratio of the outcome and
1343/// treatment jumps; covariates adjust both.
1344pub fn rd_estimate(
1345    data: &RdData<'_>,
1346    opts: &RdOptions,
1347    bandwidths: Option<RdBandwidths>,
1348) -> Result<RdEstimate> {
1349    opts.validate()?;
1350    let q = opts.q();
1351    let c = data.cutoff;
1352    let (left, right) = prepare(data, opts)?;
1353    check_support(&left, &right, q)?;
1354    if let (Some(lt), Some(rt)) = (&left.t, &right.t) {
1355        if var(lt) == 0.0
1356            && var(rt) == 0.0
1357            && (np_mean(lt) - np_mean(rt)).abs() < f64::EPSILON.sqrt()
1358        {
1359            return Err(DmlError::InvalidArgument(
1360                "fuzzy RD: the treatment has no variation and no jump at the cutoff".into(),
1361            ));
1362        }
1363    }
1364    let vce = effective_vce(opts.vce, data.cluster.is_some())?;
1365    let n = left.n() + right.n();
1366    let bws = match bandwidths {
1367        Some(bws) => {
1368            let all = [bws.h.0, bws.h.1, bws.b.0, bws.b.1];
1369            if !all.iter().all(|v| v.is_finite() && *v > 0.0) {
1370                return Err(DmlError::InvalidArgument(
1371                    "bandwidths must be positive and finite".into(),
1372                ));
1373            }
1374            match opts.rho {
1375                Some(rho) => RdBandwidths {
1376                    h: bws.h,
1377                    b: (bws.h.0 / rho, bws.h.1 / rho),
1378                },
1379                None => bws,
1380            }
1381        }
1382        None if n < 20 => {
1383            let h = (c - left.x[0])
1384                .abs()
1385                .max((right.x[right.n() - 1] - c).abs());
1386            RdBandwidths::common(h, opts.rho.map_or(h, |rho| h / rho))
1387        }
1388        None => select_bandwidths(&left, &right, c, opts)?,
1389    };
1390    let fl = fit_side(&left, c, bws.h.0, bws.b.0, opts, vce)?;
1391    let fr = fit_side(&right, c, bws.h.1, bws.b.1, opts, vce)?;
1392
1393    let fuzzy = data.fuzzy.is_some();
1394    let dt = usize::from(fuzzy);
1395    let dz = left.d_z();
1396    let sp = opts.scalepar;
1397    let beta_p = &fr.beta_p - &fl.beta_p;
1398    let beta_bc = &fr.beta_bc - &fl.beta_bc;
1399    let (tau_cl, tau_bc, s_y) = if dz == 0 {
1400        let (tau_y_cl, tau_y_bc) = (sp * beta_p[[0, 0]], sp * beta_bc[[0, 0]]);
1401        if fuzzy {
1402            let (tau_t_cl, tau_t_bc) = (beta_p[[0, 1]], beta_bc[[0, 1]]);
1403            let tau_cl = tau_y_cl / tau_t_cl;
1404            let s = vec![1.0 / tau_t_cl, -(tau_y_cl / (tau_t_cl * tau_t_cl))];
1405            let tau_bc = tau_cl - (s[0] * (tau_y_cl - tau_y_bc) + s[1] * (tau_t_cl - tau_t_bc));
1406            (tau_cl, tau_bc, s)
1407        } else {
1408            (tau_y_cl, tau_y_bc, vec![1.0])
1409        }
1410    } else {
1411        // Partial the covariates out of the pooled local fits (pinv, as
1412        // rdrobust with covs_drop).
1413        let cols_z = s![.., 1 + dt..1 + dt + dz];
1414        let side_terms = |f: &SideFit| -> (Array2<f64>, Array2<f64>) {
1415            let (zwd, u) = (f.zwd.as_ref().expect("covs"), f.u_p.as_ref().expect("covs"));
1416            let uigu = u.slice(cols_z).t().dot(&f.inv_g_p.dot(u));
1417            (
1418                &zwd.slice(cols_z) - &uigu.slice(cols_z),
1419                &zwd.slice(s![.., ..1 + dt]) - &uigu.slice(s![.., ..1 + dt]),
1420            )
1421        };
1422        let ((zwz_l, zwy_l), (zwz_r, zwy_r)) = (side_terms(&fl), side_terms(&fr));
1423        let gamma = sym_pinv(&(&zwz_r + &zwz_l)).dot(&(&zwy_r + &zwy_l));
1424        let s_y: Vec<f64> = std::iter::once(1.0)
1425            .chain(gamma.column(0).iter().map(|g| -g))
1426            .collect();
1427        let dot = |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>();
1428        let row = |beta: &Array2<f64>, col: usize| -> Vec<f64> {
1429            std::iter::once(beta[[0, col]])
1430                .chain((0..dz).map(|j| beta[[0, 1 + dt + j]]))
1431                .collect()
1432        };
1433        if fuzzy {
1434            let s_t: Vec<f64> = std::iter::once(1.0)
1435                .chain(gamma.column(1).iter().map(|g| -g))
1436                .collect();
1437            let tau_y_cl = sp * dot(&s_y, &row(&beta_p, 0));
1438            let tau_t_cl = dot(&s_t, &row(&beta_p, 1));
1439            let tau_y_bc = sp * dot(&s_y, &row(&beta_bc, 0));
1440            let tau_t_bc = dot(&s_t, &row(&beta_bc, 1));
1441            let tau_cl = tau_y_cl / tau_t_cl;
1442            let s2 = [1.0 / tau_t_cl, -(tau_y_cl / (tau_t_cl * tau_t_cl))];
1443            let tau_bc = tau_cl - (s2[0] * (tau_y_cl - tau_y_bc) + s2[1] * (tau_t_cl - tau_t_bc));
1444            let mut s = s2.to_vec();
1445            s.extend((0..dz).map(|j| {
1446                -(1.0 / tau_t_cl) * gamma[[j, 0]]
1447                    + (tau_y_cl / (tau_t_cl * tau_t_cl)) * gamma[[j, 1]]
1448            }));
1449            (tau_cl, tau_bc, s)
1450        } else {
1451            let tau_cl = sp * dot(&s_y, &beta_p.row(0).to_vec());
1452            let tau_bc = sp * dot(&s_y, &beta_bc.row(0).to_vec());
1453            (tau_cl, tau_bc, s_y)
1454        }
1455    };
1456
1457    let hb_match = bws.h == bws.b;
1458    let clustered = data.cluster.is_some();
1459    let variance = |f: &SideFit, robust: bool| -> f64 {
1460        let cl = f.cl.as_deref();
1461        if !robust {
1462            let k = f.rw_p.ncols();
1463            return f
1464                .inv_g_p
1465                .dot(&vce_meat(&s_y, &f.rw_p, &f.res_h, cl, k))
1466                .dot(&f.inv_g_p)[[0, 0]];
1467        }
1468        if hb_match && clustered {
1469            // The q-order regression directly (rdrobust's h = b path).
1470            let k = f.rw_q_h.ncols();
1471            f.inv_g_q
1472                .dot(&vce_meat(&s_y, &f.rw_q_h, &f.res_b, cl, k))
1473                .dot(&f.inv_g_q)[[0, 0]]
1474        } else {
1475            f.inv_g_p
1476                .dot(&vce_meat(&s_y, &f.q_q, &f.res_b, cl, q + 1))
1477                .dot(&f.inv_g_p)[[0, 0]]
1478        }
1479    };
1480    let se_cl = (sp * sp * (variance(&fl, false) + variance(&fr, false))).sqrt();
1481    let se_rb = (sp * sp * (variance(&fl, true) + variance(&fr, true))).sqrt();
1482    let coef = [tau_cl, tau_bc, tau_bc];
1483    let se = [se_cl, se_cl, se_rb];
1484    let quant = -norm_ppf((1.0 - opts.level) / 2.0);
1485    let ci = std::array::from_fn(|k| [coef[k] - quant * se[k], coef[k] + quant * se[k]]);
1486    Ok(RdEstimate {
1487        coef,
1488        se,
1489        ci,
1490        h: bws.h,
1491        b: bws.b,
1492        n_h: (fl.n_h, fr.n_h),
1493        n: (left.n(), right.n()),
1494    })
1495}
1496
1497/// Local linear RD estimate at cutoff `c` with a quadratic bias correction
1498/// and nearest-neighbour standard errors, as `rdrobust` with its default
1499/// options. `bandwidths` gives `(h, b)` (both sides); `None` selects them
1500/// with [`rdbwselect`]. Pass the treatment as `fuzzy` for a fuzzy design (the
1501/// effect is then the ratio of the outcome and treatment jumps).
1502pub fn rdrobust<'a>(
1503    y: ArrayView1<'a, f64>,
1504    x: ArrayView1<'a, f64>,
1505    fuzzy: Option<ArrayView1<'a, f64>>,
1506    c: f64,
1507    bandwidths: Option<(f64, f64)>,
1508) -> Result<RdEstimate> {
1509    let mut data = RdData::new(y, x).cutoff(c);
1510    data.fuzzy = fuzzy;
1511    rd_estimate(
1512        &data,
1513        &RdOptions::default(),
1514        bandwidths.map(|(h, b)| RdBandwidths::common(h, b)),
1515    )
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520    use super::*;
1521    use ndarray::array;
1522
1523    #[test]
1524    fn duplicates_count_runs() {
1525        let (d, id) = duplicates(&[1.0, 2.0, 2.0, 2.0, 3.0]);
1526        assert_eq!(d, vec![1, 3, 3, 3, 1]);
1527        assert_eq!(id, vec![1, 1, 2, 3, 1]);
1528    }
1529
1530    #[test]
1531    fn quantile_type2_matches_r() {
1532        let x = [1.0, 2.0, 3.0, 4.0];
1533        assert_eq!(quantile_type2(&x, 0.25), 1.5);
1534        assert_eq!(quantile_type2(&x, 0.75), 3.5);
1535        assert_eq!(quantile_type2(&[1.0, 2.0, 3.0], 0.25), 1.0);
1536    }
1537
1538    #[test]
1539    fn collinear_covariates_are_dropped() {
1540        let z = array![
1541            [1.0, 2.0, 0.5],
1542            [2.0, 4.0, -1.0],
1543            [3.0, 6.0, 2.0],
1544            [4.0, 8.0, 0.0]
1545        ];
1546        let keep = independent_columns(&z);
1547        assert_eq!(keep.len(), 2);
1548        assert!(keep.contains(&2));
1549        let full = array![[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]];
1550        assert_eq!(independent_columns(&full), vec![0, 1]);
1551    }
1552
1553    #[test]
1554    fn options_are_validated() {
1555        let bad = |o: RdOptions| o.validate().is_err();
1556        assert!(bad(RdOptions {
1557            q: Some(1),
1558            ..RdOptions::default()
1559        }));
1560        assert!(bad(RdOptions {
1561            level: 95.0,
1562            ..RdOptions::default()
1563        }));
1564        assert!(bad(RdOptions {
1565            nnmatch: 0,
1566            ..RdOptions::default()
1567        }));
1568        assert!(bad(RdOptions {
1569            rho: Some(0.0),
1570            ..RdOptions::default()
1571        }));
1572        assert!(RdOptions::default().validate().is_ok());
1573        assert!(effective_vce(Vce::Hc2, true).is_err());
1574        assert_eq!(effective_vce(Vce::Nn, true).unwrap(), Vce::Hc1);
1575    }
1576}