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