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