1use ndarray::{concatenate, Array1, ArrayView1, ArrayView2, Axis};
21
22use super::quantile::half_split;
23use super::{
24 dml_model_api, nuisance_api, LinearScoreModel, NuisanceLearners, NuisanceTarget, ScoreEstimate,
25 ScoreInput,
26};
27use crate::crossfit::{cross_fit_predict_proba, cross_fit_predict_subset, Fold, SampleSplitting};
28use crate::data::{gather, is_binary, DmlData};
29use crate::error::{DmlError, Result};
30use crate::learner::{Classifier, Regressor};
31use crate::propensity::{adjust_propensity, Calibration};
32use crate::score::LinearScore;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum SsmScore {
37 #[default]
39 MissingAtRandom,
40 Nonignorable,
42}
43
44pub struct Ssm {
58 nuisance: NuisanceLearners,
59 ml_g: Box<dyn Regressor>,
60 ml_pi: Box<dyn Classifier>,
61 ml_m: Box<dyn Classifier>,
62 score: SsmScore,
63 clipping_threshold: f64,
64 calibration: Calibration,
65 normalize_ipw: bool,
66 splitting: SampleSplitting,
67}
68
69impl Ssm {
70 pub fn new(
73 ml_g: impl Regressor + 'static,
74 ml_pi: impl Classifier + 'static,
75 ml_m: impl Classifier + 'static,
76 ) -> Self {
77 Self::from_boxed(Box::new(ml_g), Box::new(ml_pi), Box::new(ml_m))
78 }
79
80 pub fn from_boxed(
81 ml_g: Box<dyn Regressor>,
82 ml_pi: Box<dyn Classifier>,
83 ml_m: Box<dyn Classifier>,
84 ) -> Self {
85 Self {
86 nuisance: NuisanceLearners::default(),
87 ml_g,
88 ml_pi,
89 ml_m,
90 score: SsmScore::default(),
91 clipping_threshold: 1e-2,
92 calibration: Calibration::None,
93 normalize_ipw: false,
94 splitting: SampleSplitting::default(),
95 }
96 }
97
98 pub fn score(mut self, score: SsmScore) -> Self {
99 self.score = score;
100 self
101 }
102
103 pub fn clipping_threshold(mut self, t: f64) -> Self {
106 self.clipping_threshold = t;
107 self
108 }
109 pub fn calibration(mut self, calibration: Calibration) -> Self {
112 self.calibration = calibration;
113 self
114 }
115
116 pub fn normalize_ipw(mut self, normalize: bool) -> Self {
118 self.normalize_ipw = normalize;
119 self
120 }
121
122 dml_model_api!();
123 nuisance_api!();
124
125 fn nonignorable_nuisances(
128 &self,
129 input: &ScoreInput<'_>,
130 s: ArrayView1<'_, f64>,
131 z: ArrayView1<'_, f64>,
132 folds: &[Fold],
133 ) -> Result<[Array1<f64>; 4]> {
134 let ScoreInput { y, d, x, .. } = *input;
135 let n = y.len();
136 let strata: Vec<f64> = (0..n).map(|i| d[i] + 2.0 * s[i]).collect();
137 let dxz = concatenate![Axis(1), x, d.insert_axis(Axis(1)), z.insert_axis(Axis(1))];
138 let fit_fold = |fold: &Fold| -> Result<[Array1<f64>; 4]> {
139 let (train1, train2) = half_split(&fold.train, &strata)?;
140 let ml_pi = self.nuisance.classifier("ml_pi", self.ml_pi.as_ref());
143 let pi_all = fit_proba(ml_pi, dxz.view(), &train1, s, dxz.view())?;
144 let xpi = concatenate![Axis(1), x, pi_all.view().insert_axis(Axis(1))];
145 let xpi_test = xpi.select(Axis(0), &fold.test);
146 let ml_m = self.nuisance.classifier("ml_m", self.ml_m.as_ref());
147 let m = fit_proba(ml_m, xpi.view(), &train2, d, xpi_test.view())?;
148 let mut sorted2 = train2.clone();
151 sorted2.sort_unstable();
152 let outcome = |level: f64| -> Result<Array1<f64>> {
153 let rows: Vec<usize> = sorted2
154 .iter()
155 .copied()
156 .filter(|&i| d[i] == level && s[i] == 1.0)
157 .collect();
158 if rows.is_empty() {
159 return Err(DmlError::InvalidArgument(
160 "a training fold has no selected observations in a treatment arm".into(),
161 ));
162 }
163 let name = if level == 1.0 { "ml_g_d1" } else { "ml_g_d0" };
164 let mut model = self.nuisance.regressor(name, self.ml_g.as_ref()).fresh();
165 model.fit(xpi.select(Axis(0), &rows).view(), gather(y, &rows).view())?;
166 model.predict(xpi_test.view())
167 };
168 let pi_test = gather(pi_all.view(), &fold.test);
169 Ok([outcome(0.0)?, outcome(1.0)?, pi_test, m])
170 };
171 let per_fold: Vec<[Array1<f64>; 4]> =
172 crate::crossfit::map_folds(folds.len(), |k| fit_fold(&folds[k]))?;
173
174 let mut out: [Array1<f64>; 4] = std::array::from_fn(|_| Array1::from_elem(n, f64::NAN));
175 for (fold, preds) in folds.iter().zip(per_fold) {
176 for (dst, src) in out.iter_mut().zip(preds) {
177 for (k, &i) in fold.test.iter().enumerate() {
178 dst[i] = src[k];
179 }
180 }
181 }
182 Ok(out)
183 }
184}
185
186fn fit_proba(
188 learner: &dyn Classifier,
189 x: ArrayView2<'_, f64>,
190 rows: &[usize],
191 labels: ArrayView1<'_, f64>,
192 x_pred: ArrayView2<'_, f64>,
193) -> Result<Array1<f64>> {
194 let mut model = learner.fresh();
195 model.fit(x.select(Axis(0), rows).view(), gather(labels, rows).view())?;
196 model.predict_proba(x_pred)
197}
198
199impl LinearScoreModel for Ssm {
200 fn name(&self) -> String {
201 match self.score {
202 SsmScore::MissingAtRandom => "SSM (missing at random)".into(),
203 SsmScore::Nonignorable => "SSM (nonignorable)".into(),
204 }
205 }
206
207 fn validate(&self, data: &DmlData) -> Result<()> {
208 self.nuisance
209 .check("SSM", &["ml_g_d0", "ml_g_d1"], &["ml_pi", "ml_m"])?;
210 if !(0..data.n_treat()).all(|j| data.is_binary_treatment(j)) {
211 return Err(DmlError::InvalidArgument(
212 "SSM needs binary (0/1) treatments".into(),
213 ));
214 }
215 if data.selection().is_none() {
216 return Err(DmlError::InvalidArgument(
217 "SSM needs a selection indicator (DmlData::with_selection_indicator)".into(),
218 ));
219 }
220 if data.n_instruments() > 1 {
221 return Err(DmlError::InvalidArgument(
222 "SSM needs exactly one instrument".into(),
223 ));
224 }
225 if self.score == SsmScore::Nonignorable && data.z().is_none() {
226 return Err(DmlError::InvalidArgument(
227 "the nonignorable score needs an instrument for selection (DmlData::with_instrument)"
228 .into(),
229 ));
230 }
231 if !(self.clipping_threshold > 0.0 && self.clipping_threshold < 0.5) {
232 return Err(DmlError::InvalidArgument(
233 "clipping_threshold must be in (0, 0.5)".into(),
234 ));
235 }
236 Ok(())
237 }
238
239 fn strata(&self, data: &DmlData) -> Option<Vec<usize>> {
241 let (d, s) = (data.treatment(0), data.selection()?);
242 Some(
243 d.iter()
244 .zip(s)
245 .map(|(&d, &s)| (d + 2.0 * s) as usize)
246 .collect(),
247 )
248 }
249
250 fn estimate_score(&self, input: &ScoreInput<'_>, folds: &[Fold]) -> Result<ScoreEstimate> {
251 let ScoreInput { y, d, x, z, s, .. } = *input;
252 let s =
253 s.ok_or_else(|| DmlError::InvalidArgument("SSM needs a selection indicator".into()))?;
254 if !is_binary(d) {
255 return Err(DmlError::InvalidArgument(
256 "SSM needs a binary treatment".into(),
257 ));
258 }
259 let n = y.len();
260 let [g0, g1, pi, m] = match self.score {
261 SsmScore::MissingAtRandom => {
262 let dx = concatenate![Axis(1), x, d.insert_axis(Axis(1))];
263 let nu = &self.nuisance;
264 let ml_pi = nu.classifier("ml_pi", self.ml_pi.as_ref());
265 let pi = cross_fit_predict_proba(ml_pi, dx.view(), s, folds)?;
266 let m = cross_fit_predict_proba(
267 nu.classifier("ml_m", self.ml_m.as_ref()),
268 x,
269 d,
270 folds,
271 )?;
272 let selected = |level: f64| -> Vec<bool> {
273 (0..n).map(|i| d[i] == level && s[i] == 1.0).collect()
274 };
275 let (g_d1, g_d0) = (
276 nu.regressor("ml_g_d1", self.ml_g.as_ref()),
277 nu.regressor("ml_g_d0", self.ml_g.as_ref()),
278 );
279 let g1 = cross_fit_predict_subset(g_d1, x, y, folds, &selected(1.0))?;
280 let g0 = cross_fit_predict_subset(g_d0, x, y, folds, &selected(0.0))?;
281 [g0, g1, pi, m]
282 }
283 SsmScore::Nonignorable => {
284 let z = z.ok_or_else(|| {
285 DmlError::InvalidArgument("the nonignorable score needs an instrument".into())
286 })?;
287 self.nonignorable_nuisances(input, s, z, folds)?
288 }
289 };
290 let t = self.clipping_threshold;
291 let m = adjust_propensity(m, d, folds, t, self.calibration)?;
292
293 let treat: Array1<f64> = d.mapv(|v| f64::from(v == 1.0));
297 let control: Array1<f64> = d.mapv(|v| f64::from(v == 0.0));
298 let (w1, w0) = if self.normalize_ipw {
299 let sum = |a: Array1<f64>| a.iter().fold(0.0, |acc, &v| acc + v);
300 let w1 = treat.sum() / sum(&treat * &s / &(&pi * &m));
301 let w0 = control.sum() / sum(&control * &s / &(&pi * &(1.0 - &m)));
302 (Some(w1), Some(w0))
303 } else {
304 (None, None)
305 };
306 let psi_b: Array1<f64> = (0..n)
307 .map(|i| {
308 let c1 = treat[i] * s[i] * (y[i] - g1[i]) / (m[i] * pi[i]);
309 let c0 = control[i] * s[i] * (y[i] - g0[i]) / ((1.0 - m[i]) * pi[i]);
310 let b1 = w1.map_or(c1, |w| w * c1) + g1[i];
311 let b0 = w0.map_or(c0, |w| w * c0) + g0[i];
312 b1 - b0
313 })
314 .collect();
315 let psi_a = Array1::from_elem(n, -1.0);
316 Ok(ScoreEstimate::new(
317 LinearScore::new(psi_a, psi_b)?,
318 vec![("ml_g_d0", g0), ("ml_g_d1", g1), ("ml_pi", pi), ("ml_m", m)],
319 )
320 .with_targets(vec![
322 NuisanceTarget::regression("ml_g_d0", y.to_owned()),
323 NuisanceTarget::regression("ml_g_d1", y.to_owned()),
324 NuisanceTarget::classification("ml_pi", s.to_owned()),
325 NuisanceTarget::classification("ml_m", d.to_owned()),
326 ]))
327 }
328}