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