use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct CalibrationBin {
pub bin_lower: f64,
pub bin_upper: f64,
pub avg_confidence: f64,
pub avg_accuracy: f64,
pub count: usize,
}
pub fn scores_in_unit_interval(scores: &[f64]) -> Result<(), String> {
let mut n_bad = 0usize;
let mut first_bad = None;
for &s in scores {
if !(0.0..=1.0).contains(&s) {
n_bad += 1;
first_bad.get_or_insert(s);
}
}
let Some(bad) = first_bad else {
return Ok(());
};
Err(format!(
"requires scores in [0, 1], found {bad} ({n_bad} of {} out of range). \
Raw logits or unnormalized scores bucket into the end bins and produce \
a meaningless calibration error — apply a sigmoid or softmax first.",
scores.len()
))
}
pub fn calibration_curve(scores: &[f64], matched: &[bool], n_bins: usize) -> Vec<CalibrationBin> {
assert_eq!(
scores.len(),
matched.len(),
"calibration_curve: scores and matched must be parallel arrays (got {} vs {})",
scores.len(),
matched.len()
);
if n_bins == 0 {
return Vec::new();
}
let mut bins: Vec<CalibrationBin> = (0..n_bins)
.map(|i| CalibrationBin {
bin_lower: i as f64 / n_bins as f64,
bin_upper: (i + 1) as f64 / n_bins as f64,
avg_confidence: 0.0,
avg_accuracy: 0.0,
count: 0,
})
.collect();
for (&score, &hit) in scores.iter().zip(matched) {
let idx = ((score * n_bins as f64) as usize).min(n_bins - 1);
bins[idx].avg_confidence += score;
bins[idx].avg_accuracy += if hit { 1.0 } else { 0.0 };
bins[idx].count += 1;
}
for bin in &mut bins {
if bin.count > 0 {
let n = bin.count as f64;
bin.avg_confidence /= n;
bin.avg_accuracy /= n;
}
}
bins
}
pub fn calibration_error(bins: &[CalibrationBin]) -> (f64, f64) {
let total: usize = bins.iter().map(|b| b.count).sum();
if total == 0 {
return (0.0, 0.0);
}
let mut ece = 0.0;
let mut mce = 0.0f64;
for bin in bins {
if bin.count > 0 {
let gap = (bin.avg_accuracy - bin.avg_confidence).abs();
ece += (bin.count as f64 / total as f64) * gap;
mce = mce.max(gap);
}
}
(ece, mce)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
#[test]
fn ece_matches_netcal() {
#[derive(serde::Deserialize)]
struct Case {
n_bins: usize,
style: String,
scores: Vec<f64>,
matched: Vec<bool>,
ece: f64,
}
let data = include_str!("testdata/calibration_netcal.json");
let cases: Vec<Case> = serde_json::from_str(data).expect("parse fixture");
assert!(cases.len() > 150, "fixture looks truncated");
let mut worst = 0.0f64;
for (i, c) in cases.iter().enumerate() {
let bins = calibration_curve(&c.scores, &c.matched, c.n_bins);
let (ece, _) = calibration_error(&bins);
let diff = (ece - c.ece).abs();
worst = worst.max(diff);
assert!(
diff < 1e-12,
"case {i} ({}, n_bins={}, n={}): ECE {ece} vs netcal {} (diff {diff:.3e})",
c.style,
c.n_bins,
c.scores.len(),
c.ece
);
}
println!("ECE worst deviation from netcal: {worst:.3e}");
}
#[test]
fn calibration_binning_contract() {
let mut rng = StdRng::seed_from_u64(0xCA11B);
for case in 0..5000 {
let n_bins = rng.random_range(1..=20);
let n = rng.random_range(0..=60);
let heavy_low = rng.random_bool(0.5);
let scores: Vec<f64> = (0..n)
.map(|_| {
if heavy_low && rng.random_bool(0.9) {
rng.random_range(0.0..=0.1)
} else {
rng.random_range(0.0..=1.0)
}
})
.collect();
let matched: Vec<bool> = (0..n).map(|_| rng.random_bool(0.5)).collect();
let bins = calibration_curve(&scores, &matched, n_bins);
let ctx = format!("case {case}: n_bins={n_bins} n={n}");
assert_eq!(bins.len(), n_bins, "{ctx}");
assert_eq!(
bins.iter().map(|b| b.count).sum::<usize>(),
n,
"{ctx}: bin counts do not partition the predictions"
);
for (i, b) in bins.iter().enumerate() {
if b.count == 0 {
continue;
}
assert!(
b.avg_confidence >= b.bin_lower - 1e-12
&& b.avg_confidence <= b.bin_upper + 1e-12,
"{ctx}: bin {i} mean confidence {} outside [{}, {}]",
b.avg_confidence,
b.bin_lower,
b.bin_upper
);
assert!(
(0.0..=1.0).contains(&b.avg_accuracy),
"{ctx}: bin {i} accuracy {} outside [0,1]",
b.avg_accuracy
);
}
let (ece, mce) = calibration_error(&bins);
assert!((0.0..=1.0).contains(&ece), "{ctx}: ECE {ece} outside [0,1]");
assert!((0.0..=1.0).contains(&mce), "{ctx}: MCE {mce} outside [0,1]");
assert!(mce >= ece - 1e-12, "{ctx}: MCE {mce} below ECE {ece}");
}
}
#[test]
fn out_of_range_scores_escape_their_bin() {
let bins = calibration_curve(&[5.0, -3.0], &[true, false], 10);
let last = bins.last().expect("10 bins");
assert_eq!(last.count, 1, "a score of 5.0 saturates into the last bin");
assert!(
last.avg_confidence > last.bin_upper,
"expected {} to escape the bin upper bound {}",
last.avg_confidence,
last.bin_upper
);
assert_eq!(bins[0].count, 1, "a negative score saturates into bin 0");
assert!(
bins[0].avg_confidence < bins[0].bin_lower,
"expected {} to fall below the bin lower bound {}",
bins[0].avg_confidence,
bins[0].bin_lower
);
let (ece, _) = calibration_error(&bins);
assert!(ece > 1.0, "expected a meaningless ECE above 1.0, got {ece}");
}
#[test]
fn bins_partition_every_prediction() {
let scores: Vec<f64> = (0..100).map(|i| (i as f64 + 0.5) / 100.0).collect();
let matched: Vec<bool> = (0..100).map(|i| i % 2 == 0).collect();
let bins = calibration_curve(&scores, &matched, 10);
assert_eq!(bins.len(), 10);
for bin in &bins {
assert_eq!(bin.count, 10);
}
assert_eq!(bins.iter().map(|b| b.count).sum::<usize>(), 100);
}
#[test]
fn confidence_one_lands_in_last_bin_not_off_the_end() {
let bins = calibration_curve(&[1.0], &[true], 10);
assert_eq!(bins[9].count, 1);
assert_eq!(bins.iter().map(|b| b.count).sum::<usize>(), 1);
}
#[test]
fn underconfident_model_has_gap_equal_to_the_shortfall() {
let bins = calibration_curve(&[0.95; 100], &[true; 100], 10);
let (ece, mce) = calibration_error(&bins);
assert!((ece - 0.05).abs() < 1e-9);
assert!((mce - 0.05).abs() < 1e-9);
}
#[test]
fn overconfident_model_gap_is_weighted_by_bin_occupancy() {
let matched: Vec<bool> = (0..100).map(|i| i < 50).collect();
let bins = calibration_curve(&[0.9; 100], &matched, 10);
let (ece, mce) = calibration_error(&bins);
assert!((ece - 0.4).abs() < 1e-9);
assert!((mce - 0.4).abs() < 1e-9);
}
#[test]
fn mce_exceeds_ece_when_a_small_bin_is_badly_off() {
let mut scores = vec![0.05; 99];
let mut matched = vec![false; 99];
scores.push(0.95);
matched.push(false);
let bins = calibration_curve(&scores, &matched, 10);
let (ece, mce) = calibration_error(&bins);
assert!((mce - 0.95).abs() < 1e-9);
assert!(
ece < 0.06,
"ece={ece} should be diluted by the 99 good bins"
);
}
#[test]
fn empty_input_is_zero_not_a_panic() {
assert_eq!(
calibration_error(&calibration_curve(&[], &[], 10)),
(0.0, 0.0)
);
assert!(calibration_curve(&[0.5], &[true], 0).is_empty());
assert_eq!(calibration_error(&[]), (0.0, 0.0));
}
#[test]
#[should_panic(expected = "parallel arrays")]
fn mismatched_array_lengths_panic_instead_of_truncating() {
calibration_curve(&[0.9, 0.9, 0.9], &[true], 10);
}
}