Skip to main content

dml_core/model/
plpr.rs

1//! Partially linear panel regression (PLPR) for static panels, as DoubleML's
2//! `DoubleMLPLPR` (Clarke and Polselli, 2025):
3//!
4//! ```text
5//! Y_it = θ·D_it + g(X_it) + α_i + U_it,    D_it = m(X_it) + γ_i + V_it
6//! ```
7//!
8//! with unit effects `α_i`, `γ_i` correlated with the covariates. The panel is
9//! transformed to remove (or model) the unit effects, and a partially linear
10//! regression is fitted on the transformed data with sample splits and
11//! standard errors clustered by unit.
12
13use std::collections::HashMap;
14
15use ndarray::{concatenate, Array1, Array2, ArrayView1, Axis};
16
17use super::lplr::TreatmentLearner;
18use super::{LinearScoreModel, NuisanceTarget, ScoreEstimate, ScoreInput};
19use crate::cluster::ClusterFold;
20use crate::compat::np_mean;
21use crate::crossfit::{cross_fit_predict, cross_fit_predict_proba, Fold, SampleSplitting};
22use crate::data::{is_binary, DmlData};
23use crate::error::{DmlError, Result};
24use crate::fit::{fit_model, fit_model_clustered, DmlFit};
25use crate::learner::Regressor;
26use crate::model::PlrScore;
27use crate::score::LinearScore;
28
29/// How [`Plpr`] handles the unit effects.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum PanelApproach {
32    /// Correlated random effects: unit means of the covariates are added to
33    /// them, and the treatment predictions are shifted so their unit means
34    /// equal the unit means of the treatment.
35    CreGeneral,
36    /// Correlated random effects assuming normally distributed treatments:
37    /// unit means of the covariates are added, and the treatment learner also
38    /// sees the unit mean of the treatment.
39    CreNormal,
40    /// First differences of outcome and treatment, with the covariates and
41    /// their lags (exact).
42    #[default]
43    FdExact,
44    /// Within-unit demeaning of outcome, treatment and covariates (approximate).
45    WgApprox,
46}
47
48/// A static panel in long format: one row per unit `id` and period `t`.
49#[derive(Debug, Clone)]
50pub struct StaticPanel {
51    y: Array1<f64>,
52    d: Array2<f64>,
53    x: Array2<f64>,
54    id: Array1<i64>,
55    t: Array1<f64>,
56    treatment_names: Vec<String>,
57    external: Option<super::ExternalPredictions>,
58}
59
60impl StaticPanel {
61    /// One treatment.
62    pub fn new(
63        y: Array1<f64>,
64        d: Array1<f64>,
65        x: Array2<f64>,
66        id: Array1<i64>,
67        t: Array1<f64>,
68    ) -> Result<Self> {
69        let n = d.len();
70        Self::with_treatments(y, d.into_shape_with_order((n, 1)).expect("1-D"), x, id, t)
71    }
72
73    /// Several treatments, one per column of `d`.
74    pub fn with_treatments(
75        y: Array1<f64>,
76        d: Array2<f64>,
77        x: Array2<f64>,
78        id: Array1<i64>,
79        t: Array1<f64>,
80    ) -> Result<Self> {
81        // DmlData checks shapes and finiteness.
82        let data = DmlData::with_treatments(y.clone(), d.clone(), x.clone())?;
83        if id.len() != y.len() || t.len() != y.len() {
84            return Err(DmlError::DimensionMismatch(
85                "id and t need one entry per row".into(),
86            ));
87        }
88        if !t.iter().all(|v| v.is_finite()) {
89            return Err(DmlError::InvalidArgument("t must be finite".into()));
90        }
91        if x.ncols() == 0 {
92            return Err(DmlError::InvalidArgument(
93                "PLPR needs at least one covariate".into(),
94            ));
95        }
96        let mut seen = std::collections::HashSet::new();
97        if !id
98            .iter()
99            .zip(&t)
100            .all(|(&i, &s)| seen.insert((i, s.to_bits())))
101        {
102            return Err(DmlError::InvalidArgument(
103                "each (id, t) pair must appear once".into(),
104            ));
105        }
106        Ok(Self {
107            treatment_names: data.treatment_names().to_vec(),
108            y,
109            d,
110            x,
111            id,
112            t,
113            external: None,
114        })
115    }
116
117    pub fn with_treatment_names(mut self, names: Vec<String>) -> Result<Self> {
118        if names.len() != self.d.ncols() {
119            return Err(DmlError::DimensionMismatch(format!(
120                "{} treatment names for {} treatments",
121                names.len(),
122                self.d.ncols()
123            )));
124        }
125        self.treatment_names = names;
126        Ok(self)
127    }
128
129    /// External nuisance predictions for the rows of the transformed panel
130    /// ([`Plpr::transform`]), as DoubleML.
131    pub fn with_external_predictions(mut self, external: super::ExternalPredictions) -> Self {
132        self.external = Some(external);
133        self
134    }
135
136    pub fn n_obs(&self) -> usize {
137        self.y.len()
138    }
139}
140
141/// Partially linear regression on a transformed static panel, with folds and
142/// standard errors clustered by unit. See [`PanelApproach`] for the
143/// transformations and [`PlrScore`] for the scores.
144pub struct Plpr {
145    ml_l: Box<dyn Regressor>,
146    ml_m: TreatmentLearner,
147    ml_g: Option<Box<dyn Regressor>>,
148    score: PlrScore,
149    approach: PanelApproach,
150    splitting: SampleSplitting,
151}
152
153impl Plpr {
154    /// `ml_l` learns `E[Y | X]`, `ml_m` `E[D | X]` on the transformed panel.
155    pub fn new(ml_l: impl Regressor + 'static, ml_m: impl Regressor + 'static) -> Self {
156        Self::from_boxed(Box::new(ml_l), TreatmentLearner::Regressor(Box::new(ml_m)))
157    }
158
159    /// `ml_m` may be a classifier for a binary treatment with the correlated
160    /// random effects approaches (the other approaches transform the
161    /// treatment, which is then no longer binary).
162    pub fn from_boxed(ml_l: Box<dyn Regressor>, ml_m: TreatmentLearner) -> Self {
163        Self {
164            ml_l,
165            ml_m,
166            ml_g: None,
167            score: PlrScore::PartiallingOut,
168            approach: PanelApproach::default(),
169            splitting: SampleSplitting::default(),
170        }
171    }
172
173    pub fn score(mut self, score: PlrScore) -> Self {
174        self.score = score;
175        self
176    }
177
178    pub fn approach(mut self, approach: PanelApproach) -> Self {
179        self.approach = approach;
180        self
181    }
182
183    /// Learner for `E[Y − θD | X]` with the IV-type score (default: a copy of `ml_l`).
184    pub fn ml_g(mut self, ml_g: impl Regressor + 'static) -> Self {
185        self.ml_g = Some(Box::new(ml_g));
186        self
187    }
188
189    pub fn ml_g_boxed(mut self, ml_g: Box<dyn Regressor>) -> Self {
190        self.ml_g = Some(ml_g);
191        self
192    }
193
194    /// Number of cross-fitting folds (default 5), over units.
195    pub fn n_folds(mut self, n_folds: usize) -> Self {
196        self.splitting.n_folds = n_folds;
197        self
198    }
199
200    /// Number of independent sample splits (default 1), aggregated by the median.
201    pub fn n_rep(mut self, n_rep: usize) -> Self {
202        self.splitting.n_rep = n_rep;
203        self
204    }
205
206    /// Seed for the sample splits (default 0).
207    pub fn seed(mut self, seed: u64) -> Self {
208        self.splitting.seed = seed;
209        self
210    }
211
212    /// The transformed panel the partially linear regression is fitted on,
213    /// clustered by unit.
214    pub fn transform(&self, panel: &StaticPanel) -> Result<DmlData> {
215        Ok(transform(panel, self.approach)?.0)
216    }
217
218    /// Draw folds of whole units and fit.
219    pub fn fit(&self, panel: &StaticPanel) -> Result<DmlFit> {
220        let (data, ids) = transform(panel, self.approach)?;
221        let splits = self
222            .splitting
223            .draw_clustered(data.clusters().expect("clustered by unit"))?;
224        fit_model_clustered(&self.model(&ids), &data, &splits)
225    }
226
227    /// Fit on given folds of the transformed rows ([`Plpr::transform`]); each
228    /// test set should hold whole units.
229    pub fn fit_with_folds(&self, panel: &StaticPanel, splits: &[Vec<Fold>]) -> Result<DmlFit> {
230        let (data, ids) = transform(panel, self.approach)?;
231        fit_model(&self.model(&ids), &data, splits)
232    }
233
234    /// Fit on given cluster folds of the transformed rows.
235    pub fn fit_with_cluster_folds(
236        &self,
237        panel: &StaticPanel,
238        splits: &[Vec<ClusterFold>],
239    ) -> Result<DmlFit> {
240        let (data, ids) = transform(panel, self.approach)?;
241        fit_model_clustered(&self.model(&ids), &data, splits)
242    }
243
244    fn model<'a>(&'a self, ids: &'a [i64]) -> PlprModel<'a> {
245        PlprModel { plpr: self, ids }
246    }
247}
248
249/// Unit mean of `v` for every row, as pandas' `groupby(id).transform("mean")`
250/// (Kahan-compensated sums).
251fn unit_means(v: ArrayView1<'_, f64>, ids: &[i64]) -> Array1<f64> {
252    let mut acc: HashMap<i64, (f64, f64, usize)> = HashMap::new();
253    for (&value, id) in v.iter().zip(ids) {
254        let (sum, comp, count) = acc.entry(*id).or_insert((0.0, 0.0, 0));
255        let y = value - *comp;
256        let t = *sum + y;
257        *comp = t - *sum - y;
258        *sum = t;
259        *count += 1;
260    }
261    ids.iter()
262        .map(|id| {
263            let (sum, _, count) = acc[id];
264            sum / count as f64
265        })
266        .collect()
267}
268
269/// Sort by unit and period, drop units observed once, and transform. Returns
270/// the transformed data (clustered by unit) and the unit of every row.
271fn transform(panel: &StaticPanel, approach: PanelApproach) -> Result<(DmlData, Vec<i64>)> {
272    let mut counts: HashMap<i64, usize> = HashMap::new();
273    for &i in &panel.id {
274        *counts.entry(i).or_default() += 1;
275    }
276    let mut order: Vec<usize> = (0..panel.n_obs())
277        .filter(|&r| counts[&panel.id[r]] > 1)
278        .collect();
279    order.sort_by(|&a, &b| {
280        panel.id[a]
281            .cmp(&panel.id[b])
282            .then(panel.t[a].total_cmp(&panel.t[b]))
283    });
284    if order.is_empty() {
285        return Err(DmlError::InvalidArgument(
286            "PLPR needs units observed in at least two periods".into(),
287        ));
288    }
289    let y = panel.y.select(Axis(0), &order);
290    let d = panel.d.select(Axis(0), &order);
291    let x = panel.x.select(Axis(0), &order);
292    let ids: Vec<i64> = order.iter().map(|&r| panel.id[r]).collect();
293    let names = &panel.treatment_names;
294
295    let (y, d, x, ids, names) = match approach {
296        PanelApproach::CreGeneral | PanelApproach::CreNormal => {
297            let mut means = Array2::zeros(x.dim());
298            for (j, col) in x.columns().into_iter().enumerate() {
299                means.column_mut(j).assign(&unit_means(col, &ids));
300            }
301            (y, d, concatenate![Axis(1), x, means], ids, names.clone())
302        }
303        PanelApproach::FdExact => {
304            // Each row needs the same unit's previous period on the grid of
305            // all periods (DoubleML reindexes to the full grid; rows after a
306            // gap have no lag and are dropped).
307            let t: Vec<f64> = order.iter().map(|&r| panel.t[r]).collect();
308            let mut grid = t.clone();
309            grid.sort_by(f64::total_cmp);
310            grid.dedup();
311            let pos = |v: f64| grid.binary_search_by(|g| g.total_cmp(&v)).expect("on grid");
312            let keep: Vec<usize> = (1..ids.len())
313                .filter(|&r| ids[r] == ids[r - 1] && pos(t[r]) == pos(t[r - 1]) + 1)
314                .collect();
315            let prev: Vec<usize> = keep.iter().map(|&r| r - 1).collect();
316            let dy = &y.select(Axis(0), &keep) - &y.select(Axis(0), &prev);
317            let dd = &d.select(Axis(0), &keep) - &d.select(Axis(0), &prev);
318            let xx = concatenate![Axis(1), x.select(Axis(0), &keep), x.select(Axis(0), &prev)];
319            let ids: Vec<i64> = keep.iter().map(|&r| ids[r]).collect();
320            let names = names.iter().map(|n| format!("{n}_diff")).collect();
321            (dy, dd, xx, ids, names)
322        }
323        PanelApproach::WgApprox => {
324            let within = |v: ArrayView1<'_, f64>| -> Array1<f64> {
325                let grand = np_mean(&v.to_vec());
326                &v - &unit_means(v, &ids) + grand
327            };
328            let mut d_w = Array2::zeros(d.dim());
329            for (j, col) in d.columns().into_iter().enumerate() {
330                d_w.column_mut(j).assign(&within(col));
331            }
332            let mut x_w = Array2::zeros(x.dim());
333            for (j, col) in x.columns().into_iter().enumerate() {
334                x_w.column_mut(j).assign(&within(col));
335            }
336            let names = names.iter().map(|n| format!("{n}_demean")).collect();
337            (within(y.view()), d_w, x_w, ids, names)
338        }
339    };
340    if ids.is_empty() {
341        return Err(DmlError::InvalidArgument(
342            "no rows left after first-differencing (units need consecutive periods)".into(),
343        ));
344    }
345    let mut data = DmlData::with_treatments(y, d, x)?
346        .with_treatment_names(names)?
347        .with_clusters(Array1::from(ids.clone()))?;
348    if let Some(e) = &panel.external {
349        data = data.with_external_predictions(e.clone());
350    }
351    Ok((data, ids))
352}
353
354/// [`Plpr`] on the transformed data, with the unit of every row.
355struct PlprModel<'a> {
356    plpr: &'a Plpr,
357    ids: &'a [i64],
358}
359
360impl LinearScoreModel for PlprModel<'_> {
361    fn name(&self) -> String {
362        let approach = match self.plpr.approach {
363            PanelApproach::CreGeneral => "CRE general",
364            PanelApproach::CreNormal => "CRE normal",
365            PanelApproach::FdExact => "first differences",
366            PanelApproach::WgApprox => "within groups",
367        };
368        let score = match self.plpr.score {
369            PlrScore::PartiallingOut => "partialling out",
370            PlrScore::IvType => "IV-type",
371        };
372        format!("PLPR ({approach}, {score})")
373    }
374
375    fn validate(&self, data: &DmlData) -> Result<()> {
376        if matches!(self.plpr.ml_m, TreatmentLearner::Classifier(_)) {
377            let cre = matches!(
378                self.plpr.approach,
379                PanelApproach::CreGeneral | PanelApproach::CreNormal
380            );
381            if !cre || !(0..data.n_treat()).all(|j| data.is_binary_treatment(j)) {
382                return Err(DmlError::InvalidArgument(
383                    "a classifier for ml_m needs binary treatments and a correlated random effects approach"
384                        .into(),
385                ));
386            }
387        }
388        Ok(())
389    }
390
391    fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
392        let ScoreInput { y, d, x, .. } = *input;
393        let p = self.plpr;
394        let l_hat =
395            input.predictions_or("ml_l", || cross_fit_predict(p.ml_l.as_ref(), x, y, folds))?;
396        let d_mean = unit_means(d, self.ids);
397        let x_m = match p.approach {
398            PanelApproach::CreNormal => {
399                concatenate![Axis(1), x, d_mean.view().insert_axis(Axis(1))]
400            }
401            _ => x.to_owned(),
402        };
403        let external_m = input
404            .external_ref()
405            .is_some_and(|e| e.preds.contains(e.treatment, "ml_m"));
406        let mut m_hat = input.predictions_or("ml_m", || match &p.ml_m {
407            TreatmentLearner::Regressor(r) => cross_fit_predict(r.as_ref(), x_m.view(), d, folds),
408            TreatmentLearner::Classifier(c) => {
409                if !is_binary(d) {
410                    return Err(DmlError::InvalidArgument(
411                        "a classifier for ml_m needs a binary treatment".into(),
412                    ));
413                }
414                cross_fit_predict_proba(c.as_ref(), x_m.view(), d, folds)
415            }
416        })?;
417        // External predictions are used as given (DoubleML).
418        if p.approach == PanelApproach::CreGeneral && !external_m {
419            // Shift the predictions so their unit means match the treatment's.
420            let m_mean = unit_means(m_hat.view(), self.ids);
421            m_hat = &m_hat + &d_mean - &m_mean;
422        }
423        let v: Array1<f64> = &d - &m_hat;
424        let m_target = match p.ml_m {
425            TreatmentLearner::Regressor(_) => NuisanceTarget::regression("ml_m", d.to_owned()),
426            TreatmentLearner::Classifier(_) => NuisanceTarget::classification("ml_m", d.to_owned()),
427        };
428        match p.score {
429            PlrScore::PartiallingOut => {
430                let u: Array1<f64> = &y - &l_hat;
431                Ok(ScoreEstimate::new(
432                    LinearScore::new(-(&v * &v), &v * &u)?,
433                    vec![("ml_l", l_hat), ("ml_m", m_hat)],
434                )
435                .with_targets(vec![
436                    NuisanceTarget::regression("ml_l", y.to_owned()),
437                    m_target,
438                ]))
439            }
440            PlrScore::IvType => {
441                // Initial θ from the partialling-out score.
442                let psi_a: Vec<f64> = v.iter().map(|v| -(v * v)).collect();
443                let psi_b: Vec<f64> = v
444                    .iter()
445                    .zip(&y)
446                    .zip(&l_hat)
447                    .map(|((v, y), l)| v * (y - l))
448                    .collect();
449                let theta_init = -np_mean(&psi_b) / np_mean(&psi_a);
450                let target: Array1<f64> = &y - &(theta_init * &d);
451                let fresh_l;
452                let ml_g = match &p.ml_g {
453                    Some(g) => g.as_ref(),
454                    None => {
455                        fresh_l = p.ml_l.fresh();
456                        fresh_l.as_ref()
457                    }
458                };
459                let g_hat = input
460                    .predictions_or("ml_g", || cross_fit_predict(ml_g, x, target.view(), folds))?;
461                let resid: Array1<f64> = &y - &g_hat;
462                Ok(ScoreEstimate::new(
463                    LinearScore::new(-(&v * &d), &v * &resid)?,
464                    vec![("ml_l", l_hat), ("ml_m", m_hat), ("ml_g", g_hat)],
465                )
466                .with_targets(vec![
467                    NuisanceTarget::regression("ml_l", y.to_owned()),
468                    m_target,
469                    NuisanceTarget::regression("ml_g", target),
470                ]))
471            }
472        }
473    }
474}