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/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
+ )