use std::collections::BTreeMap;
use crate::{Error, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
AWins,
BWins,
Tie,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Judgment {
item: usize,
rater: usize,
a: usize,
b: usize,
outcome: Outcome,
}
impl Judgment {
pub fn new(item: usize, rater: usize, a: usize, b: usize, outcome: Outcome) -> Result<Self> {
if a == b {
return Err(Error::validation(format!(
"a judgment compares two distinct systems; got a == b == {a}"
)));
}
Ok(Self {
item,
rater,
a,
b,
outcome,
})
}
pub fn item(&self) -> usize {
self.item
}
pub fn rater(&self) -> usize {
self.rater
}
pub fn a(&self) -> usize {
self.a
}
pub fn b(&self) -> usize {
self.b
}
pub fn outcome(&self) -> Outcome {
self.outcome
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct BradleyTerry {
strengths: Vec<f64>,
}
impl BradleyTerry {
pub fn fit(systems: usize, judgments: &[Judgment]) -> Result<Self> {
if systems < 2 {
return Err(Error::validation(format!(
"a Bradley-Terry fit needs at least two systems; got {systems}"
)));
}
if judgments.is_empty() {
return Err(Error::validation("no judgments to fit"));
}
let mut wins = vec![vec![0.0f64; systems]; systems];
for (k, judgment) in judgments.iter().enumerate() {
let (a, b) = (judgment.a, judgment.b);
if a >= systems || b >= systems {
return Err(Error::validation(format!(
"judgment {k} references system {a} / {b} but there are only {systems} systems"
)));
}
match judgment.outcome {
Outcome::AWins => wins[a][b] += 1.0,
Outcome::BWins => wins[b][a] += 1.0,
Outcome::Tie => {
wins[a][b] += 0.5;
wins[b][a] += 0.5;
}
}
}
if !is_strongly_connected(&wins, systems) {
return Err(Error::validation(
"the comparison design does not identify a ranking: the win graph is not \
strongly connected (a system never loses or the comparisons are disconnected)",
));
}
let wins_total: Vec<f64> = wins.iter().map(|row| row.iter().sum()).collect();
let counts = pair_counts(&wins, systems);
let strengths = mm_fit(&wins_total, &counts, systems)?;
Ok(Self { strengths })
}
pub fn strengths(&self) -> &[f64] {
&self.strengths
}
pub fn ranking(&self) -> Vec<usize> {
let mut order: Vec<usize> = (0..self.strengths.len()).collect();
order.sort_by(|&i, &j| {
self.strengths[j]
.total_cmp(&self.strengths[i])
.then(i.cmp(&j))
});
order
}
}
fn pair_counts(wins: &[Vec<f64>], n: usize) -> Vec<Vec<f64>> {
let mut counts = vec![vec![0.0f64; n]; n];
for (i, row) in counts.iter_mut().enumerate() {
for (j, c) in row.iter_mut().enumerate() {
*c = wins[i][j] + wins[j][i];
}
}
counts
}
fn is_strongly_connected(wins: &[Vec<f64>], n: usize) -> bool {
reach(n, |i, j| wins[i][j] > 0.0) == n && reach(n, |i, j| wins[j][i] > 0.0) == n
}
fn reach(n: usize, edge: impl Fn(usize, usize) -> bool) -> usize {
let mut seen = vec![false; n];
seen[0] = true;
let mut stack = vec![0usize];
let mut count = 1;
while let Some(i) = stack.pop() {
for (j, seen_j) in seen.iter_mut().enumerate() {
if !*seen_j && edge(i, j) {
*seen_j = true;
count += 1;
stack.push(j);
}
}
}
count
}
fn mm_fit(wins_total: &[f64], counts: &[Vec<f64>], n: usize) -> Result<Vec<f64>> {
const TOL: f64 = 1e-10;
const MAX_ITERS: usize = 10_000;
let mut p = vec![1.0 / n as f64; n];
for _ in 0..MAX_ITERS {
let mut next = vec![0.0f64; n];
for (i, slot) in next.iter_mut().enumerate() {
let mut denom = 0.0f64;
for j in 0..n {
if i != j && counts[i][j] > 0.0 {
denom += counts[i][j] / (p[i] + p[j]);
}
}
*slot = wins_total[i] / denom;
}
let sum: f64 = next.iter().sum();
for x in &mut next {
*x /= sum;
}
let delta = (0..n).map(|i| (next[i] - p[i]).abs()).fold(0.0, f64::max);
p = next;
if delta < TOL {
return Ok(p);
}
}
Err(Error::validation(
"Bradley-Terry iteration did not converge",
))
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Reliability {
alpha: f64,
units: usize,
ratings: usize,
}
impl Reliability {
pub fn krippendorff_alpha(judgments: &[Judgment]) -> Result<Self> {
if judgments.is_empty() {
return Err(Error::validation("no judgments to assess"));
}
let mut units: BTreeMap<(usize, usize, usize), BTreeMap<usize, usize>> = BTreeMap::new();
for judgment in judgments {
let (lo, hi, category) = canonical_category(judgment);
let raters = units.entry((judgment.item, lo, hi)).or_default();
if raters.insert(judgment.rater, category).is_some() {
return Err(Error::validation(format!(
"rater {} judged the comparison (item {}, systems {lo}/{hi}) more than once",
judgment.rater, judgment.item
)));
}
}
let mut n = 0.0f64;
let mut diagonal = 0.0f64;
let mut marginal = [0.0f64; 3];
let mut usable_units = 0;
let mut ratings = 0;
for raters in units.values() {
let m = raters.len();
if m < 2 {
continue;
}
usable_units += 1;
ratings += m;
let mut counts = [0usize; 3];
for &category in raters.values() {
counts[category] += 1;
}
let pairable = (m - 1) as f64;
for (category, &count) in counts.iter().enumerate() {
let nc = count as f64;
n += nc;
marginal[category] += nc;
diagonal += nc * (nc - 1.0) / pairable;
}
}
if usable_units == 0 {
return Err(Error::validation(
"reliability needs at least one comparison judged by two or more raters",
));
}
let expected = n * n - marginal.iter().map(|&x| x * x).sum::<f64>();
let alpha = if expected == 0.0 {
1.0
} else {
1.0 - (n - 1.0) * (n - diagonal) / expected
};
Ok(Self {
alpha,
units: usable_units,
ratings,
})
}
pub fn alpha(&self) -> f64 {
self.alpha
}
pub fn units(&self) -> usize {
self.units
}
pub fn ratings(&self) -> usize {
self.ratings
}
}
fn canonical_category(judgment: &Judgment) -> (usize, usize, usize) {
let (lo, hi) = if judgment.a < judgment.b {
(judgment.a, judgment.b)
} else {
(judgment.b, judgment.a)
};
let category = match judgment.outcome {
Outcome::Tie => 2,
Outcome::AWins => usize::from(judgment.a != lo),
Outcome::BWins => usize::from(judgment.b != lo),
};
(lo, hi, category)
}
#[cfg(test)]
mod tests {
use super::*;
fn judgments(triples: &[(usize, usize, Outcome)]) -> Vec<Judgment> {
triples
.iter()
.map(|&(a, b, o)| Judgment::new(0, 0, a, b, o).unwrap())
.collect()
}
fn graded_round_robin(systems: usize, win: usize, lose: usize) -> Vec<Judgment> {
let mut triples = Vec::new();
for i in 0..systems {
for j in (i + 1)..systems {
for _ in 0..win {
triples.push((i, j, Outcome::AWins));
}
for _ in 0..lose {
triples.push((i, j, Outcome::BWins));
}
}
}
judgments(&triples)
}
#[test]
fn recovers_known_ranking() {
let bt = BradleyTerry::fit(3, &graded_round_robin(3, 2, 1)).unwrap();
assert_eq!(bt.ranking(), vec![0, 1, 2]);
let s = bt.strengths();
assert!(s[0] > s[1] && s[1] > s[2], "{s:?}");
}
#[test]
fn strengths_are_a_positive_simplex() {
let bt = BradleyTerry::fit(4, &graded_round_robin(4, 3, 1)).unwrap();
let s = bt.strengths();
assert_eq!(s.len(), 4);
assert!(s.iter().all(|&p| p > 0.0), "{s:?}");
assert!((s.iter().sum::<f64>() - 1.0).abs() < 1e-9, "{s:?}");
}
#[test]
fn balanced_results_give_equal_strengths() {
let bt = BradleyTerry::fit(3, &graded_round_robin(3, 1, 1)).unwrap();
for &p in bt.strengths() {
assert!((p - 1.0 / 3.0).abs() < 1e-9, "{:?}", bt.strengths());
}
}
#[test]
fn ties_are_symmetric_and_keep_the_graph_connected() {
let all_ties = judgments(&[
(0, 1, Outcome::Tie),
(0, 2, Outcome::Tie),
(1, 2, Outcome::Tie),
]);
let bt = BradleyTerry::fit(3, &all_ties).unwrap();
for &p in bt.strengths() {
assert!((p - 1.0 / 3.0).abs() < 1e-9, "{:?}", bt.strengths());
}
}
#[test]
fn one_tie_breaks_a_near_dominance() {
let js = judgments(&[
(0, 1, Outcome::AWins),
(0, 1, Outcome::AWins),
(0, 1, Outcome::Tie),
]);
let bt = BradleyTerry::fit(2, &js).unwrap();
assert_eq!(bt.ranking(), vec![0, 1]);
assert!(bt.strengths()[0] > bt.strengths()[1]);
}
#[test]
fn is_deterministic() {
let js = graded_round_robin(5, 3, 2);
let a = BradleyTerry::fit(5, &js).unwrap();
let b = BradleyTerry::fit(5, &js).unwrap();
assert_eq!(a, b);
}
#[test]
fn ranking_breaks_strength_ties_by_index() {
let bt = BradleyTerry::fit(3, &graded_round_robin(3, 1, 1)).unwrap();
assert_eq!(bt.ranking(), vec![0, 1, 2]);
}
#[test]
fn rejects_self_comparison_at_construction() {
assert!(Judgment::new(0, 0, 2, 2, Outcome::AWins).is_err());
}
#[test]
fn rejects_too_few_systems_and_empty() {
assert!(BradleyTerry::fit(1, &graded_round_robin(2, 2, 1)).is_err());
assert!(BradleyTerry::fit(2, &[]).is_err());
}
#[test]
fn rejects_out_of_range_system() {
let js = judgments(&[(0, 3, Outcome::AWins)]);
assert!(BradleyTerry::fit(2, &js).is_err());
}
#[test]
fn rejects_a_system_that_never_loses() {
let js = judgments(&[(0, 1, Outcome::AWins), (0, 1, Outcome::AWins)]);
assert!(BradleyTerry::fit(2, &js).is_err());
}
#[test]
fn rejects_disconnected_comparisons() {
let js = judgments(&[
(0, 1, Outcome::AWins),
(1, 0, Outcome::AWins),
(2, 3, Outcome::AWins),
(3, 2, Outcome::AWins),
]);
assert!(BradleyTerry::fit(4, &js).is_err());
}
#[test]
fn alpha_is_one_for_perfect_agreement() {
let js = vec![
Judgment::new(0, 0, 1, 2, Outcome::AWins).unwrap(),
Judgment::new(0, 1, 1, 2, Outcome::AWins).unwrap(),
Judgment::new(1, 0, 0, 3, Outcome::BWins).unwrap(),
Judgment::new(1, 1, 0, 3, Outcome::BWins).unwrap(),
];
let r = Reliability::krippendorff_alpha(&js).unwrap();
assert!((r.alpha() - 1.0).abs() < 1e-12, "{}", r.alpha());
assert_eq!(r.units(), 2);
assert_eq!(r.ratings(), 4);
}
#[test]
fn alpha_canonicalizes_presentation_order() {
let js = vec![
Judgment::new(0, 0, 2, 5, Outcome::AWins).unwrap(),
Judgment::new(0, 1, 5, 2, Outcome::BWins).unwrap(),
];
let r = Reliability::krippendorff_alpha(&js).unwrap();
assert!((r.alpha() - 1.0).abs() < 1e-12, "{}", r.alpha());
assert_eq!(r.units(), 1);
}
#[test]
fn alpha_is_zero_at_chance() {
let js = vec![
Judgment::new(0, 0, 0, 1, Outcome::AWins).unwrap(),
Judgment::new(0, 1, 0, 1, Outcome::BWins).unwrap(),
];
let r = Reliability::krippendorff_alpha(&js).unwrap();
assert!(r.alpha().abs() < 1e-12, "{}", r.alpha());
}
#[test]
fn alpha_is_negative_for_systematic_disagreement() {
let js = vec![
Judgment::new(0, 0, 0, 1, Outcome::AWins).unwrap(),
Judgment::new(0, 1, 0, 1, Outcome::BWins).unwrap(),
Judgment::new(1, 0, 0, 1, Outcome::AWins).unwrap(),
Judgment::new(1, 1, 0, 1, Outcome::BWins).unwrap(),
];
let r = Reliability::krippendorff_alpha(&js).unwrap();
assert!(r.alpha() < 0.0, "{}", r.alpha());
}
#[test]
fn alpha_rejects_duplicate_rater_on_a_unit() {
let js = vec![
Judgment::new(0, 0, 0, 1, Outcome::AWins).unwrap(),
Judgment::new(0, 0, 1, 0, Outcome::AWins).unwrap(),
];
assert!(Reliability::krippendorff_alpha(&js).is_err());
}
#[test]
fn alpha_rejects_no_multi_rater_unit() {
let js = vec![
Judgment::new(0, 0, 0, 1, Outcome::AWins).unwrap(),
Judgment::new(1, 0, 0, 1, Outcome::AWins).unwrap(),
];
assert!(Reliability::krippendorff_alpha(&js).is_err());
}
#[test]
fn alpha_rejects_empty() {
assert!(Reliability::krippendorff_alpha(&[]).is_err());
}
use proptest::prelude::*;
fn arb_connected_design() -> impl Strategy<Value = (usize, Vec<Judgment>)> {
(2usize..6)
.prop_flat_map(|systems| {
let pairs = systems * (systems - 1) / 2;
(Just(systems), proptest::collection::vec(0u8..3, pairs * 3))
})
.prop_map(|(systems, extra)| {
let mut triples = Vec::new();
let mut e = extra.into_iter();
for i in 0..systems {
for j in (i + 1)..systems {
triples.push((i, j, Outcome::AWins));
triples.push((j, i, Outcome::AWins));
for _ in 0..3 {
match e.next() {
Some(0) => triples.push((i, j, Outcome::AWins)),
Some(1) => triples.push((i, j, Outcome::BWins)),
_ => triples.push((i, j, Outcome::Tie)),
}
}
}
}
(systems, judgments(&triples))
})
}
proptest! {
#[test]
fn fit_is_a_positive_simplex_permutation((systems, js) in arb_connected_design()) {
let bt = BradleyTerry::fit(systems, &js).unwrap();
let s = bt.strengths();
prop_assert_eq!(s.len(), systems);
prop_assert!(s.iter().all(|&p| p > 0.0 && p.is_finite()));
prop_assert!((s.iter().sum::<f64>() - 1.0).abs() < 1e-9);
let mut sorted = bt.ranking();
sorted.sort_unstable();
prop_assert_eq!(sorted, (0..systems).collect::<Vec<_>>());
}
#[test]
fn fit_is_reproducible((systems, js) in arb_connected_design()) {
let a = BradleyTerry::fit(systems, &js).unwrap();
let b = BradleyTerry::fit(systems, &js).unwrap();
prop_assert_eq!(a, b);
}
#[test]
fn strength_order_matches_win_share((systems, js) in arb_connected_design()) {
let bt = BradleyTerry::fit(systems, &js).unwrap();
let order = bt.ranking();
let s = bt.strengths();
prop_assert!(s[order[0]] >= s[order[systems - 1]]);
}
}
fn arb_unit_grid() -> impl Strategy<Value = Vec<Vec<u8>>> {
proptest::collection::vec(proptest::collection::vec(0u8..3, 2..5), 1..6)
}
fn from_grid(grid: &[Vec<u8>], agree: bool) -> Vec<Judgment> {
let mut js = Vec::new();
for (item, raters) in grid.iter().enumerate() {
for (rater, &cat) in raters.iter().enumerate() {
let category = if agree { raters[0] } else { cat };
let outcome = match category {
0 => Outcome::AWins,
1 => Outcome::BWins,
_ => Outcome::Tie,
};
js.push(Judgment::new(item, rater, 0, 1, outcome).unwrap());
}
}
js
}
proptest! {
#[test]
fn alpha_at_most_one(grid in arb_unit_grid()) {
let r = Reliability::krippendorff_alpha(&from_grid(&grid, false)).unwrap();
prop_assert!(r.alpha() <= 1.0 + 1e-9);
}
#[test]
fn alpha_is_one_under_within_unit_agreement(grid in arb_unit_grid()) {
let r = Reliability::krippendorff_alpha(&from_grid(&grid, true)).unwrap();
prop_assert!((r.alpha() - 1.0).abs() < 1e-9, "{}", r.alpha());
}
#[test]
fn alpha_is_reproducible(grid in arb_unit_grid()) {
let js = from_grid(&grid, false);
let a = Reliability::krippendorff_alpha(&js).unwrap();
let b = Reliability::krippendorff_alpha(&js).unwrap();
prop_assert_eq!(a, b);
}
}
}