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