use crate::error::{Error, Result};
use crate::frame::{Dataset, Frame};
use crate::traits::{Predictor, ProbaPredictor};
fn sigmoid(z: f64) -> f64 {
1.0 / (1.0 + (-z).exp())
}
fn check(scores: &[f64], labels: &[f64]) -> Result<()> {
if scores.is_empty() || scores.len() != labels.len() {
return Err(Error::Shape(format!(
"calibration: {} scores vs {} labels",
scores.len(),
labels.len()
)));
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct PlattScaling {
a: f64,
b: f64,
}
impl PlattScaling {
pub fn fit(scores: &[f64], labels: &[f64]) -> Result<PlattScaling> {
check(scores, labels)?;
let n = scores.len() as f64;
let (mut a, mut b) = (1.0, 0.0);
let lr = 0.1;
for _ in 0..3000 {
let (mut ga, mut gb) = (0.0, 0.0);
for (s, y) in scores.iter().zip(labels) {
let d = sigmoid(a * s + b) - y; ga += d * s;
gb += d;
}
a -= lr * ga / n;
b -= lr * gb / n;
}
Ok(PlattScaling { a, b })
}
pub fn transform(&self, scores: &[f64]) -> Vec<f64> {
scores
.iter()
.map(|s| sigmoid(self.a * s + self.b))
.collect()
}
}
#[derive(Clone, Debug)]
pub struct IsotonicRegression {
edges: Vec<f64>,
values: Vec<f64>,
}
impl IsotonicRegression {
pub fn fit(scores: &[f64], labels: &[f64]) -> Result<IsotonicRegression> {
check(scores, labels)?;
let mut pairs: Vec<(f64, f64)> =
scores.iter().copied().zip(labels.iter().copied()).collect();
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
let (mut edges, mut values, mut weights): (Vec<f64>, Vec<f64>, Vec<f64>) =
(Vec::new(), Vec::new(), Vec::new());
for (s, y) in pairs {
edges.push(s);
values.push(y);
weights.push(1.0);
while values.len() >= 2 && values[values.len() - 2] >= values[values.len() - 1] {
let n = values.len();
let w = weights[n - 2] + weights[n - 1];
let v = (values[n - 2] * weights[n - 2] + values[n - 1] * weights[n - 1]) / w;
let e = edges[n - 1];
values.truncate(n - 2);
weights.truncate(n - 2);
edges.truncate(n - 2);
values.push(v);
weights.push(w);
edges.push(e);
}
}
Ok(IsotonicRegression { edges, values })
}
pub fn transform(&self, scores: &[f64]) -> Vec<f64> {
scores.iter().map(|&s| self.interp(s)).collect()
}
fn interp(&self, s: f64) -> f64 {
match self.edges.iter().rposition(|&e| e <= s) {
Some(i) => self.values[i],
None => self.values.first().copied().unwrap_or(0.0),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct ReliabilityBin {
pub mean_predicted: f64,
pub fraction_positive: f64,
pub count: usize,
}
pub fn reliability_curve(probs: &[f64], labels: &[f64], bins: usize) -> Vec<ReliabilityBin> {
let bins = bins.max(1);
let mut acc = vec![(0.0f64, 0.0f64, 0usize); bins];
for (p, y) in probs.iter().zip(labels) {
let mut idx = (p * bins as f64) as usize;
if idx >= bins {
idx = bins - 1;
}
acc[idx].0 += p;
acc[idx].1 += y;
acc[idx].2 += 1;
}
acc.into_iter()
.filter(|(_, _, c)| *c > 0)
.map(|(sp, pos, c)| ReliabilityBin {
mean_predicted: sp / c as f64,
fraction_positive: pos / c as f64,
count: c,
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CalibrationMethod {
Platt,
Isotonic,
}
pub struct CalibratedClassifier<M: ProbaPredictor> {
inner: M,
method: CalibrationMethod,
platt: Option<PlattScaling>,
iso: Option<IsotonicRegression>,
neg: f64,
pos: f64,
fitted: bool,
}
impl<M: ProbaPredictor> CalibratedClassifier<M> {
pub fn platt(inner: M) -> Self {
CalibratedClassifier::with(inner, CalibrationMethod::Platt)
}
pub fn isotonic(inner: M) -> Self {
CalibratedClassifier::with(inner, CalibrationMethod::Isotonic)
}
fn with(inner: M, method: CalibrationMethod) -> Self {
CalibratedClassifier {
inner,
method,
platt: None,
iso: None,
neg: 0.0,
pos: 1.0,
fitted: false,
}
}
fn positive_scores(&self, frame: &Frame) -> Result<Vec<f64>> {
let proba = self.inner.predict_proba(frame)?;
Ok(proba.column(proba.ncols() - 1))
}
fn calibrate(&self, scores: &[f64]) -> Result<Vec<f64>> {
match self.method {
CalibrationMethod::Platt => self
.platt
.as_ref()
.map(|c| c.transform(scores))
.ok_or_else(|| Error::NotFitted("CalibratedClassifier".into())),
CalibrationMethod::Isotonic => self
.iso
.as_ref()
.map(|c| c.transform(scores))
.ok_or_else(|| Error::NotFitted("CalibratedClassifier".into())),
}
}
pub fn fit(mut self, data: &Dataset) -> Result<Self> {
let mut classes: Vec<f64> = data.target().to_vec();
classes.sort_by(f64::total_cmp);
classes.dedup();
if classes.len() != 2 {
return Err(Error::Pipeline(format!(
"CalibratedClassifier is binary; found {} classes",
classes.len()
)));
}
self.neg = classes[0];
self.pos = classes[1];
let scores = self.positive_scores(data.features())?;
let y: Vec<f64> = data
.target()
.iter()
.map(|v| if *v == self.pos { 1.0 } else { 0.0 })
.collect();
match self.method {
CalibrationMethod::Platt => self.platt = Some(PlattScaling::fit(&scores, &y)?),
CalibrationMethod::Isotonic => self.iso = Some(IsotonicRegression::fit(&scores, &y)?),
}
self.fitted = true;
Ok(self)
}
}
impl<M: ProbaPredictor> Predictor for CalibratedClassifier<M> {
fn predict(&self, frame: &Frame) -> Result<Vec<f64>> {
let cal = self.calibrate(&self.positive_scores(frame)?)?;
Ok(cal
.into_iter()
.map(|p| if p >= 0.5 { self.pos } else { self.neg })
.collect())
}
}
impl<M: ProbaPredictor> ProbaPredictor for CalibratedClassifier<M> {
fn predict_proba(&self, frame: &Frame) -> Result<Frame> {
let cal = self.calibrate(&self.positive_scores(frame)?)?;
let cols = vec![
format!("p{}", self.neg as i64),
format!("p{}", self.pos as i64),
];
let n = frame.nrows();
let mut buf = Vec::with_capacity(n * 2);
for p in cal {
buf.push(1.0 - p);
buf.push(p);
}
Frame::new(buf, n, 2, cols)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn data() -> (Vec<f64>, Vec<f64>) {
let scores = vec![-3.0, -2.0, -1.0, -0.5, 0.5, 1.0, 2.0, 3.0];
let labels = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
(scores, labels)
}
#[test]
fn platt_is_monotone_and_bounded() {
let (s, y) = data();
let cal = PlattScaling::fit(&s, &y).unwrap();
let p = cal.transform(&s);
assert!(p.iter().all(|&x| (0.0..=1.0).contains(&x)));
assert!(p[0] < 0.5 && *p.last().unwrap() > 0.5);
assert!(p.windows(2).all(|w| w[0] <= w[1] + 1e-9));
}
#[test]
fn isotonic_is_monotone_and_fits() {
let (s, y) = data();
let cal = IsotonicRegression::fit(&s, &y).unwrap();
let p = cal.transform(&s);
assert!(p.windows(2).all(|w| w[0] <= w[1] + 1e-9));
assert!(p[0] < 0.5 && *p.last().unwrap() > 0.5);
}
#[test]
fn reliability_curve_bins_counts() {
let probs = vec![0.05, 0.15, 0.85, 0.95];
let labels = vec![0.0, 0.0, 1.0, 1.0];
let curve = reliability_curve(&probs, &labels, 10);
assert_eq!(curve.iter().map(|b| b.count).sum::<usize>(), 4);
assert_eq!(curve.first().unwrap().fraction_positive, 0.0);
assert_eq!(curve.last().unwrap().fraction_positive, 1.0);
}
#[test]
fn calibrated_classifier_wraps_a_proba_model() {
use crate::logistic::LogisticRegression;
use crate::traits::Estimator;
let rows: Vec<Vec<f64>> = [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0]
.iter()
.map(|&x| vec![x])
.collect();
let ds = Dataset::new(
Frame::from_rows(rows, vec!["x".into()]).unwrap(),
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
)
.unwrap();
let mut inner = LogisticRegression::new();
inner.fit(&ds).unwrap();
let cal = CalibratedClassifier::platt(inner).fit(&ds).unwrap();
let probe = Frame::from_rows(vec![vec![-2.5], vec![2.5]], vec!["x".into()]).unwrap();
assert_eq!(cal.predict(&probe).unwrap(), vec![0.0, 1.0]);
let proba = cal.predict_proba(&probe).unwrap();
assert!((proba.get(0, 0) + proba.get(0, 1) - 1.0).abs() < 1e-9);
assert!(proba.get(1, 1) > 0.5); }
}