1use ndarray::{concatenate, Array1, ArrayView1, ArrayView2, Axis};
23
24use super::nonlinear::{assemble, RepFit};
25use super::NuisanceTarget;
26use crate::compat::{newton, np_mean, np_sum};
27use crate::crossfit::{Fold, KFold, SampleSplitting};
28use crate::data::{gather, is_binary, DmlData};
29use crate::error::{DmlError, Result};
30use crate::fit::DmlFit;
31use crate::learner::{Classifier, Regressor};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum LplrScore {
36 #[default]
38 NuisanceSpace,
39 Instrument,
41}
42
43pub enum TreatmentLearner {
46 Regressor(Box<dyn Regressor>),
47 Classifier(Box<dyn Classifier>),
48}
49
50impl TreatmentLearner {
51 fn is_classifier(&self) -> bool {
52 matches!(self, Self::Classifier(_))
53 }
54
55 fn as_ref(&self) -> Learner<'_> {
56 match self {
57 Self::Regressor(r) => Learner::Regressor(r.as_ref()),
58 Self::Classifier(c) => Learner::Classifier(c.as_ref()),
59 }
60 }
61}
62
63#[derive(Clone, Copy)]
65enum Learner<'a> {
66 Regressor(&'a dyn Regressor),
67 Classifier(&'a dyn Classifier),
68}
69
70impl Learner<'_> {
71 fn fit_predict(
74 self,
75 x: ArrayView2<'_, f64>,
76 target: ArrayView1<'_, f64>,
77 rows: &[usize],
78 weights: Option<ArrayView1<'_, f64>>,
79 x_pred: &[ArrayView2<'_, f64>],
80 ) -> Result<Vec<Array1<f64>>> {
81 let (xt, yt) = (x.select(Axis(0), rows), gather(target, rows));
82 let wt = weights.map(|w| gather(w, rows));
83 match self {
84 Learner::Regressor(r) => {
85 let mut m = r.fresh();
86 match &wt {
87 Some(w) => m.fit_weighted(xt.view(), yt.view(), w.view())?,
88 None => m.fit(xt.view(), yt.view())?,
89 }
90 x_pred.iter().map(|xp| m.predict(*xp)).collect()
91 }
92 Learner::Classifier(c) => {
93 let mut m = c.fresh();
94 match &wt {
95 Some(w) => m.fit_weighted(xt.view(), yt.view(), w.view())?,
96 None => m.fit(xt.view(), yt.view())?,
97 }
98 x_pred.iter().map(|xp| m.predict_proba(*xp)).collect()
99 }
100 }
101 }
102}
103
104pub struct Lplr {
113 ml_outcome: Box<dyn Classifier>,
114 ml_t: Box<dyn Regressor>,
115 ml_m: TreatmentLearner,
116 ml_a: Option<TreatmentLearner>,
117 score: LplrScore,
118 n_folds_inner: usize,
119 splitting: SampleSplitting,
120}
121
122impl Lplr {
123 pub fn new(
127 ml_outcome: impl Classifier + 'static,
128 ml_t: impl Regressor + 'static,
129 ml_m: impl Regressor + 'static,
130 ) -> Self {
131 Self::from_boxed(
132 Box::new(ml_outcome),
133 Box::new(ml_t),
134 TreatmentLearner::Regressor(Box::new(ml_m)),
135 None,
136 )
137 }
138
139 pub fn with_propensity(
142 ml_outcome: impl Classifier + 'static,
143 ml_t: impl Regressor + 'static,
144 ml_m: impl Classifier + 'static,
145 ) -> Self {
146 Self::from_boxed(
147 Box::new(ml_outcome),
148 Box::new(ml_t),
149 TreatmentLearner::Classifier(Box::new(ml_m)),
150 None,
151 )
152 }
153
154 pub fn from_boxed(
156 ml_outcome: Box<dyn Classifier>,
157 ml_t: Box<dyn Regressor>,
158 ml_m: TreatmentLearner,
159 ml_a: Option<TreatmentLearner>,
160 ) -> Self {
161 Self {
162 ml_outcome,
163 ml_t,
164 ml_m,
165 ml_a,
166 score: LplrScore::default(),
167 n_folds_inner: 5,
168 splitting: SampleSplitting::default(),
169 }
170 }
171
172 pub fn score(mut self, score: LplrScore) -> Self {
173 self.score = score;
174 self
175 }
176
177 pub fn ml_a(mut self, ml_a: TreatmentLearner) -> Self {
180 self.ml_a = Some(ml_a);
181 self
182 }
183
184 pub fn n_folds_inner(mut self, k: usize) -> Self {
186 self.n_folds_inner = k;
187 self
188 }
189
190 pub fn n_folds(mut self, n_folds: usize) -> Self {
192 self.splitting.n_folds = n_folds;
193 self
194 }
195
196 pub fn n_rep(mut self, n_rep: usize) -> Self {
198 self.splitting.n_rep = n_rep;
199 self
200 }
201
202 pub fn seed(mut self, seed: u64) -> Self {
204 self.splitting.seed = seed;
205 self
206 }
207
208 pub fn fit(&self, data: &DmlData) -> Result<DmlFit> {
210 self.validate(data)?;
211 let splits = self.splitting.draw(data.n_obs())?;
212 self.fit_with_folds(data, &splits)
213 }
214
215 pub fn fit_with_folds(&self, data: &DmlData, splits: &[Vec<Fold>]) -> Result<DmlFit> {
218 let inner = splits
219 .iter()
220 .enumerate()
221 .map(|(r, folds)| {
222 folds
223 .iter()
224 .enumerate()
225 .map(|(k, fold)| self.inner_folds(&fold.train, r, k))
226 .collect::<Result<Vec<_>>>()
227 })
228 .collect::<Result<Vec<_>>>()?;
229 self.fit_with_inner_folds(data, splits, &inner)
230 }
231
232 pub fn fit_with_inner_folds(
236 &self,
237 data: &DmlData,
238 splits: &[Vec<Fold>],
239 inner: &[Vec<Vec<Fold>>],
240 ) -> Result<DmlFit> {
241 self.validate(data)?;
242 if splits.is_empty() || inner.len() != splits.len() {
243 return Err(DmlError::InvalidArgument(
244 "need one set of inner folds per repetition".into(),
245 ));
246 }
247 for (folds, inner_r) in splits.iter().zip(inner) {
248 if inner_r.len() != folds.len() {
249 return Err(DmlError::InvalidArgument(
250 "need one set of inner folds per outer fold".into(),
251 ));
252 }
253 }
254 let fits = (0..data.n_treat())
255 .map(|j| {
256 let x = data.covariates_for(j);
257 let d = data.treatment(j);
258 let reps = splits
259 .iter()
260 .zip(inner)
261 .map(|(folds, inner_r)| self.fit_rep(data.y(), d, x.view(), folds, inner_r))
262 .collect::<Result<Vec<_>>>()?;
263 let mut fit = assemble(self.name(), data, splits[0].len(), reps);
264 fit.treatment_names = vec![data.treatment_names()[j].clone()];
265 Ok(fit)
266 })
267 .collect::<Result<Vec<_>>>()?;
268 if fits.len() == 1 {
269 Ok(fits.into_iter().next().expect("one fit"))
270 } else {
271 DmlFit::concat(&fits)
272 }
273 }
274
275 fn name(&self) -> String {
276 match self.score {
277 LplrScore::NuisanceSpace => "LPLR (nuisance space)".into(),
278 LplrScore::Instrument => "LPLR (instrument)".into(),
279 }
280 }
281
282 fn validate(&self, data: &DmlData) -> Result<()> {
283 if !is_binary(data.y()) {
284 return Err(DmlError::InvalidArgument(
285 "LPLR needs a binary (0/1) outcome".into(),
286 ));
287 }
288 if data.clusters().is_some() {
289 return Err(DmlError::InvalidArgument(
290 "LPLR does not support clustered data (as DoubleML's double sample splitting)"
291 .into(),
292 ));
293 }
294 let classifier = self.ml_m.is_classifier()
295 || self
296 .ml_a
297 .as_ref()
298 .is_some_and(TreatmentLearner::is_classifier);
299 if classifier && !(0..data.n_treat()).all(|j| data.is_binary_treatment(j)) {
300 return Err(DmlError::InvalidArgument(
301 "a classifier for ml_m / ml_a needs binary (0/1) treatments".into(),
302 ));
303 }
304 if self.n_folds_inner < 2 {
305 return Err(DmlError::InvalidArgument(
306 "n_folds_inner must be at least 2".into(),
307 ));
308 }
309 Ok(())
310 }
311
312 fn inner_folds(&self, train: &[usize], rep: usize, fold: usize) -> Result<Vec<Fold>> {
314 let seed = self
315 .splitting
316 .seed
317 .wrapping_add(0x9E37_79B9_7F4A_7C15_u64.wrapping_mul((rep * 1009 + fold + 1) as u64));
318 Ok(KFold::new(self.n_folds_inner, seed)?
319 .split(train.len())?
320 .into_iter()
321 .map(|f| Fold {
322 train: f.train.iter().map(|&p| train[p]).collect(),
323 test: f.test.iter().map(|&p| train[p]).collect(),
324 })
325 .collect())
326 }
327
328 fn double_cross_fit(
332 learner: Learner<'_>,
333 x: ArrayView2<'_, f64>,
334 target: ArrayView1<'_, f64>,
335 folds: &[Fold],
336 inner: &[Vec<Fold>],
337 ) -> Result<(Array1<f64>, Vec<Array1<f64>>)> {
338 let n = x.nrows();
339 let per_fold = |k: usize| -> Result<(Array1<f64>, Array1<f64>)> {
340 let fold = &folds[k];
341 let x_test = x.select(Axis(0), &fold.test);
342 let mut inner_pred = Array1::from_elem(n, f64::NAN);
343 let mut outer = Array1::zeros(fold.test.len());
344 for f in &inner[k] {
345 let x_in = x.select(Axis(0), &f.test);
346 let preds = learner.fit_predict(
347 x,
348 target,
349 &f.train,
350 None,
351 &[x_in.view(), x_test.view()],
352 )?;
353 for (p, &i) in preds[0].iter().zip(&f.test) {
354 inner_pred[i] = *p;
355 }
356 outer += &preds[1];
357 }
358 Ok((outer / inner[k].len() as f64, inner_pred))
359 };
360 #[cfg(feature = "parallel")]
361 let results: Vec<_> = {
362 use rayon::prelude::*;
363 (0..folds.len())
364 .into_par_iter()
365 .map(per_fold)
366 .collect::<Result<_>>()?
367 };
368 #[cfg(not(feature = "parallel"))]
369 let results: Vec<_> = (0..folds.len()).map(per_fold).collect::<Result<_>>()?;
370 let mut outer = Array1::from_elem(n, f64::NAN);
371 let mut inner_preds = Vec::with_capacity(folds.len());
372 for (fold, (pred, inner_pred)) in folds.iter().zip(results) {
373 for (p, &i) in pred.iter().zip(&fold.test) {
374 outer[i] = *p;
375 }
376 inner_preds.push(inner_pred);
377 }
378 Ok((outer, inner_preds))
379 }
380
381 fn fit_rep<'a>(
382 &self,
383 y: ArrayView1<'a, f64>,
384 d: ArrayView1<'a, f64>,
385 x: ArrayView2<'_, f64>,
386 folds: &[Fold],
387 inner: &[Vec<Fold>],
388 ) -> Result<RepFit> {
389 let n = y.len();
390 let xd = concatenate![Axis(1), d.insert_axis(Axis(1)), x];
391 let (big_m, big_m_inner) = Self::double_cross_fit(
392 Learner::Classifier(self.ml_outcome.as_ref()),
393 xd.view(),
394 y,
395 folds,
396 inner,
397 )?;
398 let ml_a = self.ml_a.as_ref().unwrap_or(&self.ml_m);
399 let (a, a_inner) = Self::double_cross_fit(ml_a.as_ref(), x, d, folds, inner)?;
400
401 let weights = big_m.mapv(|p| p * (1.0 - p));
403 let mut m = Array1::from_elem(n, f64::NAN);
404 for fold in folds {
405 let (rows, w) = match self.score {
406 LplrScore::NuisanceSpace => (
407 fold.train
408 .iter()
409 .copied()
410 .filter(|&i| y[i] == 0.0)
411 .collect(),
412 None,
413 ),
414 LplrScore::Instrument => (fold.train.clone(), Some(weights.view())),
415 };
416 let x_test = x.select(Axis(0), &fold.test);
417 let pred = self
418 .ml_m
419 .as_ref()
420 .fit_predict(x, d, &rows, w, &[x_test.view()])?;
421 for (p, &i) in pred[0].iter().zip(&fold.test) {
422 m[i] = *p;
423 }
424 }
425
426 let mut beta = Array1::zeros(n);
429 let mut t = Array1::from_elem(n, f64::NAN);
430 for (k, fold) in folds.iter().enumerate() {
431 let w: Array1<f64> = fold
432 .train
433 .iter()
434 .map(|&i| logit(big_m_inner[k][i].clamp(1e-8, 1.0 - 1e-8)))
435 .collect();
436 let d_tilde: Vec<f64> = fold.train.iter().map(|&i| d[i] - a_inner[k][i]).collect();
437 let cross: Vec<f64> = d_tilde.iter().zip(&w).map(|(a, b)| a * b).collect();
438 let square: Vec<f64> = d_tilde.iter().map(|v| v * v).collect();
439 let b = np_sum(&cross) / np_sum(&square);
440 for &i in &fold.test {
441 beta[i] = b;
442 }
443 let mut model = self.ml_t.fresh();
444 model.fit(x.select(Axis(0), &fold.train).view(), w.view())?;
445 let pred = model.predict(x.select(Axis(0), &fold.test).view())?;
446 for (p, &i) in pred.iter().zip(&fold.test) {
447 t[i] = *p;
448 }
449 }
450 let start = np_mean(beta.as_slice().expect("contiguous"));
451 let r = &t - &(&beta * &a);
452
453 let score = LplrElements::new(self.score, y, d, &r, &m);
454 let (theta, converged) = newton(
455 &mut |theta| np_mean(&score.psi(theta)),
456 &mut |theta| np_mean(&score.deriv(theta)),
457 start,
458 );
459 if !converged || !theta.is_finite() {
460 return Err(DmlError::Convergence(format!(
461 "could not find a root of the LPLR score (last value θ = {theta})"
462 )));
463 }
464 let psi = Array1::from(score.psi(theta));
465 let jacobian = np_mean(&score.deriv(theta));
466 let kind = |l: &TreatmentLearner, name: &'static str| {
467 if l.is_classifier() {
468 NuisanceTarget::classification(name, d.to_owned())
469 } else {
470 NuisanceTarget::regression(name, d.to_owned())
471 }
472 };
473 Ok(RepFit {
474 next_start: start,
475 theta,
476 psi,
477 jacobian,
478 predictions: vec![
479 ("ml_r", r),
480 ("ml_m", m),
481 ("ml_a", a),
482 ("ml_t", t),
483 ("ml_M", big_m),
484 ],
485 targets: vec![
486 kind(&self.ml_m, "ml_m"),
487 kind(ml_a, "ml_a"),
488 NuisanceTarget::classification("ml_M", y.to_owned()),
489 ],
490 })
491 }
492}
493
494fn logit(p: f64) -> f64 {
495 (p / (1.0 - p)).ln()
496}
497
498fn expit(x: f64) -> f64 {
500 1.0 / (1.0 + (-x).exp())
501}
502
503struct LplrElements<'a> {
505 score: LplrScore,
506 y: ArrayView1<'a, f64>,
507 d: ArrayView1<'a, f64>,
508 r: Array1<f64>,
509 d_tilde: Array1<f64>,
510 psi_hat: Array1<f64>,
511 score_const: Array1<f64>,
512}
513
514impl<'a> LplrElements<'a> {
515 fn new(
516 score: LplrScore,
517 y: ArrayView1<'a, f64>,
518 d: ArrayView1<'a, f64>,
519 r: &Array1<f64>,
520 m: &Array1<f64>,
521 ) -> Self {
522 let d_tilde = &d - m;
523 let psi_hat = r.mapv(|v| expit(-v));
524 let score_const: Array1<f64> = (0..y.len())
525 .map(|i| d_tilde[i] * (1.0 - y[i]) * r[i].exp())
526 .collect();
527 Self {
528 score,
529 y,
530 d,
531 r: r.clone(),
532 d_tilde,
533 psi_hat,
534 score_const,
535 }
536 }
537
538 fn psi(&self, theta: f64) -> Vec<f64> {
539 (0..self.y.len())
540 .map(|i| match self.score {
541 LplrScore::NuisanceSpace => {
542 let s1 = self.y[i] * (-theta * self.d[i]).exp() * self.d_tilde[i];
543 self.psi_hat[i] * (s1 - self.score_const[i])
544 }
545 LplrScore::Instrument => {
546 (self.y[i] - expit(theta * self.d[i] + self.r[i])) * self.d_tilde[i]
547 }
548 })
549 .collect()
550 }
551
552 fn deriv(&self, theta: f64) -> Vec<f64> {
553 (0..self.y.len())
554 .map(|i| match self.score {
555 LplrScore::NuisanceSpace => {
556 let d1 = -self.y[i] * (-theta * self.d[i]).exp() * self.d[i];
557 self.psi_hat[i] * self.d_tilde[i] * d1
558 }
559 LplrScore::Instrument => {
560 let e = expit(theta * self.d[i] + self.r[i]);
561 -self.d[i] * e * (1.0 - e) * self.d_tilde[i]
562 }
563 })
564 .collect()
565 }
566}