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        let results: Vec<_> = crate::crossfit::map_folds(folds.len(), per_fold)?;
375        let mut outer = Array1::from_elem(n, f64::NAN);
376        let mut inner_preds = Vec::with_capacity(folds.len());
377        for (fold, (pred, inner_pred)) in folds.iter().zip(results) {
378            for (p, &i) in pred.iter().zip(&fold.test) {
379                outer[i] = *p;
380            }
381            inner_preds.push(inner_pred);
382        }
383        Ok((outer, inner_preds))
384    }
385
386    fn fit_rep<'a>(
387        &self,
388        y: ArrayView1<'a, f64>,
389        d: ArrayView1<'a, f64>,
390        x: ArrayView2<'_, f64>,
391        folds: &[Fold],
392        inner: &[Vec<Fold>],
393        ext: Option<ExternalRef<'_>>,
394    ) -> Result<RepFit> {
395        let n = y.len();
396        let is_external = |name: &str| ext.is_some_and(|e| e.preds.contains(e.treatment, name));
397        // External outer predictions of a double cross-fitted learner need
398        // the inner predictions of every outer fold too.
399        type Double = (Array1<f64>, Vec<Array1<f64>>);
400        let double = |name: &str, fit: &dyn Fn() -> Result<Double>| -> Result<Double> {
401            let Some(outer) = external_get(ext, n, name)? else {
402                return fit();
403            };
404            let inner = (0..folds.len())
405                .map(|k| {
406                    external_get(ext, n, &format!("{name}_inner_{k}"))?.ok_or_else(|| {
407                        DmlError::InvalidArgument(format!(
408                            "external predictions for {name} also need {name}_inner_k \
409                             for every outer fold k (missing k = {k})"
410                        ))
411                    })
412                })
413                .collect::<Result<Vec<_>>>()?;
414            Ok((outer, inner))
415        };
416        let xd = concatenate![Axis(1), d.insert_axis(Axis(1)), x];
417        let (big_m, big_m_inner) = double("ml_M", &|| {
418            Self::double_cross_fit(
419                Learner::Classifier(self.ml_outcome.as_ref()),
420                xd.view(),
421                y,
422                folds,
423                inner,
424            )
425        })?;
426        let ml_a = self.ml_a.as_ref().unwrap_or(&self.ml_m);
427        let (a, a_inner) = double("ml_a", &|| {
428            Self::double_cross_fit(ml_a.as_ref(), x, d, folds, inner)
429        })?;
430
431        // m(X): on Y = 0 (nuisance space) or weighted by M(1 − M) (instrument).
432        let m = external_or(ext, n, "ml_m", || {
433            let weights = big_m.mapv(|p| p * (1.0 - p));
434            let mut m = Array1::from_elem(n, f64::NAN);
435            for fold in folds {
436                let (rows, w) = match self.score {
437                    LplrScore::NuisanceSpace => (
438                        fold.train
439                            .iter()
440                            .copied()
441                            .filter(|&i| y[i] == 0.0)
442                            .collect(),
443                        None,
444                    ),
445                    LplrScore::Instrument => (fold.train.clone(), Some(weights.view())),
446                };
447                let x_test = x.select(Axis(0), &fold.test);
448                let pred = self
449                    .ml_m
450                    .as_ref()
451                    .fit_predict(x, d, &rows, w, &[x_test.view()])?;
452                for (p, &i) in pred[0].iter().zip(&fold.test) {
453                    m[i] = *p;
454                }
455            }
456            Ok(m)
457        })?;
458
459        // Preliminary θ per fold from the inner predictions, and the targets
460        // logit M of ml_t on each training fold.
461        let mut beta = Array1::zeros(n);
462        let mut t = Array1::from_elem(n, f64::NAN);
463        let t_external = external_get(ext, n, "ml_t")?;
464        for (k, fold) in folds.iter().enumerate() {
465            let w: Array1<f64> = fold
466                .train
467                .iter()
468                .map(|&i| logit(big_m_inner[k][i].clamp(1e-8, 1.0 - 1e-8)))
469                .collect();
470            let d_tilde: Vec<f64> = fold.train.iter().map(|&i| d[i] - a_inner[k][i]).collect();
471            let cross: Vec<f64> = d_tilde.iter().zip(&w).map(|(a, b)| a * b).collect();
472            let square: Vec<f64> = d_tilde.iter().map(|v| v * v).collect();
473            let b = np_sum(&cross) / np_sum(&square);
474            for &i in &fold.test {
475                beta[i] = b;
476            }
477            if t_external.is_none() {
478                let mut model = self.ml_t.fresh();
479                model.fit(x.select(Axis(0), &fold.train).view(), w.view())?;
480                let pred = model.predict(x.select(Axis(0), &fold.test).view())?;
481                for (p, &i) in pred.iter().zip(&fold.test) {
482                    t[i] = *p;
483                }
484            }
485        }
486        if let Some(ext_t) = t_external {
487            t = ext_t;
488        }
489        let start = np_mean(beta.as_slice().expect("contiguous"));
490        let r = &t - &(&beta * &a);
491
492        let score = LplrElements::new(self.score, y, d, &r, &m);
493        let (theta, converged) = newton(
494            &mut |theta| np_mean(&score.psi(theta)),
495            &mut |theta| np_mean(&score.deriv(theta)),
496            start,
497        );
498        if !converged || !theta.is_finite() {
499            return Err(DmlError::Convergence(format!(
500                "could not find a root of the LPLR score (last value θ = {theta})"
501            )));
502        }
503        let psi = Array1::from(score.psi(theta));
504        let jacobian = np_mean(&score.deriv(theta));
505        let kind = |l: &TreatmentLearner, name: &'static str| {
506            if l.is_classifier() {
507                NuisanceTarget::classification(name, d.to_owned())
508            } else {
509                NuisanceTarget::regression(name, d.to_owned())
510            }
511        };
512        // Externally predicted learners have no out-of-sample loss.
513        let targets = [
514            kind(&self.ml_m, "ml_m"),
515            kind(ml_a, "ml_a"),
516            NuisanceTarget::classification("ml_M", y.to_owned()),
517        ]
518        .into_iter()
519        .filter(|t| !is_external(&t.name))
520        .collect();
521        let mut predictions: Vec<(Name, Array1<f64>)> = vec![
522            ("ml_r".into(), r),
523            ("ml_m".into(), m),
524            ("ml_a".into(), a),
525            ("ml_t".into(), t),
526            ("ml_M".into(), big_m),
527        ];
528        for (name, inner_preds) in [("ml_M", big_m_inner), ("ml_a", a_inner)] {
529            for (k, p) in inner_preds.into_iter().enumerate() {
530                predictions.push((format!("{name}_inner_{k}").into(), p));
531            }
532        }
533        Ok(RepFit {
534            next_start: start,
535            theta,
536            psi,
537            jacobian,
538            predictions,
539            targets,
540        })
541    }
542}
543
544fn logit(p: f64) -> f64 {
545    (p / (1.0 - p)).ln()
546}
547
548/// SciPy's `expit`, `1 / (1 + e^{−x})`.
549fn expit(x: f64) -> f64 {
550    1.0 / (1.0 + (-x).exp())
551}
552
553/// Score elements, evaluated in DoubleML's operation order.
554struct LplrElements<'a> {
555    score: LplrScore,
556    y: ArrayView1<'a, f64>,
557    d: ArrayView1<'a, f64>,
558    r: Array1<f64>,
559    d_tilde: Array1<f64>,
560    psi_hat: Array1<f64>,
561    score_const: Array1<f64>,
562}
563
564impl<'a> LplrElements<'a> {
565    fn new(
566        score: LplrScore,
567        y: ArrayView1<'a, f64>,
568        d: ArrayView1<'a, f64>,
569        r: &Array1<f64>,
570        m: &Array1<f64>,
571    ) -> Self {
572        let d_tilde = &d - m;
573        let psi_hat = r.mapv(|v| expit(-v));
574        let score_const: Array1<f64> = (0..y.len())
575            .map(|i| d_tilde[i] * (1.0 - y[i]) * r[i].exp())
576            .collect();
577        Self {
578            score,
579            y,
580            d,
581            r: r.clone(),
582            d_tilde,
583            psi_hat,
584            score_const,
585        }
586    }
587
588    fn psi(&self, theta: f64) -> Vec<f64> {
589        (0..self.y.len())
590            .map(|i| match self.score {
591                LplrScore::NuisanceSpace => {
592                    let s1 = self.y[i] * (-theta * self.d[i]).exp() * self.d_tilde[i];
593                    self.psi_hat[i] * (s1 - self.score_const[i])
594                }
595                LplrScore::Instrument => {
596                    (self.y[i] - expit(theta * self.d[i] + self.r[i])) * self.d_tilde[i]
597                }
598            })
599            .collect()
600    }
601
602    fn deriv(&self, theta: f64) -> Vec<f64> {
603        (0..self.y.len())
604            .map(|i| match self.score {
605                LplrScore::NuisanceSpace => {
606                    let d1 = -self.y[i] * (-theta * self.d[i]).exp() * self.d[i];
607                    self.psi_hat[i] * self.d_tilde[i] * d1
608                }
609                LplrScore::Instrument => {
610                    let e = expit(theta * self.d[i] + self.r[i]);
611                    -self.d[i] * e * (1.0 - e) * self.d_tilde[i]
612                }
613            })
614            .collect()
615    }
616}