use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const Z_95: f64 = 1.959_963_984_540_054;
pub fn wilson_interval(successes: u64, n: u64, z: f64) -> Option<(f64, f64)> {
if n == 0 {
return None;
}
let n_f = n as f64;
let p = successes.min(n) as f64 / n_f;
let z2 = z * z;
let denom = 1.0 + z2 / n_f;
let centre = (p + z2 / (2.0 * n_f)) / denom;
let half = z * (p * (1.0 - p) / n_f + z2 / (4.0 * n_f * n_f)).sqrt() / denom;
Some(((centre - half).max(0.0), (centre + half).min(1.0)))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Kappa {
pub n: u64,
pub observed: f64,
pub expected: f64,
pub kappa: Option<f64>,
}
pub fn cohen_kappa(pairs: &[(String, String)]) -> Option<Kappa> {
if pairs.is_empty() {
return None;
}
let n = pairs.len() as f64;
let mut a_marg: BTreeMap<&str, f64> = BTreeMap::new();
let mut b_marg: BTreeMap<&str, f64> = BTreeMap::new();
let mut agree = 0.0;
for (a, b) in pairs {
*a_marg.entry(a.as_str()).or_default() += 1.0;
*b_marg.entry(b.as_str()).or_default() += 1.0;
if a == b {
agree += 1.0;
}
}
let observed = agree / n;
let expected: f64 = a_marg
.iter()
.map(|(k, ca)| ca / n * b_marg.get(k).copied().unwrap_or(0.0) / n)
.sum();
let kappa = if (1.0 - expected).abs() < f64::EPSILON {
None
} else {
Some((observed - expected) / (1.0 - expected))
};
Some(Kappa {
n: pairs.len() as u64,
observed,
expected,
kappa,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 5e-4
}
#[test]
fn wilson_matches_known_values() {
let (lo, hi) = wilson_interval(8, 10, Z_95).unwrap_or((0.0, 0.0));
assert!(close(lo, 0.4902) && close(hi, 0.9433), "{lo} {hi}");
let (lo, hi) = wilson_interval(0, 10, Z_95).unwrap_or((1.0, 1.0));
assert!(close(lo, 0.0) && close(hi, 0.2775), "{lo} {hi}");
let (lo, hi) = wilson_interval(10, 10, Z_95).unwrap_or((0.0, 0.0));
assert!(close(lo, 0.7225) && close(hi, 1.0), "{lo} {hi}");
assert_eq!(wilson_interval(0, 0, Z_95), None);
}
#[test]
fn kappa_matches_worked_example() {
let mut pairs = Vec::new();
let mut push = |a: &str, b: &str, k: usize| {
for _ in 0..k {
pairs.push((a.to_string(), b.to_string()));
}
};
push("yes", "yes", 20);
push("yes", "no", 5);
push("no", "yes", 10);
push("no", "no", 15);
let k = cohen_kappa(&pairs).unwrap_or(Kappa {
n: 0,
observed: 0.0,
expected: 0.0,
kappa: None,
});
assert_eq!(k.n, 50);
assert!(close(k.observed, 0.7));
assert!(close(k.expected, 0.5));
assert!(close(k.kappa.unwrap_or(f64::NAN), 0.4));
assert!(cohen_kappa(&[]).is_none());
}
}