1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum PanelApproach {
32 CreGeneral,
36 CreNormal,
40 #[default]
43 FdExact,
44 WgApprox,
46}
47
48#[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 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 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 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 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
141pub 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 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 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 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 pub fn n_folds(mut self, n_folds: usize) -> Self {
196 self.splitting.n_folds = n_folds;
197 self
198 }
199
200 pub fn n_rep(mut self, n_rep: usize) -> Self {
202 self.splitting.n_rep = n_rep;
203 self
204 }
205
206 pub fn seed(mut self, seed: u64) -> Self {
208 self.splitting.seed = seed;
209 self
210 }
211
212 pub fn transform(&self, panel: &StaticPanel) -> Result<DmlData> {
215 Ok(transform(panel, self.approach)?.0)
216 }
217
218 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 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 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
249fn 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
269fn 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 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
354struct 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 if p.approach == PanelApproach::CreGeneral && !external_m {
419 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 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}