evalaudit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalaudit/__init__.py +56 -0
- evalaudit/_types.py +1572 -0
- evalaudit/agreement.py +600 -0
- evalaudit/audit.py +821 -0
- evalaudit/compare.py +708 -0
- evalaudit/judge.py +744 -0
- evalaudit/pairwise.py +682 -0
- evalaudit/power.py +528 -0
- evalaudit/scores.py +136 -0
- evalaudit-0.2.0.dist-info/METADATA +313 -0
- evalaudit-0.2.0.dist-info/RECORD +13 -0
- evalaudit-0.2.0.dist-info/WHEEL +4 -0
- evalaudit-0.2.0.dist-info/licenses/LICENSE +21 -0
evalaudit/agreement.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Do your graders agree with each other?
|
|
2
|
+
|
|
3
|
+
The question underneath every human-graded eval, and the one most reports
|
|
4
|
+
skip. A model comparison built on ratings the graders themselves do not
|
|
5
|
+
reproduce is measuring the rubric, not the model.
|
|
6
|
+
|
|
7
|
+
Krippendorff's alpha is the measure here because real grading is never fully
|
|
8
|
+
crossed. Graders miss items, join late, quit halfway. Alpha handles that.
|
|
9
|
+
Cohen's kappa needs exactly two raters who both graded everything, and
|
|
10
|
+
Fleiss' kappa needs a fixed number of raters per item. Both are here because
|
|
11
|
+
people ask for them by name.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from ._types import _MIN_USABLE_SHARE, AgreementResult, KappaResult
|
|
22
|
+
|
|
23
|
+
__all__ = ["rater_agreement", "cohens_kappa", "fleiss_kappa"]
|
|
24
|
+
|
|
25
|
+
_LEVELS = {"nominal", "ordinal", "interval"}
|
|
26
|
+
_COLUMNS = ("item_id", "rater_id", "rating")
|
|
27
|
+
|
|
28
|
+
# The four reasons a leave-one-out alpha comes back undefined. They call for
|
|
29
|
+
# different responses, so they get different sentences. "No item left with
|
|
30
|
+
# two or more ratings" means the design fell apart without that rater. "The
|
|
31
|
+
# remaining raters agreed everywhere" means it did not, and they agreed. One
|
|
32
|
+
# item left means it held together and there is still not enough to measure.
|
|
33
|
+
_NOTE_SINGLE_RATER = "only one rater left, nothing to compare"
|
|
34
|
+
_NOTE_NO_OVERLAP = "no item left with two or more ratings"
|
|
35
|
+
_NOTE_NO_VARIANCE = "remaining raters agreed everywhere, so alpha has no denominator"
|
|
36
|
+
_NOTE_ONE_ITEM = "only one item left with two or more ratings"
|
|
37
|
+
|
|
38
|
+
# Ceiling on the working array the bootstrap allocates, in floats. The
|
|
39
|
+
# resample loop runs in blocks sized to fit under it. Blocks are not
|
|
40
|
+
# resamples: every resample inside a block is still computed in one pass.
|
|
41
|
+
_BLOCK_BUDGET = 4_000_000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rater_agreement(
|
|
45
|
+
ratings: pd.DataFrame,
|
|
46
|
+
level: str = "nominal",
|
|
47
|
+
confidence: float = 0.95,
|
|
48
|
+
bootstrap_ci: bool = True,
|
|
49
|
+
n_boot: int = 1000,
|
|
50
|
+
seed: Optional[int] = None,
|
|
51
|
+
) -> AgreementResult:
|
|
52
|
+
"""Krippendorff's alpha on long-format ratings.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
ratings
|
|
57
|
+
A frame with ``item_id``, ``rater_id`` and ``rating``. One row per
|
|
58
|
+
rating given. Items nobody graded twice are kept in ``n_items`` and
|
|
59
|
+
excluded from alpha, since they carry no information about
|
|
60
|
+
agreement. Rows with a missing rating are dropped.
|
|
61
|
+
level
|
|
62
|
+
``"nominal"`` for unordered labels, where any two different ratings
|
|
63
|
+
disagree equally. ``"ordinal"`` for ranked categories, where the
|
|
64
|
+
distance between two ratings depends on how many ratings fell
|
|
65
|
+
between them. ``"interval"`` for scales where the numbers mean
|
|
66
|
+
something, and 1 against 5 is four times the disagreement of 1
|
|
67
|
+
against 2. Pick the one that matches the rubric. A 1-5 quality scale
|
|
68
|
+
is usually ordinal, and calling it nominal throws away the fact that
|
|
69
|
+
adjacent grades are nearly the same judgement.
|
|
70
|
+
confidence
|
|
71
|
+
Nominal coverage of the bootstrap interval, default 0.95.
|
|
72
|
+
bootstrap_ci
|
|
73
|
+
Set False to skip the interval. Then ``ci_low`` and ``ci_high`` are
|
|
74
|
+
NaN and nothing in the result will name an outlier rater, since
|
|
75
|
+
there is no sampling error to judge one against.
|
|
76
|
+
n_boot
|
|
77
|
+
Bootstrap resamples. The interval resamples the items graded more
|
|
78
|
+
than once, not every item in the frame. Items graded once contribute
|
|
79
|
+
nothing to alpha and nothing to its sampling error, so resampling
|
|
80
|
+
them would let the double-grading rate, a scheduling decision, leak
|
|
81
|
+
into a statistic about raters.
|
|
82
|
+
seed
|
|
83
|
+
Seeds the bootstrap. Set it in anything you publish.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
AgreementResult
|
|
88
|
+
|
|
89
|
+
Notes
|
|
90
|
+
-----
|
|
91
|
+
Alpha comes back NaN in two cases and neither is perfect agreement.
|
|
92
|
+
Fewer than two items graded twice cannot carry a reliability estimate,
|
|
93
|
+
though the arithmetic will still return a value at that size, so the
|
|
94
|
+
value is withheld. And when every rating that could be compared was
|
|
95
|
+
identical there is no disagreement to divide by.
|
|
96
|
+
"""
|
|
97
|
+
if level not in _LEVELS:
|
|
98
|
+
raise ValueError(f"level must be one of {sorted(_LEVELS)}, got {level!r}")
|
|
99
|
+
if not 0 < confidence < 1:
|
|
100
|
+
raise ValueError(f"confidence must be in (0, 1), got {confidence}")
|
|
101
|
+
|
|
102
|
+
data = _clean(ratings)
|
|
103
|
+
n_items = int(data["item_id"].nunique())
|
|
104
|
+
n_raters = int(data["rater_id"].nunique())
|
|
105
|
+
|
|
106
|
+
units = _Units.build(data, level)
|
|
107
|
+
alpha = units.alpha()
|
|
108
|
+
|
|
109
|
+
ci_low, ci_high, drawn, usable = _bootstrap(
|
|
110
|
+
units, confidence, bootstrap_ci, n_boot, seed
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return AgreementResult(
|
|
114
|
+
alpha=alpha,
|
|
115
|
+
ci_low=ci_low,
|
|
116
|
+
ci_high=ci_high,
|
|
117
|
+
n_items=n_items,
|
|
118
|
+
n_raters=n_raters,
|
|
119
|
+
n_overlapping_items=units.n_units,
|
|
120
|
+
level=level,
|
|
121
|
+
item_disagreement=units.item_table(),
|
|
122
|
+
rater_dropout=_dropout_table(data, level, alpha),
|
|
123
|
+
confidence=confidence,
|
|
124
|
+
n_boot=drawn,
|
|
125
|
+
n_boot_usable=usable,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------
|
|
130
|
+
# Input handling
|
|
131
|
+
# --------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _clean(ratings: pd.DataFrame) -> pd.DataFrame:
|
|
134
|
+
"""Validate, copy, and drop rows with no rating."""
|
|
135
|
+
if not isinstance(ratings, pd.DataFrame):
|
|
136
|
+
raise ValueError("ratings must be a pandas DataFrame")
|
|
137
|
+
|
|
138
|
+
missing = [c for c in _COLUMNS if c not in ratings.columns]
|
|
139
|
+
if missing:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"ratings is missing required column(s): {', '.join(missing)}. "
|
|
142
|
+
f"Expected {', '.join(_COLUMNS)}."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
data = ratings.loc[:, list(_COLUMNS)].copy()
|
|
146
|
+
data = data[data["rating"].notna()]
|
|
147
|
+
if data.empty:
|
|
148
|
+
raise ValueError("ratings must not be empty")
|
|
149
|
+
|
|
150
|
+
duplicated = data.duplicated(subset=["item_id", "rater_id"])
|
|
151
|
+
if duplicated.any():
|
|
152
|
+
first = data[duplicated].iloc[0]
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"ratings has duplicate rater/item pairs, starting with "
|
|
155
|
+
f"item {first['item_id']!r} rated twice by {first['rater_id']!r}. "
|
|
156
|
+
f"Two rows for one rater on one item double-count that opinion "
|
|
157
|
+
f"and push alpha up. Aggregate them first."
|
|
158
|
+
)
|
|
159
|
+
return data
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _coded_values(data: pd.DataFrame, level: str):
|
|
163
|
+
"""Ratings as an ordered value domain plus per-row codes.
|
|
164
|
+
|
|
165
|
+
Ordinal and interval need numbers, because both distances read the
|
|
166
|
+
values as positions. Nominal does not, so labels are allowed there.
|
|
167
|
+
"""
|
|
168
|
+
raw = data["rating"].to_numpy()
|
|
169
|
+
try:
|
|
170
|
+
numeric = raw.astype(float)
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
if level != "nominal":
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"level={level!r} needs numeric ratings, because the distance "
|
|
175
|
+
f"between two ratings depends on their values. Map the labels "
|
|
176
|
+
f"to numbers, or use level='nominal'."
|
|
177
|
+
) from None
|
|
178
|
+
codes, domain = pd.factorize(raw, sort=False)
|
|
179
|
+
return np.arange(len(domain), dtype=float), np.asarray(codes)
|
|
180
|
+
|
|
181
|
+
values = np.unique(numeric)
|
|
182
|
+
return values, np.searchsorted(values, numeric)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------
|
|
186
|
+
# The coincidence matrix, held as per-unit value counts
|
|
187
|
+
#
|
|
188
|
+
# Alpha only ever reads a unit through its value counts, so that is what gets
|
|
189
|
+
# stored. The coincidence matrix of a resample is the sum of the per-unit
|
|
190
|
+
# ones, which makes the whole bootstrap a matrix product against a
|
|
191
|
+
# multiplicity matrix rather than a loop.
|
|
192
|
+
# --------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
class _Units:
|
|
195
|
+
"""Value counts for the items graded more than once."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, item_ids, counts, values, level):
|
|
198
|
+
self.item_ids = item_ids # (U,)
|
|
199
|
+
self.counts = counts # (U, V) ratings per value
|
|
200
|
+
self.values = values # (V,) the value domain, ascending
|
|
201
|
+
self.level = level
|
|
202
|
+
self.sizes = counts.sum(axis=1) # (U,) ratings per item
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def build(cls, data: pd.DataFrame, level: str) -> "_Units":
|
|
206
|
+
per_item = data.groupby("item_id", sort=False)["rating"].transform("size")
|
|
207
|
+
pairable = data[per_item >= 2]
|
|
208
|
+
|
|
209
|
+
values, codes = _coded_values(pairable, level)
|
|
210
|
+
if pairable.empty:
|
|
211
|
+
return cls(np.array([]), np.zeros((0, len(values))), values, level)
|
|
212
|
+
|
|
213
|
+
item_codes, item_ids = pd.factorize(pairable["item_id"], sort=False)
|
|
214
|
+
counts = np.zeros((len(item_ids), len(values)))
|
|
215
|
+
np.add.at(counts, (item_codes, codes), 1.0)
|
|
216
|
+
return cls(np.asarray(item_ids), counts, values, level)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def n_units(self) -> int:
|
|
220
|
+
return len(self.item_ids)
|
|
221
|
+
|
|
222
|
+
# -- the two halves of alpha ------------------------------------------
|
|
223
|
+
|
|
224
|
+
def numerators(self, distances=None) -> np.ndarray:
|
|
225
|
+
"""Per-unit observed disagreement, the coincidence contribution.
|
|
226
|
+
|
|
227
|
+
``sum over ordered pairs of squared distance, divided by (m - 1)``.
|
|
228
|
+
Nominal and interval have closed forms that skip the distance
|
|
229
|
+
matrix, which matters when the ratings are continuous and the value
|
|
230
|
+
domain is nearly as large as the data.
|
|
231
|
+
"""
|
|
232
|
+
counts, sizes = self.counts, self.sizes
|
|
233
|
+
if self.level == "nominal":
|
|
234
|
+
return (sizes ** 2 - (counts ** 2).sum(axis=1)) / (sizes - 1)
|
|
235
|
+
if self.level == "interval":
|
|
236
|
+
s1 = counts @ self.values
|
|
237
|
+
s2 = counts @ self.values ** 2
|
|
238
|
+
return 2 * (sizes * s2 - s1 ** 2) / (sizes - 1)
|
|
239
|
+
return np.einsum("uc,ck,uk->u", counts, distances, counts) / (sizes - 1)
|
|
240
|
+
|
|
241
|
+
def expected(self, marginals, distances=None) -> float:
|
|
242
|
+
"""The chance-disagreement denominator, before dividing by n(n-1)."""
|
|
243
|
+
n = marginals.sum()
|
|
244
|
+
if self.level == "nominal":
|
|
245
|
+
return float(n ** 2 - (marginals ** 2).sum())
|
|
246
|
+
if self.level == "interval":
|
|
247
|
+
s1 = marginals @ self.values
|
|
248
|
+
s2 = marginals @ self.values ** 2
|
|
249
|
+
return float(2 * (n * s2 - s1 ** 2))
|
|
250
|
+
return float(marginals @ distances @ marginals)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def is_degenerate(self) -> bool:
|
|
254
|
+
"""True when every pairable rating is the same value.
|
|
255
|
+
|
|
256
|
+
Alpha has no denominator then. That is not perfect agreement, it is
|
|
257
|
+
a scale nobody varied, and the two call for different responses.
|
|
258
|
+
"""
|
|
259
|
+
if self.n_units == 0:
|
|
260
|
+
return False
|
|
261
|
+
marginals = self.counts.sum(axis=0)
|
|
262
|
+
distances = _ordinal_distances(marginals) if self.level == "ordinal" else None
|
|
263
|
+
return self.expected(marginals, distances) == 0
|
|
264
|
+
|
|
265
|
+
def alpha(self) -> float:
|
|
266
|
+
"""Alpha, or NaN when the data cannot carry one.
|
|
267
|
+
|
|
268
|
+
Two items is the floor. One item gives arithmetic that looks like a
|
|
269
|
+
reliability estimate and is not one, and a reader skimming a report
|
|
270
|
+
will quote whatever number is in front of them.
|
|
271
|
+
"""
|
|
272
|
+
if self.n_units < 2:
|
|
273
|
+
return float("nan")
|
|
274
|
+
marginals = self.counts.sum(axis=0)
|
|
275
|
+
distances = _ordinal_distances(marginals) if self.level == "ordinal" else None
|
|
276
|
+
n = marginals.sum()
|
|
277
|
+
expected = self.expected(marginals, distances)
|
|
278
|
+
if expected == 0:
|
|
279
|
+
return float("nan")
|
|
280
|
+
observed = self.numerators(distances).sum()
|
|
281
|
+
return float(1.0 - (n - 1) * observed / expected)
|
|
282
|
+
|
|
283
|
+
# -- the item table ----------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def item_table(self) -> pd.DataFrame:
|
|
286
|
+
"""Each item's disagreement, averaged over its ordered pairs.
|
|
287
|
+
|
|
288
|
+
Dividing the coincidence contribution by the grading count turns it
|
|
289
|
+
into the mean squared distance between two randomly picked ratings
|
|
290
|
+
of that item. An item graded ten times and one graded twice with the
|
|
291
|
+
same spread land on the same number.
|
|
292
|
+
"""
|
|
293
|
+
columns = ["item_id", "n_ratings", "disagreement", "vs_chance"]
|
|
294
|
+
if self.n_units == 0:
|
|
295
|
+
return pd.DataFrame(columns=columns)
|
|
296
|
+
|
|
297
|
+
marginals = self.counts.sum(axis=0)
|
|
298
|
+
distances = _ordinal_distances(marginals) if self.level == "ordinal" else None
|
|
299
|
+
n = marginals.sum()
|
|
300
|
+
expected = self.expected(marginals, distances)
|
|
301
|
+
|
|
302
|
+
disagreement = self.numerators(distances) / self.sizes
|
|
303
|
+
chance = expected / (n * (n - 1)) if expected > 0 else float("nan")
|
|
304
|
+
|
|
305
|
+
table = pd.DataFrame({
|
|
306
|
+
"item_id": self.item_ids,
|
|
307
|
+
"n_ratings": self.sizes.astype(int),
|
|
308
|
+
"disagreement": disagreement,
|
|
309
|
+
"vs_chance": disagreement / chance,
|
|
310
|
+
})
|
|
311
|
+
return (
|
|
312
|
+
table.sort_values("disagreement", ascending=False, kind="mergesort")
|
|
313
|
+
.reset_index(drop=True)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _ordinal_distances(marginals: np.ndarray) -> np.ndarray:
|
|
318
|
+
"""Krippendorff's ordinal metric, for one set of marginals or many.
|
|
319
|
+
|
|
320
|
+
d(c, k) = (sum of marginals from c to k, minus half of each end)^2
|
|
321
|
+
|
|
322
|
+
The distance depends on how the ratings were distributed, so it has to
|
|
323
|
+
be rebuilt for every resample rather than computed once.
|
|
324
|
+
"""
|
|
325
|
+
marg = np.atleast_2d(marginals)
|
|
326
|
+
cumulative = np.cumsum(marg, axis=1)
|
|
327
|
+
spread = (
|
|
328
|
+
cumulative[:, None, :]
|
|
329
|
+
- cumulative[:, :, None]
|
|
330
|
+
+ (marg[:, :, None] - marg[:, None, :]) / 2
|
|
331
|
+
)
|
|
332
|
+
out = spread ** 2
|
|
333
|
+
return out[0] if marginals.ndim == 1 else out
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --------------------------------------------------------------------------
|
|
337
|
+
# The bootstrap
|
|
338
|
+
# --------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def _bootstrap(units, confidence, wanted, n_boot, seed):
|
|
341
|
+
"""Percentile interval over resampled items.
|
|
342
|
+
|
|
343
|
+
Returns the bounds, how many resamples were asked for, and how many came
|
|
344
|
+
back defined. A resample is undefined when every rating in it is the
|
|
345
|
+
same value, which is the unanimous case, so dropping those biases the
|
|
346
|
+
upper bound downward. Past a fixed share of them the interval is refused
|
|
347
|
+
rather than reported.
|
|
348
|
+
"""
|
|
349
|
+
if not wanted:
|
|
350
|
+
return float("nan"), float("nan"), 0, 0
|
|
351
|
+
if n_boot < 1:
|
|
352
|
+
raise ValueError(f"n_boot must be at least 1, got {n_boot}")
|
|
353
|
+
|
|
354
|
+
if units.n_units < 2:
|
|
355
|
+
# One item resampled is the same item every time. Any width drawn
|
|
356
|
+
# around it would be a property of the arithmetic, not the data.
|
|
357
|
+
return float("nan"), float("nan"), n_boot, 0
|
|
358
|
+
|
|
359
|
+
alphas = _resample_alphas(units, n_boot, seed)
|
|
360
|
+
good = alphas[np.isfinite(alphas)]
|
|
361
|
+
usable = int(good.size)
|
|
362
|
+
|
|
363
|
+
if usable < _MIN_USABLE_SHARE * n_boot:
|
|
364
|
+
return float("nan"), float("nan"), n_boot, usable
|
|
365
|
+
|
|
366
|
+
tail = (1 - confidence) / 2
|
|
367
|
+
lo = float(np.percentile(good, 100 * tail))
|
|
368
|
+
hi = float(np.percentile(good, 100 * (1 - tail)))
|
|
369
|
+
return lo, hi, n_boot, usable
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _resample_alphas(units, n_boot, seed) -> np.ndarray:
|
|
373
|
+
"""Alpha for every resample, computed in blocks and never one at a time.
|
|
374
|
+
|
|
375
|
+
Each resample is a multiplicity vector over items. The coincidence
|
|
376
|
+
matrix is additive over items, so a block of resamples is one matrix
|
|
377
|
+
product against the per-item quantities.
|
|
378
|
+
"""
|
|
379
|
+
rng = np.random.default_rng(seed)
|
|
380
|
+
n_units, n_values = units.counts.shape
|
|
381
|
+
ordinal = units.level == "ordinal"
|
|
382
|
+
|
|
383
|
+
fixed_numerators = None if ordinal else units.numerators()
|
|
384
|
+
if ordinal:
|
|
385
|
+
# outer products per unit, so a block's coincidence matrix is one dot
|
|
386
|
+
outer = (
|
|
387
|
+
units.counts[:, :, None] * units.counts[:, None, :]
|
|
388
|
+
/ (units.sizes - 1)[:, None, None]
|
|
389
|
+
).reshape(n_units, n_values * n_values)
|
|
390
|
+
|
|
391
|
+
per_resample = n_values * n_values if ordinal else n_values
|
|
392
|
+
block = int(max(1, min(n_boot, _BLOCK_BUDGET // max(per_resample, 1))))
|
|
393
|
+
|
|
394
|
+
out = np.empty(n_boot)
|
|
395
|
+
for start in range(0, n_boot, block):
|
|
396
|
+
size = min(block, n_boot - start)
|
|
397
|
+
idx = rng.integers(0, n_units, size=(size, n_units))
|
|
398
|
+
picks = _multiplicities(idx, n_units)
|
|
399
|
+
|
|
400
|
+
marginals = picks @ units.counts # (size, V)
|
|
401
|
+
totals = marginals.sum(axis=1) # (size,)
|
|
402
|
+
|
|
403
|
+
if ordinal:
|
|
404
|
+
distances = _ordinal_distances(marginals)
|
|
405
|
+
coincidence = picks @ outer
|
|
406
|
+
observed = (coincidence * distances.reshape(size, -1)).sum(axis=1)
|
|
407
|
+
expected = np.einsum("bc,bck,bk->b", marginals, distances, marginals)
|
|
408
|
+
else:
|
|
409
|
+
observed = picks @ fixed_numerators
|
|
410
|
+
expected = _expected_batch(units, marginals, totals)
|
|
411
|
+
|
|
412
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
413
|
+
alphas = 1.0 - (totals - 1) * observed / expected
|
|
414
|
+
out[start:start + size] = np.where(expected > 0, alphas, np.nan)
|
|
415
|
+
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _multiplicities(idx: np.ndarray, n_units: int) -> np.ndarray:
|
|
420
|
+
"""How many times each item was drawn, per resample.
|
|
421
|
+
|
|
422
|
+
One bincount over the flattened index matrix. Building this with
|
|
423
|
+
np.add.at, or worse a loop, is where a vectorised bootstrap quietly
|
|
424
|
+
stops being one.
|
|
425
|
+
"""
|
|
426
|
+
n_boot = idx.shape[0]
|
|
427
|
+
offset = idx + np.arange(n_boot)[:, None] * n_units
|
|
428
|
+
flat = np.bincount(offset.ravel(), minlength=n_boot * n_units)
|
|
429
|
+
return flat.reshape(n_boot, n_units).astype(float)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _expected_batch(units, marginals, totals) -> np.ndarray:
|
|
433
|
+
if units.level == "nominal":
|
|
434
|
+
return totals ** 2 - (marginals ** 2).sum(axis=1)
|
|
435
|
+
s1 = marginals @ units.values
|
|
436
|
+
s2 = marginals @ units.values ** 2
|
|
437
|
+
return 2 * (totals * s2 - s1 ** 2)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# --------------------------------------------------------------------------
|
|
441
|
+
# Leave one rater out
|
|
442
|
+
# --------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def _dropout_table(data: pd.DataFrame, level: str, alpha: float) -> pd.DataFrame:
|
|
445
|
+
"""Alpha recomputed without each rater, and why it is missing when it is.
|
|
446
|
+
|
|
447
|
+
A NaN here has three possible causes and they call for opposite
|
|
448
|
+
responses, so each gets its own note. Losing the overlap means the
|
|
449
|
+
design depended on that rater. Losing the variance means the graders who
|
|
450
|
+
remain agreed on everything, which is a finding rather than a fault.
|
|
451
|
+
"""
|
|
452
|
+
columns = ["rater_id", "n_ratings", "alpha_without", "delta", "note"]
|
|
453
|
+
raters = list(dict.fromkeys(data["rater_id"]))
|
|
454
|
+
if len(raters) < 2:
|
|
455
|
+
return pd.DataFrame(columns=columns)
|
|
456
|
+
|
|
457
|
+
rows = []
|
|
458
|
+
for rater in raters:
|
|
459
|
+
kept = data[data["rater_id"] != rater]
|
|
460
|
+
n_ratings = int((data["rater_id"] == rater).sum())
|
|
461
|
+
|
|
462
|
+
if kept["rater_id"].nunique() < 2:
|
|
463
|
+
note, without = _NOTE_SINGLE_RATER, float("nan")
|
|
464
|
+
else:
|
|
465
|
+
units = _Units.build(kept, level)
|
|
466
|
+
without = units.alpha()
|
|
467
|
+
if units.n_units == 0:
|
|
468
|
+
note = _NOTE_NO_OVERLAP
|
|
469
|
+
elif units.is_degenerate:
|
|
470
|
+
# checked before the item count, because "they agreed on
|
|
471
|
+
# everything" is the more specific finding and the one worth
|
|
472
|
+
# acting on. The count is why there is no number; the
|
|
473
|
+
# agreement is what happened.
|
|
474
|
+
note = _NOTE_NO_VARIANCE
|
|
475
|
+
elif units.n_units < 2:
|
|
476
|
+
note = _NOTE_ONE_ITEM
|
|
477
|
+
else:
|
|
478
|
+
note = ""
|
|
479
|
+
|
|
480
|
+
rows.append({
|
|
481
|
+
"rater_id": rater,
|
|
482
|
+
"n_ratings": n_ratings,
|
|
483
|
+
"alpha_without": without,
|
|
484
|
+
"delta": without - alpha,
|
|
485
|
+
"note": note,
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
table = pd.DataFrame(rows, columns=columns)
|
|
489
|
+
return (
|
|
490
|
+
table.sort_values(
|
|
491
|
+
"delta", ascending=False, na_position="last", kind="mergesort"
|
|
492
|
+
)
|
|
493
|
+
.reset_index(drop=True)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --------------------------------------------------------------------------
|
|
498
|
+
# The two kappas, for people who expect them
|
|
499
|
+
# --------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def cohens_kappa(r1: Sequence, r2: Sequence) -> KappaResult:
|
|
502
|
+
"""Cohen's kappa between two raters who both graded every item.
|
|
503
|
+
|
|
504
|
+
Parameters
|
|
505
|
+
----------
|
|
506
|
+
r1, r2
|
|
507
|
+
Aligned ratings, one entry per item.
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
KappaResult
|
|
512
|
+
|
|
513
|
+
Notes
|
|
514
|
+
-----
|
|
515
|
+
Kappa moves with how often each category gets used, so two raters score
|
|
516
|
+
lower on a lopsided scale than on a balanced one at the same accuracy.
|
|
517
|
+
Alpha does not have that problem. This is here for compatibility.
|
|
518
|
+
"""
|
|
519
|
+
a = np.asarray(list(r1))
|
|
520
|
+
b = np.asarray(list(r2))
|
|
521
|
+
if a.size == 0:
|
|
522
|
+
raise ValueError("r1 and r2 must not be empty")
|
|
523
|
+
if a.shape != b.shape:
|
|
524
|
+
raise ValueError(
|
|
525
|
+
f"r1 and r2 must be the same length, got {a.size} and {b.size}"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
categories = np.unique(np.concatenate([a, b]))
|
|
529
|
+
table = np.zeros((len(categories), len(categories)))
|
|
530
|
+
np.add.at(
|
|
531
|
+
table,
|
|
532
|
+
(np.searchsorted(categories, a), np.searchsorted(categories, b)),
|
|
533
|
+
1.0,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
n = table.sum()
|
|
537
|
+
observed = np.trace(table) / n
|
|
538
|
+
expected = float(table.sum(axis=1) @ table.sum(axis=0)) / (n * n)
|
|
539
|
+
kappa = float("nan") if expected == 1 else (observed - expected) / (1 - expected)
|
|
540
|
+
|
|
541
|
+
return KappaResult(
|
|
542
|
+
kappa=float(kappa),
|
|
543
|
+
p_observed=float(observed),
|
|
544
|
+
p_expected=float(expected),
|
|
545
|
+
n_items=int(a.size),
|
|
546
|
+
n_raters=2,
|
|
547
|
+
n_categories=int(len(categories)),
|
|
548
|
+
method="cohen",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def fleiss_kappa(ratings: pd.DataFrame) -> KappaResult:
|
|
553
|
+
"""Fleiss' kappa on long-format ratings with a fixed rater count.
|
|
554
|
+
|
|
555
|
+
Parameters
|
|
556
|
+
----------
|
|
557
|
+
ratings
|
|
558
|
+
Same shape as ``rater_agreement`` takes. Every item must have the
|
|
559
|
+
same number of ratings, which is what Fleiss is defined on.
|
|
560
|
+
|
|
561
|
+
Returns
|
|
562
|
+
-------
|
|
563
|
+
KappaResult
|
|
564
|
+
"""
|
|
565
|
+
data = _clean(ratings)
|
|
566
|
+
|
|
567
|
+
per_item = data.groupby("item_id", sort=False)["rating"].size()
|
|
568
|
+
if per_item.nunique() != 1:
|
|
569
|
+
raise ValueError(
|
|
570
|
+
f"Fleiss' kappa needs the same number of ratings on every item, "
|
|
571
|
+
f"got between {per_item.min()} and {per_item.max()}. Ragged "
|
|
572
|
+
f"grading is the normal case and rater_agreement handles it."
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
n_raters = int(per_item.iloc[0])
|
|
576
|
+
if n_raters < 2:
|
|
577
|
+
raise ValueError("Fleiss' kappa needs at least two ratings per item")
|
|
578
|
+
|
|
579
|
+
categories, codes = _coded_values(data, "nominal")
|
|
580
|
+
item_codes, item_ids = pd.factorize(data["item_id"], sort=False)
|
|
581
|
+
table = np.zeros((len(item_ids), len(categories)))
|
|
582
|
+
np.add.at(table, (item_codes, codes), 1.0)
|
|
583
|
+
|
|
584
|
+
n_items = len(item_ids)
|
|
585
|
+
proportions = table.sum(axis=0) / (n_items * n_raters)
|
|
586
|
+
agreement = ((table ** 2).sum(axis=1) - n_raters) / (n_raters * (n_raters - 1))
|
|
587
|
+
|
|
588
|
+
observed = float(agreement.mean())
|
|
589
|
+
expected = float((proportions ** 2).sum())
|
|
590
|
+
kappa = float("nan") if expected == 1 else (observed - expected) / (1 - expected)
|
|
591
|
+
|
|
592
|
+
return KappaResult(
|
|
593
|
+
kappa=float(kappa),
|
|
594
|
+
p_observed=observed,
|
|
595
|
+
p_expected=expected,
|
|
596
|
+
n_items=int(n_items),
|
|
597
|
+
n_raters=int(data["rater_id"].nunique()),
|
|
598
|
+
n_categories=int(len(categories)),
|
|
599
|
+
method="fleiss",
|
|
600
|
+
)
|