judgepanel 0.1.0__tar.gz

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.
@@ -0,0 +1,30 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ title: "judgepanel: estimating LLM-judge accuracy without gold labels"
4
+ type: software
5
+ version: "0.1.0"
6
+ date-released: "2026-08-05"
7
+ license: MIT
8
+ repository-code: "https://github.com/mohammadi-hadi/judgepanel"
9
+ url: "https://github.com/mohammadi-hadi/judgepanel"
10
+ authors:
11
+ - given-names: "Hadi"
12
+ family-names: "Mohammadi"
13
+ orcid: "https://orcid.org/0000-0003-0860-9200"
14
+ affiliation: "Utrecht University"
15
+ keywords:
16
+ - "LLM-as-judge"
17
+ - "Dawid-Skene"
18
+ - "label aggregation"
19
+ - "inter-rater agreement"
20
+ - "evaluation"
21
+ - "large language models"
22
+ abstract: >-
23
+ Estimates each judge's confusion matrix, the class prevalence, and a
24
+ posterior over every item's true label from a panel of imperfect judges
25
+ labelling the same items, with no gold labels, via the Dawid-Skene model
26
+ fitted by EM. Includes majority-vote baselines, Cohen's and Fleiss' kappa,
27
+ bootstrap confidence intervals, and panel simulators. Validated on a real
28
+ five-judge panel over 400 agent trajectories where the estimated judge
29
+ accuracies can be checked against construction-time gold labels, including
30
+ an honest account of the correlated-judge failure mode.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hadi Mohammadi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: judgepanel
3
+ Version: 0.1.0
4
+ Summary: Estimate LLM-judge accuracy without gold labels and aggregate judge panels: Dawid-Skene EM, agreement statistics, bootstrap uncertainty
5
+ Project-URL: Repository, https://github.com/mohammadi-hadi/judgepanel
6
+ Project-URL: Issues, https://github.com/mohammadi-hadi/judgepanel/issues
7
+ Author: Hadi Mohammadi
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: annotation,dawid-skene,evaluation,inter-rater-agreement,label-aggregation,llm-as-judge
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: numpy>=1.26
16
+ Provides-Extra: dev
17
+ Requires-Dist: build>=1.2; extra == 'dev'
18
+ Requires-Dist: mypy>=1.10; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.5; extra == 'dev'
21
+ Requires-Dist: twine>=6.1; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # judgepanel
25
+
26
+ [![CI](https://github.com/mohammadi-hadi/judgepanel/actions/workflows/ci.yml/badge.svg)](https://github.com/mohammadi-hadi/judgepanel/actions/workflows/ci.yml)
27
+
28
+ **Estimate how accurate your LLM judges are — without gold labels — and aggregate
29
+ their verdicts better than majority vote.**
30
+
31
+ When several imperfect judges label the same items, the disagreement pattern
32
+ itself reveals who is reliable. judgepanel fits the classic Dawid–Skene model
33
+ (1979) to a judge panel and recovers, jointly:
34
+
35
+ - each judge's **confusion matrix** (sensitivity/specificity in the binary case),
36
+ - the **class prevalence**,
37
+ - a **posterior over every item's true label**, weighted by estimated judge
38
+ quality instead of one-judge-one-vote.
39
+
40
+ Around the core: majority-vote baselines with explicit tie handling,
41
+ chance-corrected agreement (Cohen's and Fleiss' kappa), bootstrap confidence
42
+ intervals, and simulators for planning panels and studying failure modes.
43
+ Pure NumPy; no model calls; every fit deterministic.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install git+https://github.com/mohammadi-hadi/judgepanel
49
+ ```
50
+
51
+ ## Sixty seconds
52
+
53
+ Your data is one JSON object per (item, judge) verdict:
54
+
55
+ ```json
56
+ {"item": "case-0017", "judge": "gpt-strict", "label": "faulty"}
57
+ ```
58
+
59
+ ```bash
60
+ judgepanel fit examples/data/demo_panel.jsonl --positive faulty --bootstrap 200
61
+ ```
62
+
63
+ ```
64
+ panel: 300 items, 5 judges, 2 classes (clean, faulty)
65
+ converged after 55 iterations
66
+ estimated prevalence: clean=0.577, faulty=0.423
67
+
68
+ judge sens spec youden
69
+ gpt-strict 0.906 0.931 0.836
70
+ gpt-lenient 0.715 0.999 0.714
71
+ small-model 0.692 0.710 0.402
72
+ rules 0.562 0.996 0.559
73
+ flag-happy 0.953 0.394 0.347
74
+ ```
75
+
76
+ The demo panel is synthetic with known truth (`examples/data/demo_gold.jsonl`):
77
+ the true parameters are (0.92, 0.94), (0.70, 0.97), (0.66, 0.72), (0.55, 0.99)
78
+ and (0.97, 0.35) — every estimate above lands within 0.04 without ever seeing a
79
+ gold label, and the flag-everything judge is exposed immediately.
80
+
81
+ Same thing in Python:
82
+
83
+ ```python
84
+ from judgepanel import fit_dawid_skene, read_panel_jsonl
85
+
86
+ panel = read_panel_jsonl("panel.jsonl")
87
+ result = fit_dawid_skene(panel)
88
+ result.sensitivity("faulty") # {"gpt-strict": 0.906, ...}
89
+ result.labels() # posterior-aggregated label per item
90
+ ```
91
+
92
+ Keys are configurable, so existing verdict files work directly
93
+ (`--item-key trajectory_id --judge-key judge_id --label-key faulty`; boolean
94
+ labels become `"true"`/`"false"`).
95
+
96
+ ## Validation on a real judge panel
97
+
98
+ The [trajectory-judge](https://github.com/mohammadi-hadi/trajectory-judge)
99
+ study had five LLM judges label the same 400 agent trajectories as faulty or
100
+ clean, with gold labels known by construction. Fitting judgepanel on the
101
+ verdicts alone (gold hidden), then unblinding
102
+ (`python examples/case_study.py --raw .../results/raw`):
103
+
104
+ | judge | est sens | true sens | est spec | true spec |
105
+ |---|---|---|---|---|
106
+ | programmatic rules | 0.771 | 0.667 | 0.993 | 1.000 |
107
+ | outcome-only (14B) | 0.639 | 0.613 | 0.633 | 0.670 |
108
+ | step-rubric (14B) | 0.995 | 0.857 | 1.000 | 1.000 |
109
+ | step-rubric (8B) | 0.988 | 0.990 | 0.000 | 0.000 |
110
+ | self-consistency k=3 (14B) | 0.980 | 0.843 | 0.994 | 0.990 |
111
+
112
+ Three honest findings:
113
+
114
+ 1. **The spammer is exposed exactly.** The 8B judge flags nearly everything;
115
+ its estimated specificity is 0.000 — matching the truth — with no gold
116
+ label in sight. The judge quality *ranking* is right across the board, and
117
+ the production-default outcome judge is estimated within 0.04.
118
+ 2. **Correlated judges inflate their own estimates.** Self-consistency is the
119
+ step judge sampled three times, and the two agree at kappa 0.96. Dawid–Skene
120
+ assumes conditionally independent judges, so this pair drags the latent
121
+ truth toward its shared verdicts: both get overestimated by up to +0.14,
122
+ and estimated prevalence drops to 0.645 against a true 0.750. The library
123
+ prints the kappa matrix precisely so you check this before believing the fit.
124
+ 3. **No aggregation recovers what every judge misses.** Faults that leave the
125
+ final answer correct are invisible to the whole panel, so posterior
126
+ aggregation (0.895 accuracy) cannot beat majority (0.897) here. Weighted
127
+ aggregation wins when judge quality is *uneven*, not when errors are shared.
128
+
129
+ ## When not to trust it
130
+
131
+ - **Fewer than 3 judges**: weakly identified; the fit warns.
132
+ - **Highly correlated judges** (same base model, ensembles of one judge,
133
+ shared blind spots): estimates bias toward the correlated cluster — check
134
+ `judgepanel agree` first, and prefer diverse judge families.
135
+ - **A panel that is wrong on average**: if most judges are worse than chance,
136
+ the labels-and-mirror solution is indistinguishable without gold.
137
+ - **Tiny or extremely imbalanced panels**: run `judgepanel simulate` with your
138
+ expected operating points to see the estimation error you should expect
139
+ before you rely on the numbers.
140
+
141
+ ## API
142
+
143
+ | call | what it does |
144
+ |---|---|
145
+ | `read_panel_jsonl(path, ...)` | long-format JSONL → `Panel` |
146
+ | `fit_dawid_skene(panel)` | EM fit → confusions, prevalence, posteriors |
147
+ | `bootstrap_judges(panel, n_boot=200)` | percentile CIs for judge parameters |
148
+ | `majority_vote(panel)` | baseline aggregation, ties made explicit |
149
+ | `cohen_kappa`, `fleiss_kappa`, `pairwise_cohen_kappa` | agreement diagnostics |
150
+ | `simulate_panel`, `simulate_confusion_panel` | synthetic panels with known truth |
151
+
152
+ CLI: `judgepanel fit`, `judgepanel agree`, `judgepanel simulate`,
153
+ `judgepanel version` (each supports `--json`).
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
159
+ .venv/bin/pytest # 52 tests: hand-computed values, seeded recovery, CLI
160
+ .venv/bin/ruff check src tests && .venv/bin/mypy src
161
+ ```
162
+
163
+ The suite checks statistical behaviour, not just plumbing: kappa against
164
+ hand-computed tables, EM recovery on seeded synthetic panels, log-likelihood
165
+ monotonicity, bootstrap coverage, and determinism.
166
+
167
+ ## Citation
168
+
169
+ If you use judgepanel, please cite it (see `CITATION.cff`).
170
+
171
+ MIT license.
@@ -0,0 +1,148 @@
1
+ # judgepanel
2
+
3
+ [![CI](https://github.com/mohammadi-hadi/judgepanel/actions/workflows/ci.yml/badge.svg)](https://github.com/mohammadi-hadi/judgepanel/actions/workflows/ci.yml)
4
+
5
+ **Estimate how accurate your LLM judges are — without gold labels — and aggregate
6
+ their verdicts better than majority vote.**
7
+
8
+ When several imperfect judges label the same items, the disagreement pattern
9
+ itself reveals who is reliable. judgepanel fits the classic Dawid–Skene model
10
+ (1979) to a judge panel and recovers, jointly:
11
+
12
+ - each judge's **confusion matrix** (sensitivity/specificity in the binary case),
13
+ - the **class prevalence**,
14
+ - a **posterior over every item's true label**, weighted by estimated judge
15
+ quality instead of one-judge-one-vote.
16
+
17
+ Around the core: majority-vote baselines with explicit tie handling,
18
+ chance-corrected agreement (Cohen's and Fleiss' kappa), bootstrap confidence
19
+ intervals, and simulators for planning panels and studying failure modes.
20
+ Pure NumPy; no model calls; every fit deterministic.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install git+https://github.com/mohammadi-hadi/judgepanel
26
+ ```
27
+
28
+ ## Sixty seconds
29
+
30
+ Your data is one JSON object per (item, judge) verdict:
31
+
32
+ ```json
33
+ {"item": "case-0017", "judge": "gpt-strict", "label": "faulty"}
34
+ ```
35
+
36
+ ```bash
37
+ judgepanel fit examples/data/demo_panel.jsonl --positive faulty --bootstrap 200
38
+ ```
39
+
40
+ ```
41
+ panel: 300 items, 5 judges, 2 classes (clean, faulty)
42
+ converged after 55 iterations
43
+ estimated prevalence: clean=0.577, faulty=0.423
44
+
45
+ judge sens spec youden
46
+ gpt-strict 0.906 0.931 0.836
47
+ gpt-lenient 0.715 0.999 0.714
48
+ small-model 0.692 0.710 0.402
49
+ rules 0.562 0.996 0.559
50
+ flag-happy 0.953 0.394 0.347
51
+ ```
52
+
53
+ The demo panel is synthetic with known truth (`examples/data/demo_gold.jsonl`):
54
+ the true parameters are (0.92, 0.94), (0.70, 0.97), (0.66, 0.72), (0.55, 0.99)
55
+ and (0.97, 0.35) — every estimate above lands within 0.04 without ever seeing a
56
+ gold label, and the flag-everything judge is exposed immediately.
57
+
58
+ Same thing in Python:
59
+
60
+ ```python
61
+ from judgepanel import fit_dawid_skene, read_panel_jsonl
62
+
63
+ panel = read_panel_jsonl("panel.jsonl")
64
+ result = fit_dawid_skene(panel)
65
+ result.sensitivity("faulty") # {"gpt-strict": 0.906, ...}
66
+ result.labels() # posterior-aggregated label per item
67
+ ```
68
+
69
+ Keys are configurable, so existing verdict files work directly
70
+ (`--item-key trajectory_id --judge-key judge_id --label-key faulty`; boolean
71
+ labels become `"true"`/`"false"`).
72
+
73
+ ## Validation on a real judge panel
74
+
75
+ The [trajectory-judge](https://github.com/mohammadi-hadi/trajectory-judge)
76
+ study had five LLM judges label the same 400 agent trajectories as faulty or
77
+ clean, with gold labels known by construction. Fitting judgepanel on the
78
+ verdicts alone (gold hidden), then unblinding
79
+ (`python examples/case_study.py --raw .../results/raw`):
80
+
81
+ | judge | est sens | true sens | est spec | true spec |
82
+ |---|---|---|---|---|
83
+ | programmatic rules | 0.771 | 0.667 | 0.993 | 1.000 |
84
+ | outcome-only (14B) | 0.639 | 0.613 | 0.633 | 0.670 |
85
+ | step-rubric (14B) | 0.995 | 0.857 | 1.000 | 1.000 |
86
+ | step-rubric (8B) | 0.988 | 0.990 | 0.000 | 0.000 |
87
+ | self-consistency k=3 (14B) | 0.980 | 0.843 | 0.994 | 0.990 |
88
+
89
+ Three honest findings:
90
+
91
+ 1. **The spammer is exposed exactly.** The 8B judge flags nearly everything;
92
+ its estimated specificity is 0.000 — matching the truth — with no gold
93
+ label in sight. The judge quality *ranking* is right across the board, and
94
+ the production-default outcome judge is estimated within 0.04.
95
+ 2. **Correlated judges inflate their own estimates.** Self-consistency is the
96
+ step judge sampled three times, and the two agree at kappa 0.96. Dawid–Skene
97
+ assumes conditionally independent judges, so this pair drags the latent
98
+ truth toward its shared verdicts: both get overestimated by up to +0.14,
99
+ and estimated prevalence drops to 0.645 against a true 0.750. The library
100
+ prints the kappa matrix precisely so you check this before believing the fit.
101
+ 3. **No aggregation recovers what every judge misses.** Faults that leave the
102
+ final answer correct are invisible to the whole panel, so posterior
103
+ aggregation (0.895 accuracy) cannot beat majority (0.897) here. Weighted
104
+ aggregation wins when judge quality is *uneven*, not when errors are shared.
105
+
106
+ ## When not to trust it
107
+
108
+ - **Fewer than 3 judges**: weakly identified; the fit warns.
109
+ - **Highly correlated judges** (same base model, ensembles of one judge,
110
+ shared blind spots): estimates bias toward the correlated cluster — check
111
+ `judgepanel agree` first, and prefer diverse judge families.
112
+ - **A panel that is wrong on average**: if most judges are worse than chance,
113
+ the labels-and-mirror solution is indistinguishable without gold.
114
+ - **Tiny or extremely imbalanced panels**: run `judgepanel simulate` with your
115
+ expected operating points to see the estimation error you should expect
116
+ before you rely on the numbers.
117
+
118
+ ## API
119
+
120
+ | call | what it does |
121
+ |---|---|
122
+ | `read_panel_jsonl(path, ...)` | long-format JSONL → `Panel` |
123
+ | `fit_dawid_skene(panel)` | EM fit → confusions, prevalence, posteriors |
124
+ | `bootstrap_judges(panel, n_boot=200)` | percentile CIs for judge parameters |
125
+ | `majority_vote(panel)` | baseline aggregation, ties made explicit |
126
+ | `cohen_kappa`, `fleiss_kappa`, `pairwise_cohen_kappa` | agreement diagnostics |
127
+ | `simulate_panel`, `simulate_confusion_panel` | synthetic panels with known truth |
128
+
129
+ CLI: `judgepanel fit`, `judgepanel agree`, `judgepanel simulate`,
130
+ `judgepanel version` (each supports `--json`).
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
136
+ .venv/bin/pytest # 52 tests: hand-computed values, seeded recovery, CLI
137
+ .venv/bin/ruff check src tests && .venv/bin/mypy src
138
+ ```
139
+
140
+ The suite checks statistical behaviour, not just plumbing: kappa against
141
+ hand-computed tables, EM recovery on seeded synthetic panels, log-likelihood
142
+ monotonicity, bootstrap coverage, and determinism.
143
+
144
+ ## Citation
145
+
146
+ If you use judgepanel, please cite it (see `CITATION.cff`).
147
+
148
+ MIT license.
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "judgepanel"
7
+ version = "0.1.0"
8
+ description = "Estimate LLM-judge accuracy without gold labels and aggregate judge panels: Dawid-Skene EM, agreement statistics, bootstrap uncertainty"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Hadi Mohammadi" }]
13
+ requires-python = ">=3.12"
14
+ keywords = [
15
+ "llm-as-judge",
16
+ "dawid-skene",
17
+ "label-aggregation",
18
+ "inter-rater-agreement",
19
+ "annotation",
20
+ "evaluation",
21
+ ]
22
+ classifiers = [
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ ]
27
+ dependencies = ["numpy>=1.26"]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/mohammadi-hadi/judgepanel"
31
+ Issues = "https://github.com/mohammadi-hadi/judgepanel/issues"
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "ruff>=0.5",
37
+ "mypy>=1.10",
38
+ "build>=1.2",
39
+ "twine>=6.1",
40
+ ]
41
+
42
+ [project.scripts]
43
+ judgepanel = "judgepanel.cli:main"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/judgepanel"]
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["src", "tests", "README.md", "LICENSE", "CITATION.cff"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py312"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
57
+
58
+ [tool.mypy]
59
+ python_version = "3.12"
60
+ strict = true
61
+ warn_unreachable = true
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+ addopts = "-q"
@@ -0,0 +1,39 @@
1
+ """Estimate LLM-judge accuracy without gold labels and aggregate judge panels.
2
+
3
+ The core is the Dawid-Skene model: several imperfect judges label the same
4
+ items, and EM jointly recovers each judge's confusion matrix, the class
5
+ prevalence, and a posterior over every item's true label - no gold labels
6
+ required. Around it: majority-vote baselines, chance-corrected agreement,
7
+ bootstrap confidence intervals, and simulators for planning panels and
8
+ studying failure modes such as correlated judges.
9
+ """
10
+
11
+ from judgepanel.agreement import cohen_kappa, fleiss_kappa, pairwise_cohen_kappa
12
+ from judgepanel.em import DawidSkeneResult, fit_dawid_skene
13
+ from judgepanel.io import read_panel_jsonl, write_labels_jsonl
14
+ from judgepanel.panel import MISSING, Panel
15
+ from judgepanel.simulate import SimJudge, simulate_confusion_panel, simulate_panel
16
+ from judgepanel.uncertainty import BootstrapResult, bootstrap_judges
17
+ from judgepanel.vote import MajorityResult, majority_vote
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "MISSING",
23
+ "BootstrapResult",
24
+ "DawidSkeneResult",
25
+ "MajorityResult",
26
+ "Panel",
27
+ "SimJudge",
28
+ "__version__",
29
+ "bootstrap_judges",
30
+ "cohen_kappa",
31
+ "fit_dawid_skene",
32
+ "fleiss_kappa",
33
+ "majority_vote",
34
+ "pairwise_cohen_kappa",
35
+ "read_panel_jsonl",
36
+ "simulate_confusion_panel",
37
+ "simulate_panel",
38
+ "write_labels_jsonl",
39
+ ]
@@ -0,0 +1,80 @@
1
+ """Chance-corrected agreement between judges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+
10
+ from judgepanel.panel import MISSING, Panel
11
+
12
+ FloatArray = npt.NDArray[np.float64]
13
+
14
+ _EPS = 1e-12
15
+
16
+
17
+ def cohen_kappa(a: Sequence[object], b: Sequence[object]) -> float:
18
+ """Cohen's kappa between two aligned label sequences.
19
+
20
+ Returns 1.0 when both raters agree everywhere with no variation, and NaN
21
+ when chance agreement is 1 but observed agreement is not (kappa undefined).
22
+ """
23
+ if len(a) != len(b):
24
+ raise ValueError("sequences must be aligned")
25
+ if not a:
26
+ raise ValueError("no labels")
27
+ labels_a = list(a)
28
+ labels_b = list(b)
29
+ classes = sorted({*labels_a, *labels_b}, key=repr)
30
+ index = {c: k for k, c in enumerate(classes)}
31
+ n = len(labels_a)
32
+ table = np.zeros((len(classes), len(classes)), dtype=np.float64)
33
+ for x, y in zip(labels_a, labels_b, strict=True):
34
+ table[index[x], index[y]] += 1.0
35
+ table /= n
36
+ observed = float(np.trace(table))
37
+ expected = float(table.sum(axis=1) @ table.sum(axis=0))
38
+ if 1.0 - expected < _EPS:
39
+ return 1.0 if 1.0 - observed < _EPS else float("nan")
40
+ return (observed - expected) / (1.0 - expected)
41
+
42
+
43
+ def pairwise_cohen_kappa(panel: Panel) -> FloatArray:
44
+ """Cohen's kappa for every judge pair, on their co-observed items.
45
+
46
+ The diagonal is 1. A pair with fewer than 2 co-observed items is NaN.
47
+ """
48
+ out = np.full((panel.n_judges, panel.n_judges), np.nan, dtype=np.float64)
49
+ np.fill_diagonal(out, 1.0)
50
+ labels = panel.labels
51
+ for j in range(panel.n_judges):
52
+ for m in range(j + 1, panel.n_judges):
53
+ both = (labels[:, j] != MISSING) & (labels[:, m] != MISSING)
54
+ if int(both.sum()) < 2:
55
+ continue
56
+ kappa = cohen_kappa(labels[both, j].tolist(), labels[both, m].tolist())
57
+ out[j, m] = out[m, j] = kappa
58
+ return out
59
+
60
+
61
+ def fleiss_kappa(panel: Panel) -> float:
62
+ """Fleiss' kappa over the panel, generalised to a variable rater count.
63
+
64
+ Items with fewer than 2 observed labels carry no pairwise-agreement
65
+ information and are skipped.
66
+ """
67
+ counts = panel.counts().astype(np.float64)
68
+ per_item = counts.sum(axis=1)
69
+ usable = per_item >= 2
70
+ if not bool(usable.any()):
71
+ raise ValueError("no item has 2 or more labels")
72
+ counts = counts[usable]
73
+ per_item = per_item[usable]
74
+ agree = ((counts * (counts - 1)).sum(axis=1)) / (per_item * (per_item - 1))
75
+ observed = float(agree.mean())
76
+ shares = counts.sum(axis=0) / counts.sum()
77
+ expected = float(shares @ shares)
78
+ if 1.0 - expected < _EPS:
79
+ return 1.0 if 1.0 - observed < _EPS else float("nan")
80
+ return (observed - expected) / (1.0 - expected)