Skip to main content

solow_stats/
inter_rater.rs

1//! Inter-rater agreement measures.
2//!
3//! Provides Cohen's kappa for two raters ([`cohens_kappa`]) with its asymptotic
4//! variance, confidence interval and zero-test, Fleiss'/Randolph's kappa for
5//! many raters ([`fleiss_kappa`]), and the [`aggregate_raters`] helper that
6//! turns a `(subject, rater)` assignment matrix into the `(subject, category
7//! counts)` form expected by [`fleiss_kappa`]. Mirrors the reference
8//! `inter_rater` module.
9
10use ndarray::Array2;
11use solow_distributions::{norm_isf, norm_sf};
12
13/// Chance-correction convention used by [`fleiss_kappa`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum FleissMethod {
16    /// Fleiss' kappa: the chance outcome uses the sample marginal of categories.
17    Fleiss,
18    /// Randolph's (uniform) kappa: the chance outcome assumes a uniform
19    /// distribution over categories.
20    Randolph,
21}
22
23/// Aggregate a `(subject, rater)` assignment matrix into category counts.
24///
25/// `data` has subjects in rows and raters in columns; each entry is a category
26/// label. The labels are mapped to consecutive integers `0..n_cat-1` (only
27/// levels with non-zero counts are kept) and the result is a `(n_subject,
28/// n_cat)` matrix whose `[i, c]` entry counts how many raters assigned category
29/// `c` to subject `i`, together with the distinct category labels in sorted
30/// order. Mirrors the reference `aggregate_raters` (with `n_cat=None`).
31pub fn aggregate_raters(data: &Array2<f64>) -> (Array2<f64>, Vec<f64>) {
32    // Distinct category labels, sorted, deduplicated.
33    let mut cats: Vec<f64> = data.iter().copied().collect();
34    cats.sort_by(|a, b| a.total_cmp(b));
35    cats.dedup();
36    let n_cat = cats.len();
37    let n_rows = data.nrows();
38
39    let cat_index = |v: f64| -> usize {
40        cats.iter()
41            .position(|&c| c == v)
42            .expect("category present in label set")
43    };
44
45    let mut tt = Array2::<f64>::zeros((n_rows, n_cat));
46    for (i, row) in data.rows().into_iter().enumerate() {
47        for &v in row {
48            tt[[i, cat_index(v)]] += 1.0;
49        }
50    }
51    (tt, cats)
52}
53
54/// Fleiss' or Randolph's kappa for multi-rater agreement.
55///
56/// `table` has subjects in rows and category counts in columns (the output of
57/// [`aggregate_raters`]); every subject must be rated the same number of times.
58/// Method [`FleissMethod::Fleiss`] defines the chance agreement from the sample
59/// category marginal, [`FleissMethod::Randolph`] from a uniform category
60/// distribution. Mirrors the reference `fleiss_kappa`.
61pub fn fleiss_kappa(table: &Array2<f64>, method: FleissMethod) -> f64 {
62    let n_cat = table.ncols() as f64;
63    let n_total: f64 = table.sum();
64    // n_rat: number of ratings per subject (assumed constant, == row max sum).
65    let n_rat = table
66        .rows()
67        .into_iter()
68        .map(|r| r.sum())
69        .fold(f64::NEG_INFINITY, f64::max);
70
71    // Marginal category frequencies.
72    let p_cat: Vec<f64> = (0..table.ncols())
73        .map(|j| table.column(j).sum() / n_total)
74        .collect();
75
76    // Per-subject agreement.
77    let mut p_sum = 0.0;
78    for row in table.rows() {
79        let sq: f64 = row.iter().map(|&v| v * v).sum();
80        p_sum += (sq - n_rat) / (n_rat * (n_rat - 1.0));
81    }
82    let p_mean = p_sum / table.nrows() as f64;
83
84    let p_mean_exp = match method {
85        FleissMethod::Fleiss => p_cat.iter().map(|&p| p * p).sum::<f64>(),
86        FleissMethod::Randolph => 1.0 / n_cat,
87    };
88
89    (p_mean - p_mean_exp) / (1.0 - p_mean_exp)
90}
91
92/// Results of [`cohens_kappa`].
93#[derive(Debug, Clone)]
94pub struct KappaResults {
95    /// Cohen's (simple) kappa coefficient.
96    pub kappa: f64,
97    /// Maximum attainable kappa given the marginals.
98    pub kappa_max: f64,
99    /// Asymptotic variance of kappa.
100    pub var_kappa: f64,
101    /// Asymptotic variance of kappa under H0: kappa = 0.
102    pub var_kappa0: f64,
103    /// Asymptotic standard error of kappa, `sqrt(var_kappa)`.
104    pub std_kappa: f64,
105    /// Standard error under H0, `sqrt(var_kappa0)`.
106    pub std_kappa0: f64,
107    /// Test statistic `kappa / std_kappa0` for H0: kappa = 0 (standard normal).
108    pub z_value: f64,
109    /// One-sided p-value for H0: kappa = 0 vs H1: kappa > 0.
110    pub pvalue_one_sided: f64,
111    /// Two-sided p-value for H0: kappa = 0 vs H1: kappa != 0.
112    pub pvalue_two_sided: f64,
113    /// Lower `(1 - 2*alpha)` confidence limit for kappa.
114    pub kappa_low: f64,
115    /// Upper `(1 - 2*alpha)` confidence limit for kappa.
116    pub kappa_upp: f64,
117}
118
119/// Cohen's (simple) kappa with variance, confidence interval and zero-test.
120///
121/// `table` is a square contingency matrix of two raters (rater 1 in rows, rater
122/// 2 in columns). `alpha` is the one-sided tail probability for the confidence
123/// interval (the reference default is `0.025`, giving a 95% interval). Mirrors
124/// the unweighted branch of the reference `cohens_kappa`.
125pub fn cohens_kappa(table: &Array2<f64>, alpha: f64) -> KappaResults {
126    let n = table.nrows();
127    let nobs: f64 = table.sum();
128
129    // Observed agreement on the diagonal.
130    let agree: f64 = (0..n).map(|i| table[[i, i]]).sum();
131
132    // Probabilities and marginals.
133    let freq_row: Vec<f64> = (0..n).map(|i| table.row(i).sum() / nobs).collect();
134    let freq_col: Vec<f64> = (0..n).map(|j| table.column(j).sum() / nobs).collect();
135    // prob_exp[i, j] = freq_col[j] * freq_row[i]
136    let agree_exp: f64 = (0..n).map(|i| freq_col[i] * freq_row[i]).sum();
137
138    let kappa = (agree / nobs - agree_exp) / (1.0 - agree_exp);
139
140    // Asymptotic variance (SAS / Fleiss formulas).
141    let probs_diag: Vec<f64> = (0..n).map(|i| table[[i, i]] / nobs).collect();
142    let mut term_a = 0.0;
143    for i in 0..n {
144        let inner = 1.0 - (freq_row[i] + freq_col[i]) * (1.0 - kappa);
145        term_a += probs_diag[i] * inner * inner;
146    }
147    let mut term_b = 0.0;
148    for i in 0..n {
149        for j in 0..n {
150            if i == j {
151                continue;
152            }
153            // Reference: term_b[i,j] = probs[i,j] * (freq_col[i] + freq_row[j])^2.
154            let inner = freq_col[i] + freq_row[j];
155            term_b += (table[[i, j]] / nobs) * inner * inner;
156        }
157    }
158    term_b *= (1.0 - kappa) * (1.0 - kappa);
159    let term_c = (kappa - agree_exp * (1.0 - kappa)).powi(2);
160    let var_kappa = (term_a + term_b - term_c) / ((1.0 - agree_exp).powi(2) * nobs);
161
162    // Variance under H0: kappa = 0.
163    let term_c0: f64 = (0..n)
164        .map(|i| freq_col[i] * freq_row[i] * (freq_col[i] + freq_row[i]))
165        .sum();
166    let var_kappa0 =
167        (agree_exp + agree_exp * agree_exp - term_c0) / ((1.0 - agree_exp).powi(2) * nobs);
168
169    let kappa_max =
170        ((0..n).map(|i| freq_row[i].min(freq_col[i])).sum::<f64>() - agree_exp) / (1.0 - agree_exp);
171
172    let std_kappa = var_kappa.sqrt();
173    let std_kappa0 = var_kappa0.sqrt();
174    let z_value = kappa / std_kappa0;
175    let pvalue_one_sided = norm_sf(z_value);
176    let pvalue_two_sided = norm_sf(z_value.abs()) * 2.0;
177    let delta = norm_isf(alpha) * std_kappa;
178
179    KappaResults {
180        kappa,
181        kappa_max,
182        var_kappa,
183        var_kappa0,
184        std_kappa,
185        std_kappa0,
186        z_value,
187        pvalue_one_sided,
188        pvalue_two_sided,
189        kappa_low: kappa - delta,
190        kappa_upp: kappa + delta,
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use ndarray::array;
198
199    #[test]
200    fn perfect_agreement_kappa_one() {
201        let t = array![[10.0, 0.0], [0.0, 15.0]];
202        let r = cohens_kappa(&t, 0.025);
203        assert!((r.kappa - 1.0).abs() < 1e-12);
204    }
205
206    #[test]
207    fn aggregate_then_fleiss_runs() {
208        // 3 subjects, 4 raters, categories {0, 1, 2}.
209        let data = array![
210            [0.0, 0.0, 0.0, 1.0],
211            [1.0, 1.0, 2.0, 2.0],
212            [0.0, 1.0, 2.0, 0.0]
213        ];
214        let (tt, cats) = aggregate_raters(&data);
215        assert_eq!(cats, vec![0.0, 1.0, 2.0]);
216        assert_eq!(tt.dim(), (3, 3));
217        let k = fleiss_kappa(&tt, FleissMethod::Fleiss);
218        assert!(k.is_finite());
219    }
220}