use serde::Serialize;
use crate::stats::{mean, std_dev};
pub fn brier_score(confidences: &[f64], outcomes: &[bool]) -> f64 {
let n = confidences.len().min(outcomes.len());
if n == 0 {
return 0.0;
}
let mut sum = 0.0;
for (&c, &o) in confidences.iter().zip(outcomes.iter()) {
let outcome = if o { 1.0 } else { 0.0 };
let conf = c.clamp(0.0, 1.0);
sum += (conf - outcome) * (conf - outcome);
}
sum / n as f64
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct UncertaintySplit {
pub aleatoric: f64,
pub epistemic: f64,
pub distributional: f64,
}
pub fn aleatoric_uncertainty(outcomes: &[bool]) -> f64 {
if outcomes.is_empty() {
return 0.0;
}
let base = outcomes.iter().filter(|&&o| o).count() as f64 / outcomes.len() as f64;
(4.0 * base * (1.0 - base)).clamp(0.0, 1.0)
}
pub fn epistemic_uncertainty(signals: &[&[f64]]) -> f64 {
let k = signals.len();
let n = signals.iter().map(|s| s.len()).min().unwrap_or(0);
let disagreement = if k < 2 || n == 0 {
0.0
} else {
let mut acc = 0.0;
for i in 0..n {
let m = signals.iter().map(|s| s[i].clamp(0.0, 1.0)).sum::<f64>() / k as f64;
let var = signals
.iter()
.map(|s| {
let d = s[i].clamp(0.0, 1.0) - m;
d * d
})
.sum::<f64>()
/ k as f64;
acc += var;
}
(4.0 * acc / n as f64).clamp(0.0, 1.0)
};
let thinness = 1.0 / (1.0 + n as f64);
1.0 - (1.0 - disagreement) * (1.0 - thinness)
}
pub fn distributional_uncertainty(case: &[f64], reference: &[f64]) -> f64 {
if case.len() < 2 || reference.len() < 2 {
return 0.0;
}
let (m_case, m_ref) = (mean(case), mean(reference));
let (s_case, s_ref) = (std_dev(case), std_dev(reference));
if s_ref <= 0.0 {
return if m_case == m_ref && s_case <= 0.0 {
0.0
} else {
1.0
};
}
let z_location = (m_case - m_ref).abs() / s_ref;
let z_dispersion = (s_case - s_ref).abs() / s_ref;
let d = z_location.max(z_dispersion);
d / (1.0 + d)
}
pub fn decompose_uncertainty(
outcomes: &[bool],
signals: &[&[f64]],
case_returns: &[f64],
reference_returns: &[f64],
) -> UncertaintySplit {
UncertaintySplit {
aleatoric: aleatoric_uncertainty(outcomes),
epistemic: epistemic_uncertainty(signals),
distributional: distributional_uncertainty(case_returns, reference_returns),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn perfect_calibration_is_zero() {
let c = [1.0, 0.0, 1.0, 0.0];
let o = [true, false, true, false];
assert!(brier_score(&c, &o) < 1e-12);
}
#[test]
fn always_half_is_quarter() {
let c = [0.5; 4];
let o = [true, false, true, false];
assert!((brier_score(&c, &o) - 0.25).abs() < 1e-12);
}
#[test]
fn aleatoric_is_maximal_for_a_coin_flip() {
let o = [true, false, true, false];
assert!((aleatoric_uncertainty(&o) - 1.0).abs() < 1e-12);
assert!((0.25 * aleatoric_uncertainty(&o) - 0.25).abs() < 1e-12);
}
#[test]
fn aleatoric_is_zero_when_the_outcome_never_varies() {
assert_eq!(aleatoric_uncertainty(&[true; 8]), 0.0);
assert_eq!(aleatoric_uncertainty(&[false; 8]), 0.0);
assert_eq!(aleatoric_uncertainty(&[]), 0.0);
}
#[test]
fn epistemic_rises_with_disagreement_between_signals() {
let a = vec![0.9; 50];
let b = vec![0.9; 50];
let against = vec![0.1; 50];
let agreeing = epistemic_uncertainty(&[&a, &b]);
let split = epistemic_uncertainty(&[&a, &against]);
assert!(
split > agreeing,
"disagreement {split} must exceed agreement {agreeing}"
);
let low = vec![0.0; 50];
let high = vec![1.0; 50];
assert!((epistemic_uncertainty(&[&low, &high]) - 1.0).abs() < 1e-12);
}
#[test]
fn epistemic_rises_when_the_evidence_is_thin() {
let long_a = vec![0.7; 100];
let long_b = vec![0.7; 100];
let short_a = vec![0.7; 2];
let short_b = vec![0.7; 2];
let thick = epistemic_uncertainty(&[&long_a, &long_b]);
let thin = epistemic_uncertainty(&[&short_a, &short_b]);
assert!(thin > thick, "thin {thin} must exceed thick {thick}");
assert!((epistemic_uncertainty(&[]) - 1.0).abs() < 1e-12);
}
#[test]
fn unanimous_but_wrong_signals_understate_epistemic() {
let a = vec![1.0; 100];
let b = vec![1.0; 100];
let c = vec![1.0; 100];
let confidently_wrong = epistemic_uncertainty(&[&a, &b, &c]);
let outcomes = vec![false; 100];
assert!(brier_score(&a, &outcomes) > 0.9, "the calls were all wrong");
assert!(
confidently_wrong < 0.02,
"agreement alone drives the leg to {confidently_wrong}"
);
}
#[test]
fn correlated_signals_understate_epistemic() {
let s: Vec<f64> = (0..100)
.map(|i| 0.5 + 0.4 * (i as f64 * 0.3).sin())
.collect();
let duplicated = epistemic_uncertainty(&[&s, &s, &s]);
let single = epistemic_uncertainty(&[&s]);
assert!(
(duplicated - single).abs() < 1e-12,
"three copies read exactly like one: {duplicated} vs {single}"
);
}
#[test]
fn distributional_flags_a_case_that_sits_elsewhere() {
let reference: Vec<f64> = (0..100)
.map(|i| 0.004 + 0.0005 * (i as f64).sin())
.collect();
let same: Vec<f64> = (0..100)
.map(|i| 0.004 + 0.0005 * (i as f64).cos())
.collect();
let shifted: Vec<f64> = reference.iter().map(|r| r - 0.02).collect();
let familiar = distributional_uncertainty(&same, &reference);
let novel = distributional_uncertainty(&shifted, &reference);
assert!(
familiar < 0.5,
"a like-for-like case is familiar: {familiar}"
);
assert!(novel > 0.9, "a displaced case is novel: {novel}");
}
#[test]
fn distributional_flags_a_case_that_moves_differently() {
let reference: Vec<f64> = (0..100)
.map(|i| 0.004 + 0.0005 * (i as f64).sin())
.collect();
let wild: Vec<f64> = (0..100).map(|i| 0.004 + 0.005 * (i as f64).sin()).collect();
assert!(
distributional_uncertainty(&wild, &reference) > 0.8,
"a dispersion shift is a distribution shift too"
);
}
#[test]
fn distributional_claims_nothing_without_a_reference() {
let case = [0.01, 0.02, 0.03];
assert_eq!(distributional_uncertainty(&case, &[]), 0.0);
assert_eq!(distributional_uncertainty(&case, &[0.01]), 0.0);
assert_eq!(distributional_uncertainty(&[], &case), 0.0);
}
#[test]
fn distributional_handles_a_constant_reference() {
let flat = [0.01; 10];
assert_eq!(distributional_uncertainty(&[0.01; 10], &flat), 0.0);
assert_eq!(distributional_uncertainty(&[0.05; 10], &flat), 1.0);
}
#[test]
fn decomposition_separates_noise_from_ignorance() {
let coin: Vec<bool> = (0..100).map(|i| i % 2 == 0).collect();
let hedged = vec![0.5; 100];
let reference: Vec<f64> = (0..100)
.map(|i| 0.004 + 0.0005 * (i as f64).sin())
.collect();
let humble = decompose_uncertainty(&coin, &[&hedged, &hedged], &reference, &reference);
let sure_lo = vec![0.0; 100];
let sure_hi = vec![1.0; 100];
let confused = decompose_uncertainty(&coin, &[&sure_lo, &sure_hi], &reference, &reference);
assert!((brier_score(&hedged, &coin) - 0.25).abs() < 1e-12);
assert!(
(humble.aleatoric - confused.aleatoric).abs() < 1e-12,
"same outcome series, same irreducible noise"
);
assert!(
confused.epistemic > humble.epistemic + 0.9,
"only the epistemic leg tells the two apart: {} vs {}",
confused.epistemic,
humble.epistemic
);
assert!(
humble.distributional < 1e-12,
"a case compared against itself is not novel"
);
}
}