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}
58
59impl StaticPanel {
60 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 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 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
132pub 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 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 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 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 pub fn n_folds(mut self, n_folds: usize) -> Self {
187 self.splitting.n_folds = n_folds;
188 self
189 }
190
191 pub fn n_rep(mut self, n_rep: usize) -> Self {
193 self.splitting.n_rep = n_rep;
194 self
195 }
196
197 pub fn seed(mut self, seed: u64) -> Self {
199 self.splitting.seed = seed;
200 self
201 }
202
203 pub fn transform(&self, panel: &StaticPanel) -> Result<DmlData> {
206 Ok(transform(panel, self.approach)?.0)
207 }
208
209 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 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 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
240fn 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
260fn 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 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
342struct 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 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 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}