1use ndarray::{Array1, ArrayView1};
9
10use crate::crossfit::Fold;
11use crate::data::gather;
12use crate::error::{DmlError, Result};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Calibration {
17 #[default]
19 None,
20 Isotonic,
23 IsotonicCrossFitted,
25}
26
27#[derive(Debug, Clone)]
31pub struct IsotonicRegression {
32 x: Vec<f64>,
33 y: Vec<f64>,
34}
35
36impl IsotonicRegression {
37 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 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 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 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 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 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
112fn pava(y: &[f64], w: &[f64]) -> Vec<f64> {
114 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
136pub(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 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 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 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}