Skip to main content

dml_core/
rd.rs

1//! Local polynomial regression discontinuity (RD) estimation: a port of the
2//! default path of the `rdrobust` package (Calonico, Cattaneo and Titiunik,
3//! 2014; Calonico, Cattaneo and Farrell, 2020), used by
4//! [`RdFlex`](crate::RdFlex).
5//!
6//! - [`rdbwselect`]: the MSE-optimal common bandwidth (`bwselect = "mserd"`)
7//!   for the point estimate (`h`) and the bias correction (`b`).
8//! - [`rdrobust`]: the local linear estimate at the cutoff with a quadratic
9//!   bias correction, nearest-neighbour standard errors and the
10//!   conventional, bias-corrected and robust confidence intervals.
11//!
12//! Defaults as in `rdrobust`: triangular kernel, `p = 1`, `q = 2`,
13//! `vce = "nn"` with 3 neighbours, standardised variables for bandwidth
14//! selection and `masspoints = "adjust"`. Sharp and fuzzy designs are
15//! supported; covariates, clusters and weights are not.
16
17use ndarray::{Array1, Array2, ArrayView1};
18
19use crate::compat::{np_mean, np_sum};
20use crate::error::{DmlError, Result};
21use crate::linalg::spd_inverse;
22use crate::stats::norm_ppf;
23
24const P: usize = 1;
25const Q: usize = 2;
26const NN_MATCHES: usize = 3;
27/// `C_c` of the triangular kernel.
28const C_C: f64 = 2.576;
29
30/// Result of [`rdrobust`]: rows are the conventional, bias-corrected and
31/// robust (bias-corrected with robust standard error) estimates.
32#[derive(Debug, Clone)]
33pub struct RdEstimate {
34    pub coef: [f64; 3],
35    pub se: [f64; 3],
36    /// 95% confidence intervals `[lower, upper]`.
37    pub ci: [[f64; 2]; 3],
38    /// Main bandwidth `(left, right)`.
39    pub h: (f64, f64),
40    /// Bias bandwidth `(left, right)`.
41    pub b: (f64, f64),
42    /// Observations within the main bandwidth `(left, right)`.
43    pub n_h: (usize, usize),
44    /// Observations on each side of the cutoff.
45    pub n: (usize, usize),
46}
47
48/// One side of the cutoff, sorted by the running variable.
49struct Side {
50    x: Vec<f64>,
51    y: Vec<f64>,
52    t: Option<Vec<f64>>,
53    dups: Vec<usize>,
54    dupsid: Vec<usize>,
55}
56
57impl Side {
58    fn new(x: Vec<f64>, y: Vec<f64>, t: Option<Vec<f64>>) -> Self {
59        let (dups, dupsid) = duplicates(&x);
60        Self {
61            x,
62            y,
63            t,
64            dups,
65            dupsid,
66        }
67    }
68
69    fn n(&self) -> usize {
70        self.x.len()
71    }
72
73    fn select(&self, keep: &[bool]) -> Side {
74        let pick = |v: &[f64]| -> Vec<f64> {
75            v.iter()
76                .zip(keep)
77                .filter(|(_, &k)| k)
78                .map(|(a, _)| *a)
79                .collect()
80        };
81        let pick_n = |v: &[usize]| -> Vec<usize> {
82            v.iter()
83                .zip(keep)
84                .filter(|(_, &k)| k)
85                .map(|(a, _)| *a)
86                .collect()
87        };
88        Side {
89            x: pick(&self.x),
90            y: pick(&self.y),
91            t: self.t.as_ref().map(|t| pick(t)),
92            dups: pick_n(&self.dups),
93            dupsid: pick_n(&self.dupsid),
94        }
95    }
96
97    /// Outcome (and treatment) columns.
98    fn d(&self) -> Array2<f64> {
99        let k = 1 + usize::from(self.t.is_some());
100        let mut d = Array2::zeros((self.n(), k));
101        for i in 0..self.n() {
102            d[[i, 0]] = self.y[i];
103            if let Some(t) = &self.t {
104                d[[i, 1]] = t[i];
105            }
106        }
107        d
108    }
109}
110
111/// Size of each run of equal values and the position within it, as
112/// pandas' `groupby(x).transform("sum" / "cumsum")` on sorted `x`.
113fn duplicates(x: &[f64]) -> (Vec<usize>, Vec<usize>) {
114    let n = x.len();
115    let (mut dups, mut dupsid) = (vec![0; n], vec![0; n]);
116    let mut start = 0;
117    while start < n {
118        let mut end = start + 1;
119        while end < n && x[end] == x[start] {
120            end += 1;
121        }
122        for (k, i) in (start..end).enumerate() {
123            dups[i] = end - start;
124            dupsid[i] = k + 1;
125        }
126        start = end;
127    }
128    (dups, dupsid)
129}
130
131/// Triangular kernel weight `(1 − |u|)₊ / h` with `u = (x − c) / h`.
132fn kweight(x: f64, c: f64, h: f64) -> f64 {
133    let u = (x - c) / h;
134    ((1.0 - u.abs()) * f64::from(u.abs() <= 1.0)) / h
135}
136
137/// `[1, u, u², …, u^p]` by successive multiplication.
138fn vander(u: &[f64], p: usize) -> Array2<f64> {
139    let mut out = Array2::zeros((u.len(), p + 1));
140    for (i, &v) in u.iter().enumerate() {
141        out[[i, 0]] = 1.0;
142        for j in 1..=p {
143            out[[i, j]] = out[[i, j - 1]] * v;
144        }
145    }
146    out
147}
148
149/// NumPy's `x ** k` for a float base and integer exponent.
150fn powk(x: f64, k: usize) -> f64 {
151    match k {
152        0 => 1.0,
153        1 => x,
154        2 => x * x,
155        _ => x.powf(k as f64),
156    }
157}
158
159fn scale_rows(m: &Array2<f64>, w: &[f64]) -> Array2<f64> {
160    let mut out = m.clone();
161    for (mut row, &wi) in out.rows_mut().into_iter().zip(w) {
162        row *= wi;
163    }
164    out
165}
166
167fn crossprod(a: &Array2<f64>, b: &Array2<f64>) -> Array2<f64> {
168    a.t().dot(b)
169}
170
171fn n_unique(x: &[f64]) -> usize {
172    let mut v = x.to_vec();
173    v.sort_by(f64::total_cmp);
174    v.dedup();
175    v.len()
176}
177
178/// Nearest-neighbour residuals (`vce = "nn"`): each observation minus the
179/// mean of its (at least) `NN_MATCHES` nearest neighbours, scaled by
180/// `√(J / (J + 1))`. `x` is sorted.
181fn nn_residuals(s: &Side) -> Array2<f64> {
182    let n = s.n();
183    let k = 1 + usize::from(s.t.is_some());
184    let mut res = Array2::zeros((n, k));
185    let tol_eps = f64::EPSILON.sqrt();
186    let (x, dups, dupsid) = (&s.x, &s.dups, &s.dupsid);
187    for pos in 0..n {
188        let p = pos as i64;
189        let mut rpos = (dups[pos] - dupsid[pos]) as i64;
190        let mut lpos = dupsid[pos] as i64 - 1;
191        let cap = NN_MATCHES.min(n.saturating_sub(1)) as i64;
192        while lpos + rpos < cap {
193            if p - lpos - 1 < 0 {
194                rpos += dups[(p + rpos + 1) as usize] as i64;
195            } else if p + rpos + 1 >= n as i64 {
196                lpos += dups[(p - lpos - 1) as usize] as i64;
197            } else {
198                let dleft = x[pos] - x[(p - lpos - 1) as usize];
199                let dright = x[(p + rpos + 1) as usize] - x[pos];
200                let nn_tol = dleft.max(dright) * tol_eps;
201                if dleft - dright > nn_tol {
202                    rpos += dups[(p + rpos + 1) as usize] as i64;
203                } else if dright - dleft > nn_tol {
204                    lpos += dups[(p - lpos - 1) as usize] as i64;
205                } else {
206                    rpos += dups[(p + rpos + 1) as usize] as i64;
207                    lpos += dups[(p - lpos - 1) as usize] as i64;
208                }
209            }
210        }
211        let (lo, hi) = ((p - lpos) as usize, (p + rpos + 1) as usize);
212        let ji = (hi - lo - 1) as f64;
213        let sf = (ji / (ji + 1.0)).powf(0.5);
214        let resid = |v: &[f64]| -> f64 {
215            let mut sum = 0.0;
216            for &vj in &v[lo..hi] {
217                sum += vj;
218            }
219            sum -= v[pos];
220            sf * (v[pos] - sum / ji)
221        };
222        res[[pos, 0]] = resid(&s.y);
223        if let Some(t) = &s.t {
224            res[[pos, 1]] = resid(t);
225        }
226    }
227    res
228}
229
230/// Meat of the sandwich: `Σᵢ (sᵀrᵢ)² RXᵢ RXᵢᵀ`.
231fn vce(s: &[f64], rx: &Array2<f64>, res: &Array2<f64>) -> Array2<f64> {
232    let r: Vec<f64> = if res.ncols() == 1 {
233        res.column(0).to_vec()
234    } else {
235        res.rows()
236            .into_iter()
237            .map(|row| row.dot(&ArrayView1::from(s)))
238            .collect()
239    };
240    let scaled = scale_rows(rx, &r);
241    crossprod(&scaled, &scaled)
242}
243
244/// NumPy's `std(ddof=1)`.
245fn std_ddof1(v: &[f64]) -> f64 {
246    let mean = np_sum(v) / v.len() as f64;
247    let sq: Vec<f64> = v.iter().map(|x| (x - mean) * (x - mean)).collect();
248    (np_sum(&sq) / (v.len() - 1) as f64).sqrt()
249}
250
251/// R's `quantile(type = 2)` of sorted `x`.
252fn quantile_type2(x: &[f64], prob: f64) -> f64 {
253    let n = x.len();
254    let h = n as f64 * prob;
255    let j = h.floor() as usize;
256    if h == j as f64 {
257        0.5 * (x[j - 1] + x[j])
258    } else {
259        x[j]
260    }
261}
262
263/// Sort by the running variable (stably) and split at `c`.
264fn split(
265    y: ArrayView1<'_, f64>,
266    x: ArrayView1<'_, f64>,
267    t: Option<ArrayView1<'_, f64>>,
268    c: f64,
269) -> Result<(Side, Side)> {
270    let n = x.len();
271    if y.len() != n || t.is_some_and(|t| t.len() != n) {
272        return Err(DmlError::DimensionMismatch(
273            "rdrobust: y, x and fuzzy need equal lengths".into(),
274        ));
275    }
276    if !x.iter().chain(y.iter()).all(|v| v.is_finite()) {
277        return Err(DmlError::InvalidArgument(
278            "rdrobust: data contain NaN or infinite values".into(),
279        ));
280    }
281    let mut order: Vec<usize> = (0..n).collect();
282    order.sort_by(|&a, &b| x[a].total_cmp(&x[b]));
283    let (x_min, x_max) = (x[order[0]], x[order[n - 1]]);
284    if c <= x_min || c >= x_max {
285        return Err(DmlError::InvalidArgument(
286            "the cutoff must lie within the range of the running variable".into(),
287        ));
288    }
289    let side = |left: bool| -> Side {
290        let rows: Vec<usize> = order
291            .iter()
292            .copied()
293            .filter(|&i| (x[i] < c) == left)
294            .collect();
295        Side::new(
296            rows.iter().map(|&i| x[i]).collect(),
297            rows.iter().map(|&i| y[i]).collect(),
298            t.map(|t| rows.iter().map(|&i| t[i]).collect()),
299        )
300    };
301    Ok((side(true), side(false)))
302}
303
304// ---------------------------------------------------------------------------
305// Bandwidth selection (rdbwselect, mserd)
306// ---------------------------------------------------------------------------
307
308/// `(V, B, R, rate)` of one bandwidth step on one side, NaN when a pilot
309/// fit is not identified.
310type BwParts = (f64, f64, f64, f64);
311
312/// Pilot fit of `rdrobust_bw`: `(V_V, BConst, s)`.
313fn bw_vfit(side: &Side, c: f64, o: usize, nu: usize, h_v: f64) -> Option<(f64, f64, Vec<f64>)> {
314    let keep: Vec<bool> = side.x.iter().map(|&x| kweight(x, c, h_v) > 0.0).collect();
315    let e = side.select(&keep);
316    if n_unique(&e.x) < o + 1 {
317        return None;
318    }
319    let ew: Vec<f64> = e.x.iter().map(|&x| kweight(x, c, h_v)).collect();
320    let u: Vec<f64> = e.x.iter().map(|x| x - c).collect();
321    let r = vander(&u, o);
322    let sqrt_w: Vec<f64> = ew.iter().map(|w| w.sqrt()).collect();
323    let rws = scale_rows(&r, &sqrt_w);
324    let inv_g = spd_inverse(&crossprod(&rws, &rws)).ok()?;
325    let rw = scale_rows(&r, &ew);
326    let beta = inv_g.dot(&crossprod(&rw, &e.d()));
327    let fact = (1..=nu).product::<usize>() as f64;
328    let s = if e.t.is_some() {
329        let (tau_y, tau_t) = (fact * beta[[nu, 0]], fact * beta[[nu, 1]]);
330        vec![1.0 / tau_t, -(tau_y / (tau_t * tau_t))]
331    } else {
332        vec![1.0]
333    };
334    let res = nn_residuals(&e);
335    let v_v = inv_g.dot(&vce(&s, &rw, &res)).dot(&inv_g)[[nu, nu]];
336    let pw: Array1<f64> = e.x.iter().map(|x| powk((x - c) / h_v, o + 1)).collect();
337    let v = rw.t().dot(&pw);
338    let ig_v = inv_g.dot(&v);
339    // BConst = (h^j · (G⁻¹v)_j)[nu]
340    let b_const = powk(h_v, nu) * ig_v[nu];
341    Some((v_v, b_const, s))
342}
343
344#[allow(clippy::too_many_arguments)]
345fn rdrobust_bw(
346    side: &Side,
347    c: f64,
348    o: usize,
349    nu: usize,
350    o_b: usize,
351    h_v: f64,
352    h_b: f64,
353    scale: f64,
354) -> BwParts {
355    let nan = (f64::NAN, f64::NAN, f64::NAN, 1.0 / (2 * o + 3) as f64);
356    let Some((v_v, b_const, s)) = bw_vfit(side, c, o, nu, h_v) else {
357        return nan;
358    };
359    if !v_v.is_finite() || !(h_b.is_finite() && h_b > 0.0) {
360        return nan;
361    }
362    let keep: Vec<bool> = side.x.iter().map(|&x| kweight(x, c, h_b) > 0.0).collect();
363    let e = side.select(&keep);
364    if n_unique(&e.x) < o_b + 1 {
365        return nan;
366    }
367    let ew: Vec<f64> = e.x.iter().map(|&x| kweight(x, c, h_b)).collect();
368    let u: Vec<f64> = e.x.iter().map(|x| x - c).collect();
369    let r = vander(&u, o_b);
370    let sqrt_w: Vec<f64> = ew.iter().map(|w| w.sqrt()).collect();
371    let rws = scale_rows(&r, &sqrt_w);
372    let Ok(inv_g) = spd_inverse(&crossprod(&rws, &rws)) else {
373        return nan;
374    };
375    let rw = scale_rows(&r, &ew);
376    let beta = inv_g.dot(&crossprod(&rw, &e.d()));
377    let mut bw_reg = 0.0;
378    if scale > 0.0 {
379        let res = nn_residuals(&e);
380        let v_b = inv_g.dot(&vce(&s, &rw, &res)).dot(&inv_g)[[o_b, o_b]];
381        bw_reg = 3.0 * b_const * b_const * v_b;
382    }
383    let last = beta.row(o_b);
384    let s_beta: f64 = s.iter().zip(last.iter()).map(|(a, b)| a * b).sum();
385    let b = (2.0 * (o + 1 - nu) as f64).sqrt() * b_const * s_beta;
386    let v = (2 * nu + 1) as f64 * h_v.powf((2 * nu + 1) as f64) * v_v;
387    let r = scale * (2 * (o + 1 - nu)) as f64 * bw_reg;
388    (v, b, r, 1.0 / (2 * o + 3) as f64)
389}
390
391/// MSE-optimal common bandwidths `(h, b)` on the original scale.
392fn select_bandwidth(left: &Side, right: &Side, c: f64) -> Result<(f64, f64)> {
393    let x_all: Vec<f64> = left.x.iter().chain(&right.x).copied().collect();
394    let y_all: Vec<f64> = left.y.iter().chain(&right.y).copied().collect();
395    let x_iq = quantile_type2(&x_all, 0.75) - quantile_type2(&x_all, 0.25);
396    let (x_sd, y_sd) = (std_ddof1(&x_all), std_ddof1(&y_all));
397    let bwp = 1.0_f64.min((x_iq / x_sd) / 1.349);
398    // A first stage without variation on a side falls back to a sharp bandwidth.
399    let constant = |t: &Option<Vec<f64>>| t.as_ref().is_some_and(|t| t.iter().all(|&v| v == t[0]));
400    let sharp_bw = constant(&left.t) || constant(&right.t);
401    let standardise = |s: &Side| -> Side {
402        Side::new(
403            s.x.iter().map(|x| x / x_sd).collect(),
404            s.y.iter().map(|y| y / y_sd).collect(),
405            if sharp_bw { None } else { s.t.clone() },
406        )
407    };
408    let (l, r) = (standardise(left), standardise(right));
409    let c = c / x_sd;
410    let (x_min, x_max) = (l.x[0], r.x[r.n() - 1]);
411    let range_l = (c - l.x[0]).abs();
412    let range_r = (c - r.x[r.n() - 1]).abs();
413
414    let mut uniq_l = l.x.clone();
415    uniq_l.dedup();
416    uniq_l.reverse();
417    let mut uniq_r = r.x.clone();
418    uniq_r.dedup();
419    let (m_l, m_r) = (uniq_l.len(), uniq_r.len());
420    let mass = |m: usize, n: usize| 1.0 - m as f64 / n as f64;
421    let bwcheck = (mass(m_l, l.n()) >= 0.2 || mass(m_r, r.n()) >= 0.2).then_some(10usize);
422
423    let mut c_bw = C_C * bwp * ((m_l + m_r) as f64).powf(-1.0 / 5.0);
424    let (bw_max_l, bw_max_r) = ((c - x_min).abs(), (c - x_max).abs());
425    let bw_max = bw_max_l.max(bw_max_r);
426    c_bw = c_bw.min(bw_max);
427    let pad = 1.0 + f64::EPSILON.sqrt();
428    let bw_min = bwcheck.map(|k| {
429        (
430            (uniq_l[k.min(m_l) - 1] - c).abs() * pad,
431            (uniq_r[k.min(m_r) - 1] - c).abs() * pad,
432        )
433    });
434    if let Some((lo_l, lo_r)) = bw_min {
435        c_bw = c_bw.max(lo_l).max(lo_r);
436    }
437
438    let c_d_l = rdrobust_bw(&l, c, Q + 1, Q + 1, Q + 2, c_bw, range_l * pad, 0.0);
439    let c_d_r = rdrobust_bw(&r, c, Q + 1, Q + 1, Q + 2, c_bw, range_r * pad, 0.0);
440    let mut d_bw = ((c_d_l.0 + c_d_r.0) / (c_d_r.1 - c_d_l.1).powi(2)).powf(c_d_l.3);
441    d_bw = d_bw.min(bw_max);
442    if let Some((lo_l, lo_r)) = bw_min {
443        d_bw = d_bw.max(lo_l).max(lo_r);
444    }
445    let c_b_l = rdrobust_bw(&l, c, Q, P + 1, Q + 1, c_bw, d_bw, 1.0);
446    let c_b_r = rdrobust_bw(&r, c, Q, P + 1, Q + 1, c_bw, d_bw, 1.0);
447    let b_bw = ((c_b_l.0 + c_b_r.0) / ((c_b_r.1 - c_b_l.1).powi(2) + (c_b_r.2 + c_b_l.2)))
448        .powf(c_b_l.3)
449        .min(bw_max);
450    let c_h_l = rdrobust_bw(&l, c, P, 0, Q, c_bw, b_bw, 1.0);
451    let c_h_r = rdrobust_bw(&r, c, P, 0, Q, c_bw, b_bw, 1.0);
452    let h_bw = ((c_h_l.0 + c_h_r.0) / ((c_h_r.1 - c_h_l.1).powi(2) + (c_h_r.2 + c_h_l.2)))
453        .powf(c_h_l.3)
454        .min(bw_max);
455    let (h, b) = (x_sd * h_bw, x_sd * b_bw);
456    if !(h.is_finite() && h > 0.0 && b.is_finite() && b > 0.0) {
457        return Err(DmlError::InvalidArgument(
458            "not enough variability in the running variable to compute the bandwidth".into(),
459        ));
460    }
461    Ok((h, b))
462}
463
464/// The MSE-optimal common bandwidths `(h, b)` of `rdrobust`'s `rdbwselect`
465/// (`bwselect = "mserd"`) for the RD effect at cutoff `c`; pass the
466/// treatment as `fuzzy` for a fuzzy design.
467pub fn rdbwselect(
468    y: ArrayView1<'_, f64>,
469    x: ArrayView1<'_, f64>,
470    fuzzy: Option<ArrayView1<'_, f64>>,
471    c: f64,
472) -> Result<(f64, f64)> {
473    let (left, right) = split(y, x, fuzzy, c)?;
474    check_support(&left, &right, P)?;
475    select_bandwidth(&left, &right, c)
476}
477
478fn check_support(left: &Side, right: &Side, order: usize) -> Result<()> {
479    if n_unique(&left.x).min(n_unique(&right.x)) < order + 1 {
480        return Err(DmlError::InvalidArgument(format!(
481            "not enough distinct running-variable values on each side of the cutoff for a polynomial of order {order}"
482        )));
483    }
484    Ok(())
485}
486
487// ---------------------------------------------------------------------------
488// Estimation (rdrobust)
489// ---------------------------------------------------------------------------
490
491/// One side's fit: `(β_p, β_bc, V_cl, V_rb factors)`.
492struct SideFit {
493    beta_p: Array2<f64>,
494    beta_bc: Array2<f64>,
495    inv_g_p: Array2<f64>,
496    rw_p: Array2<f64>,
497    q_q: Array2<f64>,
498    res: Array2<f64>,
499    n_h: usize,
500}
501
502fn fit_side(side: &Side, c: f64, h: f64, b: f64) -> Result<SideFit> {
503    let w_h: Vec<f64> = side.x.iter().map(|&x| kweight(x, c, h)).collect();
504    let w_b: Vec<f64> = side.x.iter().map(|&x| kweight(x, c, b)).collect();
505    let in_h: Vec<bool> = w_h.iter().map(|&w| w > 0.0).collect();
506    let in_b: Vec<bool> = w_b.iter().map(|&w| w > 0.0).collect();
507    let n_h = in_h.iter().filter(|&&k| k).count();
508    let uniq_in = |keep: &[bool]| {
509        n_unique(
510            &side
511                .x
512                .iter()
513                .zip(keep)
514                .filter(|(_, &k)| k)
515                .map(|(x, _)| *x)
516                .collect::<Vec<_>>(),
517        )
518    };
519    if uniq_in(&in_h) < P + 1 || uniq_in(&in_b) < Q + 1 {
520        return Err(DmlError::InvalidArgument(
521            "not enough distinct running-variable values with positive weight on each side of the cutoff"
522                .into(),
523        ));
524    }
525    let keep = if h > b { &in_h } else { &in_b };
526    let pick = |v: &[f64]| -> Vec<f64> {
527        v.iter()
528            .zip(keep.iter())
529            .filter(|(_, &k)| k)
530            .map(|(a, _)| *a)
531            .collect()
532    };
533    let (ww_h, ww_b) = (pick(&w_h), pick(&w_b));
534    // Nearest neighbours are counted within the estimation window.
535    let e = {
536        let s = side.select(keep);
537        Side::new(s.x, s.y, s.t)
538    };
539    let u: Vec<f64> = e.x.iter().map(|x| (x - c) / h).collect();
540    let r_q = vander(&e.x.iter().map(|x| x - c).collect::<Vec<_>>(), Q);
541    let r_p = r_q.slice(ndarray::s![.., ..=P]).to_owned();
542    let rw_p = scale_rows(&r_p, &ww_h);
543    let l: Array1<f64> = rw_p
544        .t()
545        .dot(&Array1::from_iter(u.iter().map(|&v| powk(v, P + 1))));
546    let sq = |w: &[f64]| w.iter().map(|v| v.sqrt()).collect::<Vec<_>>();
547    let g_q = {
548        let m = scale_rows(&r_q, &sq(&ww_b));
549        crossprod(&m, &m)
550    };
551    let inv_g_q = spd_inverse(&g_q)?;
552    let inv_g_p = {
553        let m = scale_rows(&r_p, &sq(&ww_h));
554        spd_inverse(&crossprod(&m, &m))?
555    };
556    let m: Vec<f64> = r_q
557        .dot(&inv_g_q.row(P + 1))
558        .iter()
559        .zip(&ww_b)
560        .map(|(a, w)| a * w)
561        .collect();
562    let hp = powk(h, P + 1);
563    let mut q_q = rw_p.clone();
564    for i in 0..q_q.nrows() {
565        for j in 0..q_q.ncols() {
566            q_q[[i, j]] -= hp * m[i] * l[j];
567        }
568    }
569    let d = e.d();
570    let beta_p = inv_g_p.dot(&crossprod(&rw_p, &d));
571    let beta_bc = inv_g_p.dot(&crossprod(&q_q, &d));
572    let res = nn_residuals(&e);
573    Ok(SideFit {
574        beta_p,
575        beta_bc,
576        inv_g_p,
577        rw_p,
578        q_q,
579        res,
580        n_h,
581    })
582}
583
584/// Local linear RD estimate at cutoff `c` with a quadratic bias correction
585/// and nearest-neighbour standard errors, as `rdrobust`. `bandwidths` gives
586/// `(h, b)` (both sides); `None` selects them with [`rdbwselect`]. Pass the
587/// treatment as `fuzzy` for a fuzzy design (the effect is then the ratio of
588/// the outcome and treatment jumps).
589pub fn rdrobust(
590    y: ArrayView1<'_, f64>,
591    x: ArrayView1<'_, f64>,
592    fuzzy: Option<ArrayView1<'_, f64>>,
593    c: f64,
594    bandwidths: Option<(f64, f64)>,
595) -> Result<RdEstimate> {
596    let (left, right) = split(y, x, fuzzy, c)?;
597    check_support(&left, &right, Q)?;
598    if let (Some(lt), Some(rt)) = (&left.t, &right.t) {
599        let constant = |t: &[f64]| t.iter().all(|&v| v == t[0]);
600        if constant(lt) && constant(rt) && (np_mean(lt) - np_mean(rt)).abs() < f64::EPSILON.sqrt() {
601            return Err(DmlError::InvalidArgument(
602                "fuzzy RD: the treatment has no variation and no jump at the cutoff".into(),
603            ));
604        }
605    }
606    let n = left.n() + right.n();
607    let (h, b) = match bandwidths {
608        Some((h, b)) => {
609            if !(h.is_finite() && h > 0.0 && b.is_finite() && b > 0.0) {
610                return Err(DmlError::InvalidArgument(
611                    "bandwidths must be positive and finite".into(),
612                ));
613            }
614            (h, b)
615        }
616        None if n < 20 => {
617            let h = (c - left.x[0])
618                .abs()
619                .max((right.x[right.n() - 1] - c).abs());
620            (h, h)
621        }
622        None => select_bandwidth(&left, &right, c)?,
623    };
624    let fl = fit_side(&left, c, h, b)?;
625    let fr = fit_side(&right, c, h, b)?;
626
627    let (tau_y_cl, tau_y_bc) = (
628        fr.beta_p[[0, 0]] - fl.beta_p[[0, 0]],
629        fr.beta_bc[[0, 0]] - fl.beta_bc[[0, 0]],
630    );
631    let (tau_cl, tau_bc, s) = if fuzzy.is_some() {
632        let tau_t_cl = fr.beta_p[[0, 1]] - fl.beta_p[[0, 1]];
633        let tau_t_bc = fr.beta_bc[[0, 1]] - fl.beta_bc[[0, 1]];
634        let tau_cl = tau_y_cl / tau_t_cl;
635        let s = vec![1.0 / tau_t_cl, -(tau_y_cl / (tau_t_cl * tau_t_cl))];
636        let tau_bc = tau_cl - (s[0] * (tau_y_cl - tau_y_bc) + s[1] * (tau_t_cl - tau_t_bc));
637        (tau_cl, tau_bc, s)
638    } else {
639        (tau_y_cl, tau_y_bc, vec![1.0])
640    };
641    let variance = |f: &SideFit, robust: bool| -> f64 {
642        let rx = if robust { &f.q_q } else { &f.rw_p };
643        f.inv_g_p.dot(&vce(&s, rx, &f.res)).dot(&f.inv_g_p)[[0, 0]]
644    };
645    let se_cl = (variance(&fl, false) + variance(&fr, false)).sqrt();
646    let se_rb = (variance(&fl, true) + variance(&fr, true)).sqrt();
647    let coef = [tau_cl, tau_bc, tau_bc];
648    let se = [se_cl, se_cl, se_rb];
649    let quant = -norm_ppf(0.025);
650    let ci = std::array::from_fn(|k| [coef[k] - quant * se[k], coef[k] + quant * se[k]]);
651    Ok(RdEstimate {
652        coef,
653        se,
654        ci,
655        h: (h, h),
656        b: (b, b),
657        n_h: (fl.n_h, fr.n_h),
658        n: (left.n(), right.n()),
659    })
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn duplicates_count_runs() {
668        let (d, id) = duplicates(&[1.0, 2.0, 2.0, 2.0, 3.0]);
669        assert_eq!(d, vec![1, 3, 3, 3, 1]);
670        assert_eq!(id, vec![1, 1, 2, 3, 1]);
671    }
672
673    #[test]
674    fn quantile_type2_matches_r() {
675        let x = [1.0, 2.0, 3.0, 4.0];
676        assert_eq!(quantile_type2(&x, 0.25), 1.5);
677        assert_eq!(quantile_type2(&x, 0.75), 3.5);
678        assert_eq!(quantile_type2(&[1.0, 2.0, 3.0], 0.25), 1.0);
679    }
680}