1use ndarray::{concatenate, Array1, Array2, Axis};
14
15use super::irm::{normalize_propensity, ScoreWeights};
16use super::{
17 dml_model_api, nuisance_api, CateSignal, LinearScoreModel, NuisanceLearners, NuisanceTarget,
18 ScoreEstimate, ScoreInput,
19};
20use crate::cluster::ClusterFold;
21use crate::crossfit::{cross_fit_predict_proba, cross_fit_predict_subset, Fold, SampleSplitting};
22use crate::data::DmlData;
23use crate::error::{DmlError, Result};
24use crate::fit::DmlFit;
25use crate::learner::{Classifier, Regressor};
26use crate::propensity::{adjust_propensity, Calibration};
27use crate::score::LinearScore;
28use crate::sensitivity::{Elements, Nu2Parts, SensitivityParts};
29
30pub struct Apo {
38 nuisance: NuisanceLearners,
39 ml_g: Box<dyn Regressor>,
40 ml_m: Box<dyn Classifier>,
41 treatment_level: f64,
42 clipping_threshold: f64,
43 calibration: Calibration,
44 normalize_ipw: bool,
45 weights: Option<ScoreWeights>,
46 splitting: SampleSplitting,
47}
48
49impl Apo {
50 pub fn new(
51 ml_g: impl Regressor + 'static,
52 ml_m: impl Classifier + 'static,
53 treatment_level: f64,
54 ) -> Self {
55 Self::from_boxed(Box::new(ml_g), Box::new(ml_m), treatment_level)
56 }
57
58 pub fn from_boxed(
59 ml_g: Box<dyn Regressor>,
60 ml_m: Box<dyn Classifier>,
61 treatment_level: f64,
62 ) -> Self {
63 Self {
64 nuisance: NuisanceLearners::default(),
65 ml_g,
66 ml_m,
67 treatment_level,
68 clipping_threshold: 1e-2,
69 calibration: Calibration::None,
70 normalize_ipw: false,
71 weights: None,
72 splitting: SampleSplitting::default(),
73 }
74 }
75
76 pub fn clipping_threshold(mut self, t: f64) -> Self {
78 self.clipping_threshold = t;
79 self
80 }
81 pub fn calibration(mut self, calibration: Calibration) -> Self {
84 self.calibration = calibration;
85 self
86 }
87
88 pub fn normalize_ipw(mut self, normalize: bool) -> Self {
90 self.normalize_ipw = normalize;
91 self
92 }
93
94 pub fn weights(mut self, weights: Array1<f64>) -> Self {
97 self.weights = Some(ScoreWeights {
98 weights,
99 weights_bar: None,
100 });
101 self
102 }
103
104 pub fn weights_with_bar(mut self, weights: Array1<f64>, weights_bar: Array2<f64>) -> Self {
107 self.weights = Some(ScoreWeights {
108 weights,
109 weights_bar: Some(weights_bar),
110 });
111 self
112 }
113
114 dml_model_api!();
115 nuisance_api!();
116}
117
118fn level_codes(d: &[f64]) -> Vec<usize> {
121 let mut levels = d.to_vec();
122 levels.sort_by(f64::total_cmp);
123 levels.dedup();
124 d.iter()
125 .map(|v| {
126 levels
127 .binary_search_by(|l| l.total_cmp(v))
128 .expect("present")
129 })
130 .collect()
131}
132
133fn check_clipping(t: f64) -> Result<()> {
134 if t > 0.0 && t < 0.5 {
135 Ok(())
136 } else {
137 Err(DmlError::InvalidArgument(
138 "clipping_threshold must be in (0, 0.5)".into(),
139 ))
140 }
141}
142
143impl LinearScoreModel for Apo {
144 fn name(&self) -> String {
145 format!("APO (treatment level {})", self.treatment_level)
146 }
147
148 fn validate(&self, data: &DmlData) -> Result<()> {
149 self.nuisance
150 .check("APO", &["ml_g_d_lvl1", "ml_g_d_lvl0"], &["ml_m"])?;
151 if data.n_treat() != 1 {
152 return Err(DmlError::InvalidArgument(
153 "APO requires exactly one (discrete) treatment".into(),
154 ));
155 }
156 check_clipping(self.clipping_threshold)?;
157 if let Some(w) = &self.weights {
158 w.check(data.n_obs())?;
159 }
160 let d = data.treatment(0);
161 let at_level = d.iter().filter(|&&v| v == self.treatment_level).count();
162 if at_level == 0 || at_level == d.len() {
163 return Err(DmlError::InvalidArgument(format!(
164 "treatment level {} must occur in the data, and other levels too",
165 self.treatment_level
166 )));
167 }
168 Ok(())
169 }
170
171 fn strata(&self, data: &DmlData) -> Option<Vec<usize>> {
172 Some(level_codes(&data.treatment(0).to_vec()))
173 }
174
175 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
176 let ScoreInput { y, d, x, rep, .. } = *input;
177 let level = self.treatment_level;
178 let at_level: Vec<bool> = d.iter().map(|&v| v == level).collect();
179 let others: Vec<bool> = at_level.iter().map(|t| !t).collect();
180 let treated: Array1<f64> = at_level.iter().map(|&t| f64::from(t)).collect();
181
182 let nu = &self.nuisance;
185 let g1 = input.predictions_or("ml_g_d_lvl1", || {
186 let ml = nu.regressor("ml_g_d_lvl1", self.ml_g.as_ref());
187 cross_fit_predict_subset(ml, x, y, folds, &at_level)
188 })?;
189 let dx: Array2<f64> = concatenate![Axis(1), d.insert_axis(Axis(1)), x];
190 let g0 = input.predictions_or("ml_g_d_lvl0", || {
191 let ml = nu.regressor("ml_g_d_lvl0", self.ml_g.as_ref());
192 cross_fit_predict_subset(ml, dx.view(), y, folds, &others)
193 })?;
194 let m_raw = input.predictions_or("ml_m", || {
195 let ml = nu.classifier("ml_m", self.ml_m.as_ref());
196 cross_fit_predict_proba(ml, x, treated.view(), folds)
197 })?;
198 let m = adjust_propensity(
199 m_raw,
200 treated.view(),
201 folds,
202 self.clipping_threshold,
203 self.calibration,
204 )?;
205 let m_adj = if self.normalize_ipw {
206 normalize_propensity(&m, &treated)
207 } else {
208 m.clone()
209 };
210
211 let (w, w_bar) = ScoreWeights::ate(self.weights.as_ref(), d.len(), rep)?;
212 let psi_b = &w * &g1 + &(&w_bar * &treated * &(&y - &g1) / &m_adj);
213 let psi_a = -(&w / w.mean().expect("n > 0"));
214
215 let outcome_residual = &y - &(&treated * &g1) - &(&(1.0 - &treated) * &g0);
218 let riesz = &w_bar * &treated / &m_adj;
219 let nu2 = 2.0 * &w * &w_bar / &m_adj - &riesz.mapv(|a| a * a);
220 Ok(ScoreEstimate::new(
221 LinearScore::new(psi_a, psi_b.clone())?,
222 vec![("ml_g_d_lvl0", g0), ("ml_g_d_lvl1", g1), ("ml_m", m)],
223 )
224 .with_sensitivity(SensitivityParts {
225 outcome_residual,
226 theta_factor: None,
227 riesz,
228 nu2: Nu2Parts::Elementwise(nu2),
229 })
230 .with_targets(vec![
231 NuisanceTarget::regression("ml_g_d_lvl0", y.to_owned()).only_where(|i| !at_level[i]),
232 NuisanceTarget::regression("ml_g_d_lvl1", y.to_owned()).only_where(|i| at_level[i]),
233 NuisanceTarget::classification("ml_m", treated.clone()),
234 ])
235 .with_cate_signal(CateSignal {
237 signal: psi_b,
238 theta_factor: None,
239 basis_scale: None,
240 is_effect: false,
241 }))
242 }
243}
244
245pub struct Apos {
248 ml_g: Box<dyn Regressor>,
249 ml_m: Box<dyn Classifier>,
250 treatment_levels: Vec<f64>,
251 clipping_threshold: f64,
252 calibration: Calibration,
253 normalize_ipw: bool,
254 weights: Option<ScoreWeights>,
255 splitting: SampleSplitting,
256}
257
258impl Apos {
259 pub fn new(
260 ml_g: impl Regressor + 'static,
261 ml_m: impl Classifier + 'static,
262 treatment_levels: Vec<f64>,
263 ) -> Self {
264 Self::from_boxed(Box::new(ml_g), Box::new(ml_m), treatment_levels)
265 }
266
267 pub fn from_boxed(
268 ml_g: Box<dyn Regressor>,
269 ml_m: Box<dyn Classifier>,
270 treatment_levels: Vec<f64>,
271 ) -> Self {
272 Self {
273 ml_g,
274 ml_m,
275 treatment_levels,
276 clipping_threshold: 1e-2,
277 calibration: Calibration::None,
278 normalize_ipw: false,
279 weights: None,
280 splitting: SampleSplitting::default(),
281 }
282 }
283
284 pub fn clipping_threshold(mut self, t: f64) -> Self {
286 self.clipping_threshold = t;
287 self
288 }
289 pub fn calibration(mut self, calibration: Calibration) -> Self {
292 self.calibration = calibration;
293 self
294 }
295
296 pub fn normalize_ipw(mut self, normalize: bool) -> Self {
298 self.normalize_ipw = normalize;
299 self
300 }
301
302 pub fn weights(mut self, weights: Array1<f64>) -> Self {
305 self.weights = Some(ScoreWeights {
306 weights,
307 weights_bar: None,
308 });
309 self
310 }
311
312 pub fn weights_with_bar(mut self, weights: Array1<f64>, weights_bar: Array2<f64>) -> Self {
315 self.weights = Some(ScoreWeights {
316 weights,
317 weights_bar: Some(weights_bar),
318 });
319 self
320 }
321
322 pub fn n_folds(mut self, n_folds: usize) -> Self {
324 self.splitting.n_folds = n_folds;
325 self
326 }
327
328 pub fn n_rep(mut self, n_rep: usize) -> Self {
330 self.splitting.n_rep = n_rep;
331 self
332 }
333
334 pub fn seed(mut self, seed: u64) -> Self {
336 self.splitting.seed = seed;
337 self
338 }
339
340 fn level_model(&self, level: f64) -> Apo {
341 let model = Apo::from_boxed(self.ml_g.fresh(), self.ml_m.fresh(), level)
342 .clipping_threshold(self.clipping_threshold)
343 .calibration(self.calibration)
344 .normalize_ipw(self.normalize_ipw);
345 match &self.weights {
346 Some(w) => Apo {
347 weights: Some(w.clone()),
348 ..model
349 },
350 None => model,
351 }
352 }
353
354 fn check_levels(&self, data: &DmlData) -> Result<()> {
355 if data.n_treat() != 1 {
356 return Err(DmlError::InvalidArgument(
357 "APOS requires exactly one (discrete) treatment".into(),
358 ));
359 }
360 if self.treatment_levels.is_empty() {
361 return Err(DmlError::InvalidArgument(
362 "at least one treatment level is required".into(),
363 ));
364 }
365 let d = data.treatment(0);
366 for (i, &level) in self.treatment_levels.iter().enumerate() {
367 if self.treatment_levels[..i].contains(&level) {
368 return Err(DmlError::InvalidArgument(format!(
369 "treatment level {level} is listed twice"
370 )));
371 }
372 if !d.iter().any(|&v| v == level) {
373 return Err(DmlError::InvalidArgument(format!(
374 "treatment level {level} does not occur in the data"
375 )));
376 }
377 }
378 Ok(())
379 }
380
381 pub fn fit(&self, data: &DmlData) -> Result<AposFit> {
384 self.check_levels(data)?;
385 match data.clusters() {
386 Some(clusters) => {
387 let splits = self.splitting.draw_clustered(clusters)?;
388 self.fit_with_cluster_folds(data, &splits)
389 }
390 None => {
391 let strata = level_codes(&data.treatment(0).to_vec());
392 let splits = self.splitting.draw_stratified(&strata)?;
393 self.fit_with_folds(data, &splits)
394 }
395 }
396 }
397
398 pub fn fit_with_folds(&self, data: &DmlData, splits: &[Vec<Fold>]) -> Result<AposFit> {
400 self.check_levels(data)?;
401 let fits = self
402 .treatment_levels
403 .iter()
404 .map(|&level| self.level_model(level).fit_with_folds(data, splits))
405 .collect::<Result<Vec<_>>>()?;
406 self.assemble(fits)
407 }
408
409 pub fn fit_with_cluster_folds(
411 &self,
412 data: &DmlData,
413 splits: &[Vec<ClusterFold>],
414 ) -> Result<AposFit> {
415 self.check_levels(data)?;
416 let fits = self
417 .treatment_levels
418 .iter()
419 .map(|&level| self.level_model(level).fit_with_cluster_folds(data, splits))
420 .collect::<Result<Vec<_>>>()?;
421 self.assemble(fits)
422 }
423
424 fn assemble(&self, fits: Vec<DmlFit>) -> Result<AposFit> {
425 let mut fit = DmlFit::concat(&fits)?;
426 fit.model = "APOS".into();
427 fit.treatment_names = self
428 .treatment_levels
429 .iter()
430 .map(|l| l.to_string())
431 .collect();
432 Ok(AposFit {
433 levels: self.treatment_levels.clone(),
434 fit,
435 })
436 }
437}
438
439#[derive(Debug, Clone)]
442pub struct AposFit {
443 levels: Vec<f64>,
444 fit: DmlFit,
445}
446
447impl AposFit {
448 pub fn levels(&self) -> &[f64] {
449 &self.levels
450 }
451
452 pub fn apos(&self) -> &DmlFit {
454 &self.fit
455 }
456
457 pub fn causal_contrast(&self, reference_levels: &[f64]) -> Result<DmlFit> {
464 let k = self.levels.len();
465 if k < 2 {
466 return Err(DmlError::InvalidArgument(
467 "only one treatment level: no causal contrast can be computed".into(),
468 ));
469 }
470 let mut rows: Vec<(usize, usize)> = Vec::new();
471 let mut used = vec![false; k];
472 for &reference in reference_levels {
473 let r = self
474 .levels
475 .iter()
476 .position(|&l| l == reference)
477 .ok_or_else(|| {
478 DmlError::InvalidArgument(format!(
479 "reference level {reference} is not one of the fitted treatment levels"
480 ))
481 })?;
482 used[r] = true;
483 rows.extend((0..k).filter(|&i| !used[i]).map(|i| (i, r)));
484 }
485 if rows.is_empty() {
486 return Err(DmlError::InvalidArgument(
487 "no contrasts: give at least one reference level".into(),
488 ));
489 }
490 let mut weights = Array2::<f64>::zeros((rows.len(), k));
491 for (c, &(i, r)) in rows.iter().enumerate() {
492 weights[[c, i]] = 1.0;
493 weights[[c, r]] = -1.0;
494 }
495 let names = rows
496 .iter()
497 .map(|&(i, r)| format!("{} vs {}", self.levels[i], self.levels[r]))
498 .collect();
499 let mut contrast = self.fit.combine("APOS contrasts".into(), names, &weights);
500 if let Some(store) = &self.fit.sensitivity {
501 let n_rep = self.fit.n_rep();
502 let mut combined = crate::sensitivity::SensitivityStore::new(
503 self.fit.n_obs,
504 rows.len(),
505 n_rep,
506 store.var_y,
507 );
508 for (c, &(i, r)) in rows.iter().enumerate() {
509 for rep in 0..n_rep {
510 let (a, b) = (store.elements(i, rep), store.elements(r, rep));
511 combined.set(
512 c,
513 rep,
514 &Elements {
515 sigma2: (a.sigma2 + b.sigma2) / 2.0,
516 nu2: a.nu2 + b.nu2,
517 psi_sigma2: (&a.psi_sigma2 + &b.psi_sigma2) / 2.0,
518 psi_nu2: &a.psi_nu2 + &b.psi_nu2,
519 riesz: None,
520 nu2_fallback: false,
521 },
522 );
523 }
524 }
525 contrast.sensitivity = Some(combined);
526 }
527 Ok(contrast)
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::learner::{LogisticRegression, Ols};
535 use ndarray::array;
536
537 #[test]
538 fn level_codes_index_sorted_distinct_values() {
539 assert_eq!(level_codes(&[2.0, 0.0, 1.0, 2.0, 0.0]), vec![2, 0, 1, 2, 0]);
540 }
541
542 #[test]
543 fn rejects_missing_levels_and_single_level_contrasts() {
544 let data = DmlData::new(
545 array![1.0, 2.0, 3.0, 4.0],
546 array![0.0, 1.0, 0.0, 1.0],
547 array![[1.0], [2.0], [3.0], [4.0]],
548 )
549 .unwrap();
550 assert!(Apo::new(Ols::new(), LogisticRegression::default(), 5.0)
551 .validate(&data)
552 .is_err());
553 let apos = Apos::new(Ols::new(), LogisticRegression::default(), vec![0.0, 0.0]);
554 assert!(apos.check_levels(&data).is_err());
555 }
556}