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 let per_fold: Vec<_> = crate::crossfit::map_folds(folds.len(), |k| fit_fold(&folds[k]))?;
411 let mut eta = Array1::from_elem(n, f64::NAN);
412 for (fold, (left, right)) in folds.iter().zip(per_fold) {
413 for (k, &i) in fold.test.iter().enumerate() {
414 eta[i] = (left[k] + right[k]) / 2.0;
415 }
416 }
417 Ok(eta)
418}
419
420#[derive(Debug, Clone)]
423pub struct RdFlexFit {
424 reps: Vec<RdEstimate>,
425 coef: [f64; 3],
426 se: [f64; 3],
427 ci: [[f64; 2]; 3],
428 h_fs: f64,
429 fuzzy: bool,
430 cutoff: f64,
431 kernel: RdKernel,
432}
433
434const LABELS: [&str; 3] = ["Conventional", "Bias-Corrected", "Robust"];
435
436impl RdFlexFit {
437 fn new(reps: Vec<RdEstimate>, h_fs: f64, fuzzy: bool, cutoff: f64, kernel: RdKernel) -> Self {
438 let coef: [f64; 3] =
440 std::array::from_fn(|k| median(&reps.iter().map(|r| r.coef[k]).collect::<Vec<_>>()));
441 let se = std::array::from_fn(|k| {
442 let v: Vec<f64> = reps
443 .iter()
444 .map(|r| {
445 let n_h = (r.n_h.0 + r.n_h.1) as f64;
446 r.se[k] * r.se[k] + (r.coef[k] - coef[k]).powi(2) / n_h
447 })
448 .collect();
449 median(&v).sqrt()
450 });
451 let ci = std::array::from_fn(|k| {
452 std::array::from_fn(|j| median(&reps.iter().map(|r| r.ci[k][j]).collect::<Vec<_>>()))
453 });
454 Self {
455 reps,
456 coef,
457 se,
458 ci,
459 h_fs,
460 fuzzy,
461 cutoff,
462 kernel,
463 }
464 }
465
466 pub fn coef(&self) -> [f64; 3] {
468 self.coef
469 }
470
471 pub fn se(&self) -> [f64; 3] {
472 self.se
473 }
474
475 pub fn t_stat(&self) -> [f64; 3] {
476 std::array::from_fn(|k| self.coef[k] / self.se[k])
477 }
478
479 pub fn p_value(&self) -> [f64; 3] {
480 std::array::from_fn(|k| 2.0 * norm_cdf(-(self.coef[k] / self.se[k]).abs()))
481 }
482
483 pub fn ci(&self) -> [[f64; 2]; 3] {
485 self.ci
486 }
487
488 pub fn confint(&self, level: f64) -> Result<[[f64; 2]; 3]> {
491 crate::fit::check_level(level)?;
492 let z = norm_ppf(1.0 - (1.0 - level) / 2.0);
493 Ok(std::array::from_fn(|k| {
494 let lo: Vec<f64> = self.reps.iter().map(|r| r.coef[k] - r.se[k] * z).collect();
495 let hi: Vec<f64> = self.reps.iter().map(|r| r.coef[k] + r.se[k] * z).collect();
496 [median(&lo), median(&hi)]
497 }))
498 }
499
500 pub fn all_coef(&self) -> Array2<f64> {
502 Array2::from_shape_fn((3, self.reps.len()), |(k, r)| self.reps[r].coef[k])
503 }
504
505 pub fn all_se(&self) -> Array2<f64> {
506 Array2::from_shape_fn((3, self.reps.len()), |(k, r)| self.reps[r].se[k])
507 }
508
509 pub fn h(&self) -> Vec<f64> {
511 self.reps.iter().map(|r| r.h.0).collect()
512 }
513
514 pub fn b(&self) -> Vec<f64> {
516 self.reps.iter().map(|r| r.b.0).collect()
517 }
518
519 pub fn h_fs(&self) -> f64 {
521 self.h_fs
522 }
523
524 pub fn repetitions(&self) -> &[RdEstimate] {
526 &self.reps
527 }
528
529 pub fn is_fuzzy(&self) -> bool {
530 self.fuzzy
531 }
532}
533
534impl fmt::Display for RdFlexFit {
535 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536 let (t, p, ci) = (self.t_stat(), self.p_value(), self.ci);
537 writeln!(
538 f,
539 "{:<16}{:>10}{:>10}{:>9}{:>11}{:>24}",
540 "Method", "Coef.", "S.E.", "t-stat", "P>|t|", "95% CI"
541 )?;
542 writeln!(f, "{}", "-".repeat(80))?;
543 for k in 0..3 {
544 writeln!(
545 f,
546 "{:<16}{:>10.4}{:>10.4}{:>9.3}{:>11.3e} [{:.4}, {:.4}]",
547 LABELS[k], self.coef[k], self.se[k], t[k], p[k], ci[k][0], ci[k][1]
548 )?;
549 }
550 writeln!(
551 f,
552 "Design: {}",
553 if self.fuzzy { "fuzzy" } else { "sharp" }
554 )?;
555 writeln!(f, "Cutoff: {}", self.cutoff)?;
556 writeln!(f, "First-stage kernel: {}", self.kernel.name())?;
557 write!(f, "Final bandwidth: {:?}", self.h())
558 }
559}