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