Skip to main content

dml_core/model/
lplr.rs

1//! Logistic partially linear regression (LPLR) for a binary outcome, as
2//! DoubleML's `DoubleMLLPLR` (Liu, Zhang and Zhou, 2021):
3//!
4//! ```text
5//! P(Y = 1 | D, X) = expit(θ·D + r(X))
6//! ```
7//!
8//! θ is a log odds ratio. The nuisances are `M(D, X) = P(Y = 1 | D, X)`
9//! (`ml_M`), `t(X) = E[logit M(D, X) | X]` (`ml_t`), `m(X) = E[D | X]`
10//! (`ml_m`) and `a(X) = E[D | X]` (`ml_a`, by default a copy of `ml_m`).
11//! `ml_M` and `ml_a` are double cross-fitted: within every outer training
12//! fold, inner folds give out-of-sample predictions on the training fold
13//! (needed to build the targets of `ml_t` and a preliminary θ), and the
14//! average of the inner models predicts the outer test fold. The score is
15//! solved with Newton's method from the preliminary estimate.
16//!
17//! - [`LplrScore::NuisanceSpace`]: `m` is learned on the observations with
18//!   `Y = 0`.
19//! - [`LplrScore::Instrument`]: `m` is learned on all observations, weighted
20//!   by `M(1 − M)`.
21//!
22//! External predictions ([`DmlData::with_external_predictions`]) may replace
23//! any learner. For the double cross-fitted `ml_M` and `ml_a`, the inner
24//! predictions of every outer fold `k` are needed too (`ml_M_inner_k`,
25//! `ml_a_inner_k`: the out-of-inner-fold predictions on fold `k`'s training
26//! set), as in DoubleML; a fit reports them under the same names.
27
28use 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/// Score of [`Lplr`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum LplrScore {
42    /// `m(X)` estimated on the observations with `Y = 0`.
43    #[default]
44    NuisanceSpace,
45    /// Instrument-type score; `m(X)` estimated with weights `M(1 − M)`.
46    Instrument,
47}
48
49/// A learner for `E[D | X]`: a regressor, or for a binary treatment a
50/// classifier whose class-1 probability is used.
51pub 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/// A borrowed learner of either kind.
70#[derive(Clone, Copy)]
71enum Learner<'a> {
72    Regressor(&'a dyn Regressor),
73    Classifier(&'a dyn Classifier),
74}
75
76impl Learner<'_> {
77    /// Fit a fresh copy on `rows` of `x` (optionally weighted) and predict
78    /// every matrix in `x_pred`.
79    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
110/// Logistic partially linear regression for a binary outcome,
111/// `P(Y = 1 | D, X) = expit(θ·D + r(X))`, as DoubleML's `DoubleMLLPLR`; θ is
112/// a log odds ratio.
113///
114/// Nuisances: `ml_outcome` (DoubleML's `ml_M`) for `P(Y = 1 | D, X)`, `ml_t`
115/// for `E[logit M(D, X) | X]`, `ml_m` (and optionally `ml_a`) for `E[D | X]`.
116/// `ml_M` and `ml_a` are double cross-fitted ([`Lplr::n_folds_inner`]) and
117/// the score is solved with Newton's method; see [`LplrScore`] for the scores.
118pub 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    /// `ml_outcome` learns `M(D, X) = P(Y = 1 | D, X)` (DoubleML's `ml_M`),
130    /// `ml_t` the log odds `E[logit M(D, X) | X]`, `ml_m` the treatment
131    /// regression `E[D | X]`.
132    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    /// As [`Lplr::new`], with a classifier for a binary treatment's
146    /// propensity `P(D = 1 | X)`.
147    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    /// Boxed learners; `ml_a` defaults to a copy of `ml_m`.
161    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    /// A separate learner for `a(X) = E[D | X]`, the regression used to build
184    /// the preliminary estimate and `r(X)` (default: a copy of `ml_m`).
185    pub fn ml_a(mut self, ml_a: TreatmentLearner) -> Self {
186        self.ml_a = Some(ml_a);
187        self
188    }
189
190    /// Inner folds of the double cross-fitting (default 5).
191    pub fn n_folds_inner(mut self, k: usize) -> Self {
192        self.n_folds_inner = k;
193        self
194    }
195
196    /// Number of outer cross-fitting folds (default 5).
197    pub fn n_folds(mut self, n_folds: usize) -> Self {
198        self.splitting.n_folds = n_folds;
199        self
200    }
201
202    /// Number of independent sample splits (default 1), aggregated by the median.
203    pub fn n_rep(mut self, n_rep: usize) -> Self {
204        self.splitting.n_rep = n_rep;
205        self
206    }
207
208    /// Seed for the outer and inner sample splits (default 0).
209    pub fn seed(mut self, seed: u64) -> Self {
210        self.splitting.seed = seed;
211        self
212    }
213
214    /// Draw seeded outer and inner sample splits and fit.
215    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    /// Fit on given outer splits (one `Vec<Fold>` per repetition); the inner
222    /// splits are drawn from the seed.
223    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    /// Fit on given outer and inner splits: `inner[r][k]` are the inner folds
239    /// (data indices, partitioning the training set of outer fold `k`) of
240    /// repetition `r`.
241    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    /// Seeded inner K-fold split of one outer training fold.
327    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    /// Double cross-fitting: for every outer fold, out-of-inner-fold
343    /// predictions on its training set, and the mean of the inner models'
344    /// predictions on its test set.
345    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        // External outer predictions of a double cross-fitted learner need
407        // the inner predictions of every outer fold too.
408        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        // m(X): on Y = 0 (nuisance space) or weighted by M(1 − M) (instrument).
441        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        // Preliminary θ per fold from the inner predictions, and the targets
469        // logit M of ml_t on each training fold.
470        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        // Externally predicted learners have no out-of-sample loss.
522        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
557/// SciPy's `expit`, `1 / (1 + e^{−x})`.
558fn expit(x: f64) -> f64 {
559    1.0 / (1.0 + (-x).exp())
560}
561
562/// Score elements, evaluated in DoubleML's operation order.
563struct 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}