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