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
22use 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/// Score of [`Lplr`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum LplrScore {
36    /// `m(X)` estimated on the observations with `Y = 0`.
37    #[default]
38    NuisanceSpace,
39    /// Instrument-type score; `m(X)` estimated with weights `M(1 − M)`.
40    Instrument,
41}
42
43/// A learner for `E[D | X]`: a regressor, or for a binary treatment a
44/// classifier whose class-1 probability is used.
45pub 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/// A borrowed learner of either kind.
64#[derive(Clone, Copy)]
65enum Learner<'a> {
66    Regressor(&'a dyn Regressor),
67    Classifier(&'a dyn Classifier),
68}
69
70impl Learner<'_> {
71    /// Fit a fresh copy on `rows` of `x` (optionally weighted) and predict
72    /// every matrix in `x_pred`.
73    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
104/// Logistic partially linear regression for a binary outcome,
105/// `P(Y = 1 | D, X) = expit(θ·D + r(X))`, as DoubleML's `DoubleMLLPLR`; θ is
106/// a log odds ratio.
107///
108/// Nuisances: `ml_outcome` (DoubleML's `ml_M`) for `P(Y = 1 | D, X)`, `ml_t`
109/// for `E[logit M(D, X) | X]`, `ml_m` (and optionally `ml_a`) for `E[D | X]`.
110/// `ml_M` and `ml_a` are double cross-fitted ([`Lplr::n_folds_inner`]) and
111/// the score is solved with Newton's method; see [`LplrScore`] for the scores.
112pub 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    /// `ml_outcome` learns `M(D, X) = P(Y = 1 | D, X)` (DoubleML's `ml_M`),
124    /// `ml_t` the log odds `E[logit M(D, X) | X]`, `ml_m` the treatment
125    /// regression `E[D | X]`.
126    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    /// As [`Lplr::new`], with a classifier for a binary treatment's
140    /// propensity `P(D = 1 | X)`.
141    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    /// Boxed learners; `ml_a` defaults to a copy of `ml_m`.
155    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    /// A separate learner for `a(X) = E[D | X]`, the regression used to build
178    /// the preliminary estimate and `r(X)` (default: a copy of `ml_m`).
179    pub fn ml_a(mut self, ml_a: TreatmentLearner) -> Self {
180        self.ml_a = Some(ml_a);
181        self
182    }
183
184    /// Inner folds of the double cross-fitting (default 5).
185    pub fn n_folds_inner(mut self, k: usize) -> Self {
186        self.n_folds_inner = k;
187        self
188    }
189
190    /// Number of outer cross-fitting folds (default 5).
191    pub fn n_folds(mut self, n_folds: usize) -> Self {
192        self.splitting.n_folds = n_folds;
193        self
194    }
195
196    /// Number of independent sample splits (default 1), aggregated by the median.
197    pub fn n_rep(mut self, n_rep: usize) -> Self {
198        self.splitting.n_rep = n_rep;
199        self
200    }
201
202    /// Seed for the outer and inner sample splits (default 0).
203    pub fn seed(mut self, seed: u64) -> Self {
204        self.splitting.seed = seed;
205        self
206    }
207
208    /// Draw seeded outer and inner sample splits and fit.
209    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    /// Fit on given outer splits (one `Vec<Fold>` per repetition); the inner
216    /// splits are drawn from the seed.
217    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    /// Fit on given outer and inner splits: `inner[r][k]` are the inner folds
233    /// (data indices, partitioning the training set of outer fold `k`) of
234    /// repetition `r`.
235    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    /// Seeded inner K-fold split of one outer training fold.
313    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    /// Double cross-fitting: for every outer fold, out-of-inner-fold
329    /// predictions on its training set, and the mean of the inner models'
330    /// predictions on its test set.
331    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        // m(X): on Y = 0 (nuisance space) or weighted by M(1 − M) (instrument).
402        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        // Preliminary θ per fold from the inner predictions, and the targets
427        // logit M of ml_t on each training fold.
428        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
498/// SciPy's `expit`, `1 / (1 + e^{−x})`.
499fn expit(x: f64) -> f64 {
500    1.0 / (1.0 + (-x).exp())
501}
502
503/// Score elements, evaluated in DoubleML's operation order.
504struct 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}