Skip to main content

dml_core/
propensity.rs

1//! Propensity score processing, as DoubleML's `PSProcessor`: optional
2//! isotonic calibration of the cross-fitted propensity scores against the
3//! treatment (or instrument), followed by clipping to `[t, 1 − t]`.
4//!
5//! [`IsotonicRegression`] is a port of scikit-learn's
6//! `IsotonicRegression(out_of_bounds="clip", y_min=0, y_max=1)`.
7
8use ndarray::{Array1, ArrayView1};
9
10use crate::crossfit::Fold;
11use crate::data::gather;
12use crate::error::{DmlError, Result};
13
14/// Calibration of propensity scores before clipping.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Calibration {
17    /// Clip only (default).
18    #[default]
19    None,
20    /// Isotonic regression of the treatment on the propensity scores, fitted
21    /// on all observations.
22    Isotonic,
23    /// Isotonic regression cross-fitted on the model's sample splits.
24    IsotonicCrossFitted,
25}
26
27/// Monotone (non-decreasing) regression of `y` on a scalar `x`, predicting
28/// by linear interpolation and clipping inputs to the training range, as
29/// scikit-learn's `IsotonicRegression(out_of_bounds="clip")`.
30#[derive(Debug, Clone)]
31pub struct IsotonicRegression {
32    x: Vec<f64>,
33    y: Vec<f64>,
34}
35
36impl IsotonicRegression {
37    /// Fit with fitted values bounded to `[y_min, y_max]`.
38    pub fn fit(
39        x: ArrayView1<'_, f64>,
40        y: ArrayView1<'_, f64>,
41        y_min: f64,
42        y_max: f64,
43    ) -> Result<Self> {
44        if x.len() != y.len() || x.is_empty() {
45            return Err(DmlError::DimensionMismatch(
46                "isotonic regression needs equally long, non-empty x and y".into(),
47            ));
48        }
49        if !x.iter().chain(y.iter()).all(|v| v.is_finite()) {
50            return Err(DmlError::InvalidArgument(
51                "isotonic regression: data contain NaN or infinite values".into(),
52            ));
53        }
54        // Sort by x, then y (np.lexsort((y, X))).
55        let mut order: Vec<usize> = (0..x.len()).collect();
56        order.sort_by(|&a, &b| x[a].total_cmp(&x[b]).then(y[a].total_cmp(&y[b])));
57        // Merge x values closer than float64 resolution, averaging y.
58        let eps = 1e-15;
59        let (mut ux, mut uy, mut uw) = (Vec::new(), Vec::new(), Vec::new());
60        let (mut cur_x, mut cur_y, mut cur_w) = (x[order[0]], 0.0, 0.0);
61        for &i in &order {
62            if x[i] - cur_x >= eps {
63                ux.push(cur_x);
64                uw.push(cur_w);
65                uy.push(cur_y / cur_w);
66                cur_x = x[i];
67                cur_w = 1.0;
68                cur_y = y[i];
69            } else {
70                cur_w += 1.0;
71                cur_y += y[i];
72            }
73        }
74        ux.push(cur_x);
75        uw.push(cur_w);
76        uy.push(cur_y / cur_w);
77        let mut fitted = pava(&uy, &uw);
78        for v in &mut fitted {
79            *v = v.clamp(y_min, y_max);
80        }
81        // Keep the first and last point and every point where the fit changes.
82        let n = fitted.len();
83        let keep: Vec<usize> = (0..n)
84            .filter(|&i| {
85                i == 0 || i == n - 1 || fitted[i] != fitted[i - 1] || fitted[i] != fitted[i + 1]
86            })
87            .collect();
88        Ok(Self {
89            x: keep.iter().map(|&i| ux[i]).collect(),
90            y: keep.iter().map(|&i| fitted[i]).collect(),
91        })
92    }
93
94    /// Predictions by linear interpolation (SciPy's `interp1d`), with inputs
95    /// clipped to the training range.
96    pub fn predict(&self, t: ArrayView1<'_, f64>) -> Array1<f64> {
97        let (x, y) = (&self.x, &self.y);
98        if x.len() == 1 {
99            return Array1::from_elem(t.len(), y[0]);
100        }
101        let (lo_x, hi_x) = (x[0], x[x.len() - 1]);
102        t.mapv(|v| {
103            let v = v.clamp(lo_x, hi_x);
104            // searchsorted(side="left"), clipped to [1, n − 1].
105            let idx = x.partition_point(|&xi| xi < v).clamp(1, x.len() - 1);
106            let (x_lo, x_hi, y_lo, y_hi) = (x[idx - 1], x[idx], y[idx - 1], y[idx]);
107            ((v - x_lo) / (x_hi - x_lo)) * y_hi + ((x_hi - v) / (x_hi - x_lo)) * y_lo
108        })
109    }
110}
111
112/// Pool-adjacent-violators: the weighted non-decreasing fit of `y`.
113fn pava(y: &[f64], w: &[f64]) -> Vec<f64> {
114    // Blocks of (weighted sum, weight, length), merged while they violate
115    // monotonicity.
116    let mut blocks: Vec<(f64, f64, usize)> = Vec::with_capacity(y.len());
117    for (&yi, &wi) in y.iter().zip(w) {
118        let mut block = (wi * yi, wi, 1usize);
119        while let Some(&(s, ww, len)) = blocks.last() {
120            if s / ww >= block.0 / block.1 {
121                blocks.pop();
122                block = (s + block.0, ww + block.1, len + block.2);
123            } else {
124                break;
125            }
126        }
127        blocks.push(block);
128    }
129    let mut out = Vec::with_capacity(y.len());
130    for (s, ww, len) in blocks {
131        out.extend(std::iter::repeat(s / ww).take(len));
132    }
133    out
134}
135
136/// Calibrate (optionally) and clip propensity scores `m` of the 0/1
137/// `labels` (DoubleML's `PSProcessor.adjust_ps`).
138pub(crate) fn adjust_propensity(
139    m: Array1<f64>,
140    labels: ArrayView1<'_, f64>,
141    folds: &[Fold],
142    clipping_threshold: f64,
143    calibration: Calibration,
144) -> Result<Array1<f64>> {
145    let calibrated = match calibration {
146        Calibration::None => m,
147        Calibration::Isotonic => {
148            IsotonicRegression::fit(m.view(), labels, 0.0, 1.0)?.predict(m.view())
149        }
150        Calibration::IsotonicCrossFitted => {
151            let mut out = Array1::from_elem(m.len(), f64::NAN);
152            for fold in folds {
153                let iso = IsotonicRegression::fit(
154                    gather(m.view(), &fold.train).view(),
155                    gather(labels, &fold.train).view(),
156                    0.0,
157                    1.0,
158                )?;
159                let pred = iso.predict(gather(m.view(), &fold.test).view());
160                for (p, &i) in pred.iter().zip(&fold.test) {
161                    out[i] = *p;
162                }
163            }
164            out
165        }
166    };
167    let t = clipping_threshold;
168    Ok(calibrated.mapv(|p| p.clamp(t, 1.0 - t)))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use ndarray::array;
175
176    #[test]
177    fn pava_matches_scikit_learn_example() {
178        // sklearn.isotonic.isotonic_regression([5, 3, 1, 2, 8, 10, 7, 9, 6, 4])
179        let fit = pava(
180            &[5.0, 3.0, 1.0, 2.0, 8.0, 10.0, 7.0, 9.0, 6.0, 4.0],
181            &[1.0; 10],
182        );
183        let expected = [
184            2.75,
185            2.75,
186            2.75,
187            2.75,
188            44.0 / 6.0,
189            44.0 / 6.0,
190            44.0 / 6.0,
191            44.0 / 6.0,
192            44.0 / 6.0,
193            44.0 / 6.0,
194        ];
195        for (a, b) in fit.iter().zip(expected) {
196            assert!((a - b).abs() < 1e-12);
197        }
198    }
199
200    #[test]
201    fn matches_scikit_learn_with_ties() {
202        // sklearn IsotonicRegression(out_of_bounds="clip", y_min=0, y_max=1).fit(x, y).predict(t)
203        let x = array![
204            0.6, 0.3, 0.0, 0.0, 0.8, 0.9, 0.6, 0.7, 0.5, 0.9, 0.8, 0.0, 0.9, 0.0, 0.7, 0.2, 0.9,
205            0.5, 0.3, 0.4, 0.0, 0.1, 0.7, 0.6
206        ];
207        let y = array![
208            0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0,
209            1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0
210        ];
211        let t = array![-0.1, 0.0, 0.05, 0.33, 0.5, 0.71, 0.95, 1.2];
212        let expected = [0.0, 0.0, 0.0, 0.15000000000000008, 0.5, 0.7, 1.0, 1.0];
213        let p = IsotonicRegression::fit(x.view(), y.view(), 0.0, 1.0)
214            .unwrap()
215            .predict(t.view());
216        for (a, b) in p.iter().zip(expected) {
217            assert!((a - b).abs() < 1e-12, "{a} vs {b}");
218        }
219    }
220
221    #[test]
222    fn interpolates_and_clips() {
223        let iso = IsotonicRegression::fit(
224            array![0.1, 0.2, 0.3, 0.4].view(),
225            array![0.0, 1.0, 0.0, 1.0].view(),
226            0.0,
227            1.0,
228        )
229        .unwrap();
230        // Fit: [0, 0.5, 0.5, 1]; predictions interpolate and clip to [0.1, 0.4].
231        let p = iso.predict(array![0.0, 0.15, 0.25, 0.35, 1.0].view());
232        let expected = [0.0, 0.25, 0.5, 0.75, 1.0];
233        for (a, b) in p.iter().zip(expected) {
234            assert!((a - b).abs() < 1e-12, "{a} vs {b}");
235        }
236    }
237}