use prospicio_core::{Error, Result, StreamRng};
use crate::family::Family;
pub fn deviance(family: Family, y: &[f64], mu: &[f64], weights: Option<&[f64]>) -> Result<f64> {
same_length(y.len(), mu.len(), "mu")?;
if let Some(w) = weights {
same_length(y.len(), w.len(), "weights")?;
}
Ok(y.iter()
.zip(mu)
.enumerate()
.map(|(i, (&y, &m))| weights.map_or(1.0, |w| w[i]) * family.unit_deviance(y, m))
.sum())
}
pub fn mean_deviance(
family: Family,
y: &[f64],
mu: &[f64],
weights: Option<&[f64]>,
) -> Result<f64> {
let total = weights.map_or(y.len() as f64, |w| w.iter().sum());
Ok(deviance(family, y, mu, weights)? / total)
}
pub fn rmse(y: &[f64], pred: &[f64]) -> Result<f64> {
same_length(y.len(), pred.len(), "pred")?;
let n = y.len() as f64;
Ok((y
.iter()
.zip(pred)
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
/ n)
.sqrt())
}
pub fn mae(y: &[f64], pred: &[f64]) -> Result<f64> {
same_length(y.len(), pred.len(), "pred")?;
let n = y.len() as f64;
Ok(y.iter().zip(pred).map(|(a, b)| (a - b).abs()).sum::<f64>() / n)
}
pub fn pinball(y: &[f64], pred: &[f64], alpha: f64, weights: Option<&[f64]>) -> Result<f64> {
same_length(y.len(), pred.len(), "pred")?;
if !(alpha > 0.0 && alpha < 1.0) {
return Err(Error::InvalidParameter {
name: "alpha",
value: alpha,
reason: "must be in (0, 1)",
});
}
if let Some(w) = weights {
same_length(y.len(), w.len(), "weights")?;
}
let (mut loss, mut total) = (0.0, 0.0);
for (i, (&y, &q)) in y.iter().zip(pred).enumerate() {
let w = weights.map_or(1.0, |w| w[i]);
let u = y - q;
loss += w * u * if u < 0.0 { alpha - 1.0 } else { alpha };
total += w;
}
Ok(loss / total)
}
pub fn gini(y: &[f64], pred: &[f64], exposure: Option<&[f64]>) -> Result<f64> {
same_length(y.len(), pred.len(), "pred")?;
if let Some(e) = exposure {
same_length(y.len(), e.len(), "exposure")?;
}
let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
let mut order: Vec<usize> = (0..y.len()).collect();
order.sort_by(|&a, &b| pred[a].total_cmp(&pred[b]));
let total_e: f64 = (0..y.len()).map(e).sum();
let total_y: f64 = y.iter().sum();
if total_e <= 0.0 || total_y == 0.0 {
return Err(Error::InvalidParameter {
name: "y",
value: total_y,
reason: "needs positive total exposure and non-zero total loss",
});
}
let (mut area, mut cum_e, mut cum_y) = (0.0, 0.0, 0.0);
let mut i = 0;
while i < order.len() {
let mut j = i;
let (mut de, mut dy) = (0.0, 0.0);
while j < order.len() && pred[order[j]] == pred[order[i]] {
de += e(order[j]);
dy += y[order[j]];
j += 1;
}
let (x0, y0) = (cum_e / total_e, cum_y / total_y);
cum_e += de;
cum_y += dy;
let (x1, y1) = (cum_e / total_e, cum_y / total_y);
area += 0.5 * (x1 - x0) * (y0 + y1);
i = j;
}
Ok(1.0 - 2.0 * area)
}
#[derive(Debug, Clone, PartialEq)]
pub struct LiftBand {
pub exposure: f64,
pub expected: f64,
pub actual: f64,
}
impl LiftBand {
pub fn ratio(&self) -> f64 {
self.actual / self.expected
}
}
pub fn lift(
y: &[f64],
pred: &[f64],
exposure: Option<&[f64]>,
bands: usize,
) -> Result<Vec<LiftBand>> {
same_length(y.len(), pred.len(), "pred")?;
if let Some(e) = exposure {
same_length(y.len(), e.len(), "exposure")?;
}
if bands == 0 {
return Err(Error::InvalidParameter {
name: "bands",
value: 0.0,
reason: "must be positive",
});
}
let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
let mut order: Vec<usize> = (0..y.len()).collect();
order.sort_by(|&a, &b| (pred[a] / e(a)).total_cmp(&(pred[b] / e(b))));
let total: f64 = (0..y.len()).map(e).sum();
let mut out = vec![
LiftBand {
exposure: 0.0,
expected: 0.0,
actual: 0.0,
};
bands
];
let mut cum = 0.0;
for &i in &order {
let mid = (cum + 0.5 * e(i)) / total;
cum += e(i);
let b = ((mid * bands as f64) as usize).min(bands - 1);
out[b].exposure += e(i);
out[b].expected += pred[i];
out[b].actual += y[i];
}
Ok(out)
}
pub fn crps(draws: &[f64], y: f64) -> Result<f64> {
if draws.is_empty() {
return Err(Error::InvalidParameter {
name: "draws",
value: 0.0,
reason: "must not be empty",
});
}
let m = draws.len() as f64;
let mut sorted = draws.to_vec();
sorted.sort_by(f64::total_cmp);
let abs_err: f64 = sorted.iter().map(|x| (x - y).abs()).sum::<f64>() / m;
let spread: f64 = sorted
.iter()
.enumerate()
.map(|(i, x)| x * (2.0 * i as f64 - m + 1.0))
.sum::<f64>();
Ok(abs_err - spread / (m * m))
}
pub fn coverage(y: &[f64], lo: &[f64], hi: &[f64]) -> Result<f64> {
same_length(y.len(), lo.len(), "lo")?;
same_length(y.len(), hi.len(), "hi")?;
let inside = (0..y.len())
.filter(|&i| lo[i] <= y[i] && y[i] <= hi[i])
.count();
Ok(inside as f64 / y.len() as f64)
}
pub fn log_score(
family: Family,
y: &[f64],
mu: &[f64],
dispersion: f64,
weights: Option<&[f64]>,
) -> Result<f64> {
same_length(y.len(), mu.len(), "mu")?;
if let Some(w) = weights {
same_length(y.len(), w.len(), "weights")?;
}
let mut total = 0.0;
for i in 0..y.len() {
let w = weights.map_or(1.0, |w| w[i]);
total -= family.log_density(y[i], mu[i], dispersion, w)?;
}
Ok(total / y.len() as f64)
}
pub fn pit(
family: Family,
y: &[f64],
mu: &[f64],
dispersion: f64,
weights: Option<&[f64]>,
seed: u64,
) -> Result<Vec<f64>> {
same_length(y.len(), mu.len(), "mu")?;
if let Some(w) = weights {
same_length(y.len(), w.len(), "weights")?;
}
let mut rng = StreamRng::new(seed, 0);
(0..y.len())
.map(|i| {
let w = weights.map_or(1.0, |w| w[i]);
let (lo, hi) = family.cdf_bounds(y[i], mu[i], dispersion, w)?;
Ok(if hi > lo {
lo + rng.next_open01() * (hi - lo)
} else {
hi
})
})
.collect()
}
pub fn pit_from_draws(draws: &[f64], y: f64, u: f64) -> Result<f64> {
if draws.is_empty() {
return Err(Error::InvalidParameter {
name: "draws",
value: 0.0,
reason: "must not be empty",
});
}
let below = draws.iter().filter(|&&x| x < y).count() as f64;
let ties = draws.iter().filter(|&&x| x == y).count() as f64;
Ok((below + u * ties) / draws.len() as f64)
}
pub fn pit_histogram(pit: &[f64], bins: usize) -> Result<Vec<usize>> {
if bins == 0 {
return Err(Error::InvalidParameter {
name: "bins",
value: 0.0,
reason: "must be positive",
});
}
let mut counts = vec![0; bins];
for &p in pit {
let b = ((p * bins as f64) as usize).min(bins - 1);
counts[b] += 1;
}
Ok(counts)
}
pub fn ks_uniform(values: &[f64]) -> Result<f64> {
if values.is_empty() {
return Err(Error::InvalidParameter {
name: "values",
value: 0.0,
reason: "must not be empty",
});
}
let mut v = values.to_vec();
v.sort_by(f64::total_cmp);
let n = v.len() as f64;
Ok(v.iter()
.enumerate()
.map(|(i, &x)| (x - i as f64 / n).max((i as f64 + 1.0) / n - x))
.fold(0.0, f64::max))
}
fn same_length(n: usize, m: usize, name: &'static str) -> Result<()> {
if n == m {
Ok(())
} else {
Err(Error::InvalidParameter {
name,
value: m as f64,
reason: "must have one value per observation",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pit_is_uniform_under_the_model() {
let families = [
(Family::Gaussian, 2.0),
(Family::Poisson, 1.0),
(Family::Poisson, 2.5),
(Family::Binomial, 1.0),
(Family::NegativeBinomial { theta: 1.5 }, 1.0),
(Family::Gamma, 0.4),
(Family::InverseGaussian, 0.3),
(Family::Tweedie { power: 1.5 }, 2.0),
];
let n = 4000;
for (k, &(family, phi)) in families.iter().enumerate() {
let mut rng = StreamRng::new(9, k as u64);
let mu: Vec<f64> = (0..n)
.map(|i| match family {
Family::Binomial => 0.2 + 0.6 * (i % 7) as f64 / 7.0,
_ => 0.5 + (i % 5) as f64,
})
.collect();
let w: Vec<f64> = (0..n).map(|i| 1.0 + (i % 3) as f64).collect();
let y: Vec<f64> = (0..n)
.map(|i| family.draw(mu[i], phi, w[i], rng.next_open01()).unwrap())
.collect();
let p = pit(family, &y, &mu, phi, Some(&w), 1).unwrap();
let ks = ks_uniform(&p).unwrap();
assert!(ks < 1.63 / (n as f64).sqrt(), "{family:?}: ks {ks}");
let off: Vec<f64> = mu
.iter()
.map(|m| match family {
Family::Binomial => m * 0.7,
_ => m * 1.5,
})
.collect();
let bad = ks_uniform(&pit(family, &y, &off, phi, Some(&w), 1).unwrap()).unwrap();
assert!(bad > 3.0 / (n as f64).sqrt(), "{family:?}: misfit ks {bad}");
let good = log_score(family, &y, &mu, phi, Some(&w)).unwrap();
let worse = log_score(family, &y, &off, phi, Some(&w)).unwrap();
assert!(good < worse, "{family:?}");
}
}
#[test]
fn log_density_is_the_log_likelihood_where_they_agree() {
for family in [
Family::Gaussian,
Family::Gamma,
Family::InverseGaussian,
Family::Tweedie { power: 1.4 },
] {
let a = family.log_density(1.7, 2.0, 0.8, 1.5).unwrap();
let b = family.log_likelihood(1.7, 2.0, 1.5, 0.8);
assert!((a - b).abs() < 1e-12, "{family:?}");
}
for (family, y) in [
(Family::Poisson, 3.0),
(Family::NegativeBinomial { theta: 2.0 }, 3.0),
] {
let a = family.log_density(y, 2.0, 1.0, 1.0).unwrap();
let b = family.log_likelihood(y, 2.0, 1.0, 1.0);
assert!((a - b).abs() < 1e-12, "{family:?}");
}
assert_eq!(
Family::Poisson.log_density(1.5, 2.0, 1.0, 1.0).unwrap(),
f64::NEG_INFINITY
);
assert_eq!(
pit_histogram(&[0.05, 0.15, 0.95, 1.0], 2).unwrap(),
vec![2, 2]
);
}
#[test]
fn crps_matches_the_pairwise_definition() {
let x: [f64; 6] = [0.3, -1.2, 2.5, 0.3, 4.0, 1.1];
let y = 0.7;
let m = x.len() as f64;
let a: f64 = x.iter().map(|v| (v - y).abs()).sum::<f64>() / m;
let b: f64 = x
.iter()
.flat_map(|u| x.iter().map(move |v| (u - v).abs()))
.sum::<f64>()
/ (m * m);
assert!((crps(&x, y).unwrap() - (a - 0.5 * b)).abs() < 1e-14);
}
#[test]
fn gini_with_exposure_and_lift() {
let y = [0.0, 2.0, 2.0];
let pred = [0.1, 1.8, 2.1];
let e = [1.0, 2.0, 1.0];
let g = gini(&y, &pred, Some(&e)).unwrap();
let area = 0.5 * 0.5 * 0.5 + 0.5 * 0.25 * 1.5;
assert!((g - (1.0 - 2.0 * area)).abs() < 1e-15);
let bands = lift(&y, &pred, Some(&e), 2).unwrap();
assert_eq!(bands[0].exposure + bands[1].exposure, 4.0);
assert!(bands[1].actual / bands[1].exposure >= bands[0].actual / bands[0].exposure);
}
#[test]
fn deviance_scores_and_coverage() {
let y = [1.0, 0.0, 3.0];
let mu = [1.0, 0.5, 2.0];
let d = deviance(Family::Poisson, &y, &mu, None).unwrap();
let want = 2.0 * (0.5 + (3.0 * 1.5f64.ln() - 1.0));
assert!((d - want).abs() < 1e-14);
assert!(
(mean_deviance(Family::Poisson, &y, &mu, None).unwrap() - want / 3.0).abs() < 1e-15
);
assert_eq!(
coverage(&y, &[0.0, 0.0, 0.0], &[2.0, 2.0, 2.0]).unwrap(),
2.0 / 3.0
);
assert!(rmse(&y, &[1.0]).is_err());
}
}