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