use ndarray::{Array1, ArrayView1};
use crate::crossfit::Fold;
use crate::data::gather;
use crate::error::{DmlError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Calibration {
#[default]
None,
Isotonic,
IsotonicCrossFitted,
}
#[derive(Debug, Clone)]
pub struct IsotonicRegression {
x: Vec<f64>,
y: Vec<f64>,
}
impl IsotonicRegression {
pub fn fit(
x: ArrayView1<'_, f64>,
y: ArrayView1<'_, f64>,
y_min: f64,
y_max: f64,
) -> Result<Self> {
if x.len() != y.len() || x.is_empty() {
return Err(DmlError::DimensionMismatch(
"isotonic regression needs equally long, non-empty x and y".into(),
));
}
if !x.iter().chain(y.iter()).all(|v| v.is_finite()) {
return Err(DmlError::InvalidArgument(
"isotonic regression: data contain NaN or infinite values".into(),
));
}
let mut order: Vec<usize> = (0..x.len()).collect();
order.sort_by(|&a, &b| x[a].total_cmp(&x[b]).then(y[a].total_cmp(&y[b])));
let eps = 1e-15;
let (mut ux, mut uy, mut uw) = (Vec::new(), Vec::new(), Vec::new());
let (mut cur_x, mut cur_y, mut cur_w) = (x[order[0]], 0.0, 0.0);
for &i in &order {
if x[i] - cur_x >= eps {
ux.push(cur_x);
uw.push(cur_w);
uy.push(cur_y / cur_w);
cur_x = x[i];
cur_w = 1.0;
cur_y = y[i];
} else {
cur_w += 1.0;
cur_y += y[i];
}
}
ux.push(cur_x);
uw.push(cur_w);
uy.push(cur_y / cur_w);
let mut fitted = pava(&uy, &uw);
for v in &mut fitted {
*v = v.clamp(y_min, y_max);
}
let n = fitted.len();
let keep: Vec<usize> = (0..n)
.filter(|&i| {
i == 0 || i == n - 1 || fitted[i] != fitted[i - 1] || fitted[i] != fitted[i + 1]
})
.collect();
Ok(Self {
x: keep.iter().map(|&i| ux[i]).collect(),
y: keep.iter().map(|&i| fitted[i]).collect(),
})
}
pub fn predict(&self, t: ArrayView1<'_, f64>) -> Array1<f64> {
let (x, y) = (&self.x, &self.y);
if x.len() == 1 {
return Array1::from_elem(t.len(), y[0]);
}
let (lo_x, hi_x) = (x[0], x[x.len() - 1]);
t.mapv(|v| {
let v = v.clamp(lo_x, hi_x);
let idx = x.partition_point(|&xi| xi < v).clamp(1, x.len() - 1);
let (x_lo, x_hi, y_lo, y_hi) = (x[idx - 1], x[idx], y[idx - 1], y[idx]);
((v - x_lo) / (x_hi - x_lo)) * y_hi + ((x_hi - v) / (x_hi - x_lo)) * y_lo
})
}
}
fn pava(y: &[f64], w: &[f64]) -> Vec<f64> {
let mut blocks: Vec<(f64, f64, usize)> = Vec::with_capacity(y.len());
for (&yi, &wi) in y.iter().zip(w) {
let mut block = (wi * yi, wi, 1usize);
while let Some(&(s, ww, len)) = blocks.last() {
if s / ww >= block.0 / block.1 {
blocks.pop();
block = (s + block.0, ww + block.1, len + block.2);
} else {
break;
}
}
blocks.push(block);
}
let mut out = Vec::with_capacity(y.len());
for (s, ww, len) in blocks {
out.extend(std::iter::repeat(s / ww).take(len));
}
out
}
pub(crate) fn adjust_propensity(
m: Array1<f64>,
labels: ArrayView1<'_, f64>,
folds: &[Fold],
clipping_threshold: f64,
calibration: Calibration,
) -> Result<Array1<f64>> {
let calibrated = match calibration {
Calibration::None => m,
Calibration::Isotonic => {
IsotonicRegression::fit(m.view(), labels, 0.0, 1.0)?.predict(m.view())
}
Calibration::IsotonicCrossFitted => {
let mut out = Array1::from_elem(m.len(), f64::NAN);
for fold in folds {
let iso = IsotonicRegression::fit(
gather(m.view(), &fold.train).view(),
gather(labels, &fold.train).view(),
0.0,
1.0,
)?;
let pred = iso.predict(gather(m.view(), &fold.test).view());
for (p, &i) in pred.iter().zip(&fold.test) {
out[i] = *p;
}
}
out
}
};
let t = clipping_threshold;
Ok(calibrated.mapv(|p| p.clamp(t, 1.0 - t)))
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn pava_matches_scikit_learn_example() {
let fit = pava(
&[5.0, 3.0, 1.0, 2.0, 8.0, 10.0, 7.0, 9.0, 6.0, 4.0],
&[1.0; 10],
);
let expected = [
2.75,
2.75,
2.75,
2.75,
44.0 / 6.0,
44.0 / 6.0,
44.0 / 6.0,
44.0 / 6.0,
44.0 / 6.0,
44.0 / 6.0,
];
for (a, b) in fit.iter().zip(expected) {
assert!((a - b).abs() < 1e-12);
}
}
#[test]
fn matches_scikit_learn_with_ties() {
let x = array![
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,
0.5, 0.3, 0.4, 0.0, 0.1, 0.7, 0.6
];
let y = array![
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,
1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0
];
let t = array![-0.1, 0.0, 0.05, 0.33, 0.5, 0.71, 0.95, 1.2];
let expected = [0.0, 0.0, 0.0, 0.15000000000000008, 0.5, 0.7, 1.0, 1.0];
let p = IsotonicRegression::fit(x.view(), y.view(), 0.0, 1.0)
.unwrap()
.predict(t.view());
for (a, b) in p.iter().zip(expected) {
assert!((a - b).abs() < 1e-12, "{a} vs {b}");
}
}
#[test]
fn interpolates_and_clips() {
let iso = IsotonicRegression::fit(
array![0.1, 0.2, 0.3, 0.4].view(),
array![0.0, 1.0, 0.0, 1.0].view(),
0.0,
1.0,
)
.unwrap();
let p = iso.predict(array![0.0, 0.15, 0.25, 0.35, 1.0].view());
let expected = [0.0, 0.25, 0.5, 0.75, 1.0];
for (a, b) in p.iter().zip(expected) {
assert!((a - b).abs() < 1e-12, "{a} vs {b}");
}
}
}