skilldrop-cli 0.16.36 → 0.16.38

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.
Files changed (93) hide show
  1. package/README.md +1 -1
  2. package/catalogue.json +3 -1
  3. package/model-routing.json +8 -0
  4. package/package.json +1 -1
  5. package/packs/ai-engineering/skills/judge-audit/SKILL.md +157 -0
  6. package/packs/ai-engineering/skills/judge-audit/evals/eval_queries.json +11 -0
  7. package/packs/ai-engineering/skills/judge-audit/evals/evals.json +136 -0
  8. package/packs/ai-engineering/skills/judge-audit/evals/files/1/judgements.csv +321 -0
  9. package/packs/ai-engineering/skills/judge-audit/evals/files/1/notes.md +28 -0
  10. package/packs/ai-engineering/skills/judge-audit/evals/files/2/pairwise.csv +101 -0
  11. package/packs/ai-engineering/skills/judge-audit/evals/files/2/run-notes.md +16 -0
  12. package/packs/ai-engineering/skills/judge-audit/evals/files/4/grades.csv +97 -0
  13. package/packs/ai-engineering/skills/judge-audit/evals/files/5/pairwise_judgements.jsonl +120 -0
  14. package/packs/ai-engineering/skills/judge-audit/evals/files/6/rag-judge/README.md +36 -0
  15. package/packs/ai-engineering/skills/judge-audit/evals/files/6/rag-judge/judgments.csv +301 -0
  16. package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/gold_labels.csv +101 -0
  17. package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/run_1.jsonl +100 -0
  18. package/packs/ai-engineering/skills/judge-audit/evals/files/7/moderation/run_2.jsonl +100 -0
  19. package/packs/ai-engineering/skills/judge-audit/evals/files/8/patches.csv +241 -0
  20. package/packs/ai-engineering/skills/judge-audit/evals/files/9/adequacy_ratings.csv +151 -0
  21. package/packs/ai-engineering/skills/judge-audit/examples/summary-panel.md +70 -0
  22. package/packs/ai-engineering/skills/judge-audit/manifest.json +44 -0
  23. package/packs/ai-engineering/skills/judge-audit/reference.md +176 -0
  24. package/packs/ai-engineering/skills/judge-audit/scripts/judge_stats.py +1735 -0
  25. package/packs/frontend/pack.json +9 -3
  26. package/packs/frontend/skills/bundle-size-review/SKILL.md +114 -0
  27. package/packs/frontend/skills/bundle-size-review/evals/eval_queries.json +12 -0
  28. package/packs/frontend/skills/bundle-size-review/evals/evals.json +108 -0
  29. package/packs/frontend/skills/bundle-size-review/evals/files/1/package.json +28 -0
  30. package/packs/frontend/skills/bundle-size-review/evals/files/1/src/utils/format.js +26 -0
  31. package/packs/frontend/skills/bundle-size-review/evals/files/1/stats.json +1334 -0
  32. package/packs/frontend/skills/bundle-size-review/evals/files/2/PR.md +21 -0
  33. package/packs/frontend/skills/bundle-size-review/evals/files/2/meta-after.json +440 -0
  34. package/packs/frontend/skills/bundle-size-review/evals/files/2/meta-before.json +360 -0
  35. package/packs/frontend/skills/bundle-size-review/evals/files/3/package.json +29 -0
  36. package/packs/frontend/skills/bundle-size-review/evals/files/3/stats.json +1583 -0
  37. package/packs/frontend/skills/bundle-size-review/evals/files/4/src/components/CommentBox.jsx +24 -0
  38. package/packs/frontend/skills/bundle-size-review/evals/files/4/stats/after.json +299 -0
  39. package/packs/frontend/skills/bundle-size-review/evals/files/4/stats/before.json +275 -0
  40. package/packs/frontend/skills/bundle-size-review/evals/files/5/.browserslistrc +3 -0
  41. package/packs/frontend/skills/bundle-size-review/evals/files/5/build/meta.json +371 -0
  42. package/packs/frontend/skills/bundle-size-review/evals/files/5/build.mjs +18 -0
  43. package/packs/frontend/skills/bundle-size-review/evals/files/5/package-lock.json +39 -0
  44. package/packs/frontend/skills/bundle-size-review/evals/files/5/package.json +22 -0
  45. package/packs/frontend/skills/bundle-size-review/evals/files/5/src/components/Icon.jsx +8 -0
  46. package/packs/frontend/skills/bundle-size-review/evals/files/5/src/polyfills.js +7 -0
  47. package/packs/frontend/skills/bundle-size-review/evals/files/5/src/utils/groupRows.js +4 -0
  48. package/packs/frontend/skills/bundle-size-review/evals/files/6/.browserslistrc +3 -0
  49. package/packs/frontend/skills/bundle-size-review/evals/files/6/package.json +25 -0
  50. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/App.tsx +8 -0
  51. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/chartLocales.ts +16 -0
  52. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/index.ts +8 -0
  53. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/messages/de.ts +5 -0
  54. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/messages/en.ts +5 -0
  55. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/i18n/useLocale.ts +14 -0
  56. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/main.tsx +10 -0
  57. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/pages/Home.tsx +14 -0
  58. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/pages/Reports.tsx +31 -0
  59. package/packs/frontend/skills/bundle-size-review/evals/files/6/src/routes.tsx +17 -0
  60. package/packs/frontend/skills/bundle-size-review/evals/files/6/stats.json +1869 -0
  61. package/packs/frontend/skills/bundle-size-review/evals/files/6/vite.config.ts +13 -0
  62. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/package.json +26 -0
  63. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/App.tsx +27 -0
  64. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/pages/Analytics.tsx +10 -0
  65. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/src/pages/Users.tsx +13 -0
  66. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/stats.json +1421 -0
  67. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/backoffice/webpack.config.js +25 -0
  68. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/package.json +27 -0
  69. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/App.tsx +18 -0
  70. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/index.tsx +4 -0
  71. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/pages/Home.tsx +10 -0
  72. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/src/pages/Product.tsx +13 -0
  73. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/stats.json +1496 -0
  74. package/packs/frontend/skills/bundle-size-review/evals/files/7/apps/storefront/webpack.config.js +26 -0
  75. package/packs/frontend/skills/bundle-size-review/evals/files/7/package.json +9 -0
  76. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Button.css +3 -0
  77. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Button.js +14 -0
  78. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/Chart.js +21 -0
  79. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/dist/index.js +22 -0
  80. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/package.json +20 -0
  81. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Button.css +3 -0
  82. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Button.tsx +9 -0
  83. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/Chart.tsx +14 -0
  84. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/src/index.ts +6 -0
  85. package/packs/frontend/skills/bundle-size-review/evals/files/7/packages/ui/tsconfig.json +14 -0
  86. package/packs/frontend/skills/bundle-size-review/examples/inputs/stats-after.json +1358 -0
  87. package/packs/frontend/skills/bundle-size-review/examples/inputs/stats-before.json +1334 -0
  88. package/packs/frontend/skills/bundle-size-review/examples/larkspur-sparkline.md +52 -0
  89. package/packs/frontend/skills/bundle-size-review/manifest.json +52 -0
  90. package/packs/frontend/skills/bundle-size-review/reference.md +147 -0
  91. package/packs/frontend/skills/bundle-size-review/scripts/bundle_stats.py +1633 -0
  92. package/packs/frontend/skills/web-performance-review/SKILL.md +3 -3
  93. package/packs/frontend/skills/web-performance-review/manifest.json +2 -1
@@ -0,0 +1,1735 @@
1
+ #!/usr/bin/env python3
2
+ """Agreement, stability and bias statistics for LLM-as-judge records.
3
+
4
+ Reads a CSV or JSONL file with one row per judgement and reports:
5
+ - each judge's label distribution (and the expert's, when given)
6
+ - agreement with expert labels: exact agreement (Wilson interval), Cohen's
7
+ kappa, and for ordinal scores (1-5 and the like) quadratic- and
8
+ linear-weighted kappa, within-one agreement, mean judge, mean expert and
9
+ mean bias (judge - expert) on the same rows, how often the judge scores
10
+ higher or lower, and the bias-vs-length correlation; false-pass and
11
+ false-fail rates when the labels are pass/fail-like
12
+ - agreement between judges: on first trials, and matched trial by trial
13
+ (pooled and per trial) when there are repeats; Fleiss' kappa for three
14
+ or more judges, and across every judge x trial rating
15
+ - test-retest stability: per judge, the share of items whose label changed
16
+ - pairwise data (a `position` or `first_shown` column): how often the
17
+ first-shown output wins, each variant's win rate when shown first and
18
+ second, the order-balanced win rate, how order was assigned per item, and
19
+ win rates with intervals bootstrapped over items (the unit of analysis)
20
+ - length bias, when a length column exists
21
+ - with --group-by COL (or COL>=N to split a numeric column in two): every
22
+ statistic above per value of COL (version, arm, length band), led by a
23
+ comparison table and the between-group differences in judge mean or rate,
24
+ expert mean or rate, and bias, so a judge that errs differently on the
25
+ variants being compared is visible
26
+ - two-label data (pass/fail, violation/ok, relevant/irrelevant): per judge
27
+ AND PER RUN, the confusion counts against the expert, precision, recall
28
+ and false-positive rate on the class the decision acts on (Wilson
29
+ intervals), how many expert positives the sample holds, and accuracy
30
+ beside the always-majority baseline
31
+ - several runs, one file each (columns may differ between files): every
32
+ run is joined to the others and to the expert on the item id, never on
33
+ line order; each run is reported separately, then the items that flipped
34
+ - --gate SPEC: the user's own criterion, checked per judge and run against
35
+ the point estimate and the whole interval (clears / fails / can't tell)
36
+
37
+ Columns (case-insensitive; aliases in brackets; --col NAME=COLUMN overrides):
38
+ item required [item_id, id, case, case_id, ticket_id, question_id, sample_id, passage_id]
39
+ judge optional; absent means one judge [judge_id, model, rater, grader]
40
+ label required unless score is given [verdict, grade, winner, preference, choice, flagged]
41
+ score optional numeric [rating, judge_score, grader_score, model_score]
42
+ trial optional, default 1 [run, repeat]
43
+ expert optional, the human/expert label [human, gold, human_score, expert_score, ...]
44
+ position optional, pairwise: where the CHOSEN output was shown (A/B, 1/2, first/second)
45
+ first_shown optional, pairwise: which VARIANT was shown first [shown_first, first_variant]
46
+ length optional, pointwise: length of the judged output; pairwise: of the chosen one
47
+ [words, tokens, chars, reply_words, word_count, length_chosen]
48
+ other_length optional, pairwise: length of the output not chosen
49
+
50
+ Usage:
51
+ python3 judge_stats.py judgements.csv
52
+ python3 judge_stats.py grades.csv --group-by prompt_version
53
+ python3 judge_stats.py run_1.jsonl run_2.jsonl --gold gold.csv --map true=violation,false=ok \
54
+ --positive violation --gate "recall>=0.9"
55
+ python3 judge_stats.py wide.csv --judges judge_big,judge_small --group-by "tokens>=400" \
56
+ --positive relevant --gate "bias_diff<=1.0"
57
+ python3 judge_stats.py judgements.jsonl --group-by judge --json -o stats.json
58
+ python3 judge_stats.py scores.csv --threshold 4 # only a threshold the user stated
59
+ python3 judge_stats.py judgements.csv --expert-judge lead-reviewer
60
+ python3 judge_stats.py --self-test # check the maths on hand-computed tables
61
+ cat judgements.csv | python3 judge_stats.py -
62
+
63
+ Exit codes: 0 report written, 1 self-test failed, 2 bad input.
64
+ """
65
+
66
+ import argparse
67
+ import csv
68
+ import io
69
+ import json
70
+ import math
71
+ import random
72
+ import sys
73
+ from collections import Counter, defaultdict
74
+ from itertools import combinations
75
+ from pathlib import Path
76
+
77
+ ALIASES = {
78
+ "item": ["item", "item_id", "id", "case", "case_id", "ticket_id", "ticket", "question_id",
79
+ "question", "sample_id", "example_id", "passage_id", "post_id"],
80
+ "judge": ["judge", "judge_id", "model", "rater", "grader", "judge_model", "grader_model"],
81
+ "label": ["label", "verdict", "grade", "winner", "preference", "choice", "flagged", "decision",
82
+ "prediction"],
83
+ "score": ["score", "rating", "judge_score", "grader_score", "model_score", "llm_score"],
84
+ "trial": ["trial", "run", "repeat"],
85
+ "expert": ["expert", "human", "gold", "reference", "expert_label", "human_label",
86
+ "human_score", "expert_score", "gold_label", "gold_score"],
87
+ "position": ["position", "chosen_position", "winner_position"],
88
+ "first_shown": ["first_shown", "shown_first", "first_variant", "order_first"],
89
+ "length": ["length", "words", "tokens", "chars", "length_chosen", "reply_words", "word_count",
90
+ "response_words", "n_words"],
91
+ "other_length": ["other_length", "length_other", "length_rejected"],
92
+ }
93
+ POSITIVE = {"pass", "yes", "true", "correct", "good", "acceptable", "ok", "accept", "approved", "1"}
94
+ NEGATIVE = {"fail", "no", "false", "incorrect", "bad", "poor", "unacceptable", "reject", "rejected", "0"}
95
+ TIES = {"tie", "draw", "equal", "none", "same"}
96
+ FIRST = {"a", "1", "first", "left", "1st"}
97
+ SECOND = {"b", "2", "second", "right", "2nd"}
98
+ SMALL_N = 30
99
+ Z = 1.959964
100
+
101
+
102
+ def natural(s):
103
+ """Sort key so R2 comes before R10."""
104
+ out, num = [], ""
105
+ for ch in str(s):
106
+ if ch.isdigit():
107
+ num += ch
108
+ continue
109
+ if num:
110
+ out.append((0, int(num), ""))
111
+ num = ""
112
+ out.append((1, 0, ch))
113
+ if num:
114
+ out.append((0, int(num), ""))
115
+ return out
116
+
117
+
118
+ def fail(msg):
119
+ sys.stderr.write("error: " + msg + "\n")
120
+ sys.exit(2)
121
+
122
+
123
+ # ---------- loading ----------
124
+
125
+ def read_rows(path):
126
+ if path == "-":
127
+ text = sys.stdin.read()
128
+ name = "stdin"
129
+ else:
130
+ p = Path(path)
131
+ if not p.is_file():
132
+ fail("file not found: " + str(path))
133
+ with p.open(encoding="utf-8-sig") as f:
134
+ text = f.read()
135
+ name = p.name
136
+ if not text.strip():
137
+ fail("the input is empty")
138
+ if name.endswith(".jsonl") or text.lstrip().startswith("{"):
139
+ rows = []
140
+ for n, line in enumerate(text.splitlines(), 1):
141
+ if not line.strip():
142
+ continue
143
+ try:
144
+ obj = json.loads(line)
145
+ except ValueError as e:
146
+ fail("line %d is not valid JSON: %s" % (n, e))
147
+ if not isinstance(obj, dict):
148
+ fail("line %d is not a JSON object" % n)
149
+ rows.append({str(k).strip().lower(): ("" if v is None else str(v).strip())
150
+ for k, v in obj.items()})
151
+ return rows
152
+ reader = csv.DictReader(io.StringIO(text))
153
+ if not reader.fieldnames:
154
+ fail("the CSV has no header row")
155
+ return [{(k or "").strip().lower(): (v or "").strip() for k, v in r.items()} for r in reader]
156
+
157
+
158
+ def resolve(rows, overrides, strict=True, used=None):
159
+ keys = set()
160
+ for r in rows:
161
+ keys.update(r.keys())
162
+ cols = {}
163
+ for canon, col in overrides.items():
164
+ if col not in keys:
165
+ if strict:
166
+ fail("--col %s=%s: no such column (columns: %s)" % (canon, col, ", ".join(sorted(keys))))
167
+ continue
168
+ if used is not None:
169
+ used.add(canon)
170
+ cols[canon] = col
171
+ for canon, names in ALIASES.items():
172
+ if canon in cols:
173
+ continue
174
+ for n in names:
175
+ if n in keys and n not in cols.values():
176
+ cols[canon] = n
177
+ break
178
+ return cols, keys
179
+
180
+
181
+ def to_float(s):
182
+ try:
183
+ x = float(s)
184
+ except (TypeError, ValueError):
185
+ return None
186
+ return x if math.isfinite(x) else None
187
+
188
+
189
+ LABEL_MAP = {}
190
+
191
+
192
+ def norm_label(s):
193
+ s = (s or "").strip()
194
+ s = LABEL_MAP.get(s.lower(), s)
195
+ x = to_float(s)
196
+ if x is not None and x.is_integer():
197
+ return str(int(x))
198
+ low = s.lower()
199
+ return low if low in POSITIVE or low in NEGATIVE or low in TIES else s
200
+
201
+
202
+ def parse_group(group_by):
203
+ """'col' groups by value; 'col>=N' splits a numeric column into col<N and col>=N."""
204
+ g = group_by.strip().lower()
205
+ if ">=" in g:
206
+ col, _, thr = g.partition(">=")
207
+ t = to_float(thr.strip())
208
+ if t is None:
209
+ fail("--group-by %s: the value after >= must be a number" % group_by)
210
+ return col.strip(), t
211
+ return g, None
212
+
213
+
214
+ def group_value(r, gcol, gthr):
215
+ raw = r.get(gcol, "")
216
+ if gthr is None:
217
+ return raw or "(blank)"
218
+ x = to_float(raw)
219
+ if x is None:
220
+ return "(blank)"
221
+ t = ("%g" % gthr)
222
+ return "%s<%s" % (gcol, t) if x < gthr else "%s>=%s" % (gcol, t)
223
+
224
+
225
+ def load(path, threshold, expert_judge, group_by, overrides, trial_override=None, wide=None,
226
+ used=None):
227
+ rows = read_rows(path)
228
+ if not rows:
229
+ fail("no judgement rows found in " + str(path))
230
+ cols, keys = resolve(rows, overrides, strict=used is None, used=used)
231
+ warnings = []
232
+ where = "" if trial_override is None else " in %s" % Path(path).name
233
+ if "item" not in cols:
234
+ fail("no 'item' column%s (accepted names: %s; or pass --col item=<column>)"
235
+ % (where, ", ".join(ALIASES["item"])))
236
+ if wide:
237
+ missing = [c for c in wide if c not in keys]
238
+ if missing:
239
+ fail("--judges: no such column%s: %s (columns: %s)"
240
+ % (where, ", ".join(missing), ", ".join(sorted(keys))))
241
+ elif "label" not in cols and "score" not in cols:
242
+ fail("need a 'label' or a 'score' column%s (or pass --col label=<column>)" % where)
243
+ if threshold is not None and "score" not in cols and not wide:
244
+ fail("--threshold needs a 'score' column")
245
+ if "judge" not in cols and not wide:
246
+ warnings.append("no judge column%s: every row is treated as one judge named 'judge'" % where)
247
+ gcol = gthr = None
248
+ if group_by:
249
+ gcol, gthr = parse_group(group_by)
250
+ if gcol not in keys:
251
+ fail("--group-by %s: no such column%s (columns: %s)"
252
+ % (group_by, where, ", ".join(sorted(keys))))
253
+
254
+ recs, expert, order = [], {}, []
255
+ for n, r in enumerate(rows, 2):
256
+ item = r.get(cols["item"], "")
257
+ if item and item not in order:
258
+ order.append(item)
259
+ cells = [(c, r.get(c, "")) for c in wide] if wide else [
260
+ (r.get(cols["judge"], "") if "judge" in cols else "judge", None)]
261
+ for judge, wide_val in cells:
262
+ if not item or not judge:
263
+ warnings.append("row %d skipped: empty item or judge" % n)
264
+ continue
265
+ if wide:
266
+ raw_label, score = wide_val, to_float(wide_val)
267
+ else:
268
+ raw_label = r.get(cols["label"], "") if "label" in cols else ""
269
+ score = to_float(r.get(cols["score"], "")) if "score" in cols else None
270
+ if threshold is not None:
271
+ if score is None:
272
+ warnings.append("row %d skipped: score is not a number" % n)
273
+ continue
274
+ label = "pass" if score >= threshold else "fail"
275
+ else:
276
+ label = norm_label(raw_label)
277
+ if not label and score is not None and not wide:
278
+ label = norm_label(r.get(cols["score"]))
279
+ if not label:
280
+ warnings.append("row %d skipped: no label for %s" % (n, judge))
281
+ continue
282
+ trial = trial_override or (r.get(cols["trial"], "") if "trial" in cols else "")
283
+ rec = {"item": item, "judge": judge, "trial": trial or "1", "label": label,
284
+ "score": score, "position": None, "first": None, "length": None,
285
+ "other_length": None}
286
+ if gcol:
287
+ rec["group"] = group_value(r, gcol, gthr)
288
+ fill_rec(rec, r, cols)
289
+ recs.append(rec)
290
+ if "expert" in cols:
291
+ e = r.get(cols["expert"], "")
292
+ if threshold is not None and to_float(e) is not None:
293
+ e = "pass" if to_float(e) >= threshold else "fail"
294
+ e = norm_label(e)
295
+ if e and item:
296
+ if item in expert and expert[item] != e:
297
+ warnings.append("item %s has conflicting expert labels (%s, %s); kept the first"
298
+ % (item, expert[item], e))
299
+ else:
300
+ expert[item] = e
301
+ if expert_judge:
302
+ keep = []
303
+ for rec in recs:
304
+ if rec["judge"] == expert_judge:
305
+ expert.setdefault(rec["item"], rec["label"])
306
+ else:
307
+ keep.append(rec)
308
+ recs = keep
309
+ if not recs:
310
+ fail("no usable judgement rows in " + str(path))
311
+ if expert_judge and not expert:
312
+ fail("--expert-judge %s matched no rows" % expert_judge)
313
+ return recs, expert, cols, warnings, order
314
+
315
+
316
+ def load_gold(path, threshold, overrides):
317
+ """Expert labels from their own file: an item column and an expert (or label/score) column."""
318
+ rows = read_rows(path)
319
+ cols, keys = resolve(rows, overrides, strict=False)
320
+ if "item" not in cols:
321
+ fail("--gold: no item column in %s (accepted names: %s)" % (path, ", ".join(ALIASES["item"])))
322
+ col = cols.get("expert") or cols.get("label") or cols.get("score")
323
+ if not col:
324
+ fail("--gold: no expert, label or score column in %s (columns: %s)" % (path, ", ".join(sorted(keys))))
325
+ gold, warnings = {}, []
326
+ for r in rows:
327
+ item, e = r.get(cols["item"], ""), r.get(col, "")
328
+ if threshold is not None and to_float(e) is not None:
329
+ e = "pass" if to_float(e) >= threshold else "fail"
330
+ e = norm_label(e)
331
+ if not item or not e:
332
+ continue
333
+ if item in gold and gold[item] != e:
334
+ warnings.append("gold item %s has conflicting labels (%s, %s); kept the first" % (item, gold[item], e))
335
+ continue
336
+ gold[item] = e
337
+ if not gold:
338
+ fail("--gold: no labelled items in " + str(path))
339
+ return gold, warnings
340
+
341
+
342
+ def merge_case(recs, expert):
343
+ """'VIOLATION' in one file and 'violation' in another are one label."""
344
+ labels = {r["label"] for r in recs} | set(expert.values())
345
+ spell = defaultdict(set)
346
+ for l in labels:
347
+ spell[l.lower()].add(l)
348
+ if not any(len(v) > 1 for v in spell.values()):
349
+ return
350
+ for r in recs:
351
+ r["label"] = r["label"].lower()
352
+ if r["first"]:
353
+ r["first"] = r["first"].lower()
354
+ for i in expert:
355
+ expert[i] = expert[i].lower()
356
+
357
+
358
+ def fill_rec(rec, r, cols):
359
+ if "position" in cols:
360
+ pos = (r.get(cols["position"], "") or "").strip().lower()
361
+ rec["position"] = "first" if pos in FIRST else "second" if pos in SECOND else None
362
+ if "first_shown" in cols:
363
+ rec["first"] = norm_label(r.get(cols["first_shown"], "")) or None
364
+ if "length" in cols:
365
+ rec["length"] = to_float(r.get(cols["length"], ""))
366
+ if "other_length" in cols:
367
+ rec["other_length"] = to_float(r.get(cols["other_length"], ""))
368
+
369
+
370
+ # ---------- statistics ----------
371
+
372
+ def mean(xs):
373
+ xs = list(xs)
374
+ return sum(xs) / len(xs) if xs else None
375
+
376
+
377
+ def wilson(k, n):
378
+ if n == 0:
379
+ return None
380
+ p = k / n
381
+ d = 1 + Z * Z / n
382
+ c = (p + Z * Z / (2 * n)) / d
383
+ h = Z * math.sqrt(p * (1 - p) / n + Z * Z / (4 * n * n)) / d
384
+ return [max(0.0, c - h), min(1.0, c + h)]
385
+
386
+
387
+ def cohen(pairs):
388
+ """Cohen's kappa for a list of (a, b) labels. Returns (kappa, observed, chance)."""
389
+ n = len(pairs)
390
+ if n == 0:
391
+ return None, None, None
392
+ po = sum(a == b for a, b in pairs) / n
393
+ ca, cb = Counter(a for a, _ in pairs), Counter(b for _, b in pairs)
394
+ pe = sum(ca[k] * cb[k] for k in set(ca) | set(cb)) / (n * n)
395
+ if pe >= 1:
396
+ return None, po, pe
397
+ return (po - pe) / (1 - pe), po, pe
398
+
399
+
400
+ def weighted_kappa(pairs, kind="quadratic"):
401
+ """Weighted kappa on numeric (a, b) pairs: 1 - observed / expected disagreement,
402
+ disagreement (a-b)^2 for quadratic, |a-b| for linear."""
403
+ n = len(pairs)
404
+ if n == 0:
405
+ return None
406
+ d = (lambda x, y: (x - y) ** 2) if kind == "quadratic" else (lambda x, y: abs(x - y))
407
+ obs = sum(d(a, b) for a, b in pairs) / n
408
+ ca, cb = Counter(a for a, _ in pairs), Counter(b for _, b in pairs)
409
+ exp = sum(ca[x] * cb[y] * d(x, y) for x in ca for y in cb) / (n * n)
410
+ if exp == 0:
411
+ return None
412
+ return 1 - obs / exp
413
+
414
+
415
+ def fleiss(table):
416
+ """Fleiss' kappa. table: list of per-item label lists, all the same length."""
417
+ if not table:
418
+ return None
419
+ m = len(table[0])
420
+ if m < 2:
421
+ return None
422
+ totals = Counter()
423
+ pbar = 0.0
424
+ for labels in table:
425
+ c = Counter(labels)
426
+ totals.update(c)
427
+ pbar += (sum(v * v for v in c.values()) - m) / (m * (m - 1))
428
+ pbar /= len(table)
429
+ pe = sum((v / (len(table) * m)) ** 2 for v in totals.values())
430
+ if pe >= 1:
431
+ return None
432
+ return (pbar - pe) / (1 - pe)
433
+
434
+
435
+ def ranks(xs):
436
+ order = sorted(range(len(xs)), key=lambda i: xs[i])
437
+ r = [0.0] * len(xs)
438
+ i = 0
439
+ while i < len(order):
440
+ j = i
441
+ while j + 1 < len(order) and xs[order[j + 1]] == xs[order[i]]:
442
+ j += 1
443
+ for k in range(i, j + 1):
444
+ r[order[k]] = (i + j) / 2 + 1
445
+ i = j + 1
446
+ return r
447
+
448
+
449
+ def pearson(pairs):
450
+ if len(pairs) < 3:
451
+ return None
452
+ x, y = [a for a, _ in pairs], [b for _, b in pairs]
453
+ mx, my = mean(x), mean(y)
454
+ sxy = sum((a - mx) * (b - my) for a, b in zip(x, y))
455
+ sx = math.sqrt(sum((a - mx) ** 2 for a in x))
456
+ sy = math.sqrt(sum((b - my) ** 2 for b in y))
457
+ if sx == 0 or sy == 0:
458
+ return None
459
+ return sxy / (sx * sy)
460
+
461
+
462
+ def spearman(pairs):
463
+ if len(pairs) < 3:
464
+ return None
465
+ return pearson(list(zip(ranks([a for a, _ in pairs]), ranks([b for _, b in pairs]))))
466
+
467
+
468
+ def pct_at(vals, q):
469
+ return vals[int(q * (len(vals) - 1))]
470
+
471
+
472
+ def boot(units, stat, reps, rng):
473
+ """Percentile bootstrap over units (items). stat(list_of_units) -> number or None."""
474
+ if len(units) < 2 or reps <= 0:
475
+ return None
476
+ vals = []
477
+ for _ in range(reps):
478
+ v = stat([units[rng.randrange(len(units))] for _ in units])
479
+ if v is not None:
480
+ vals.append(v)
481
+ if len(vals) < reps * 0.5:
482
+ return None
483
+ vals.sort()
484
+ return [pct_at(vals, 0.025), pct_at(vals, 0.975)]
485
+
486
+
487
+ def boot2(ua, ub, stat_a, stat_b, reps, rng):
488
+ """Bootstrap interval for stat_b(ub) - stat_a(ua), resampling each group's items separately."""
489
+ if len(ua) < 2 or len(ub) < 2 or reps <= 0:
490
+ return None
491
+ vals = []
492
+ for _ in range(reps):
493
+ a = stat_a([ua[rng.randrange(len(ua))] for _ in ua])
494
+ b = stat_b([ub[rng.randrange(len(ub))] for _ in ub])
495
+ if a is not None and b is not None:
496
+ vals.append(b - a)
497
+ if len(vals) < reps * 0.5:
498
+ return None
499
+ vals.sort()
500
+ return [pct_at(vals, 0.025), pct_at(vals, 0.975)]
501
+
502
+
503
+ def positive_class(labels, forced):
504
+ if forced:
505
+ for l in labels:
506
+ if l.lower() == forced.lower():
507
+ return l
508
+ return forced.lower()
509
+ labs = {l for l in labels if l not in TIES}
510
+ if len(labs) > 2:
511
+ return None
512
+ pos = [l for l in labs if l in POSITIVE]
513
+ neg = [l for l in labs if l in NEGATIVE]
514
+ if len(pos) == 1 and (len(labs) == 1 or neg):
515
+ return pos[0]
516
+ return None
517
+
518
+
519
+ def trial_key(r):
520
+ x = to_float(r["trial"])
521
+ return (0, x, "") if x is not None else (1, 0, r["trial"])
522
+
523
+
524
+ # ---------- analysis ----------
525
+
526
+ def rate(k, n):
527
+ return {"k": k, "n": n, "rate": k / n if n else None, "ci": wilson(k, n)}
528
+
529
+
530
+ def class_metrics(pairs, pos, reps, rng):
531
+ """Confusion against the expert on the class the decision acts on, with Wilson intervals.
532
+ pairs: (judge_label, expert_label)."""
533
+ tp = sum(a == pos and b == pos for a, b in pairs)
534
+ fp = sum(a == pos and b != pos for a, b in pairs)
535
+ fn = sum(a != pos and b == pos for a, b in pairs)
536
+ tn = len(pairs) - tp - fp - fn
537
+ n = len(pairs)
538
+ flags = [(1.0 if a == pos else 0.0, 1.0 if b == pos else 0.0) for a, b in pairs]
539
+ rb = lambda s: 100 * mean(a - b for a, b in s) if s else None
540
+ return {"positive": pos, "tp": tp, "fp": fp, "fn": fn, "tn": tn,
541
+ "expert_positives": tp + fn, "expert_negatives": fp + tn, "judge_positives": tp + fp,
542
+ "precision": rate(tp, tp + fp), "recall": rate(tp, tp + fn),
543
+ "false_positive_rate": rate(fp, fp + tn),
544
+ "majority_baseline": max(tp + fn, fp + tn) / n if n else None,
545
+ "majority_label": Counter(b for _, b in pairs).most_common(1)[0][0] if pairs else None,
546
+ "rate_bias_points": rb(flags), "rate_bias_points_ci": boot(flags, rb, reps, rng)}
547
+
548
+
549
+ def agree_block(pairs, ordinal, pos, reps, rng):
550
+ """Agreement of one judge (one run) with the expert on the items both labelled."""
551
+ if not pairs:
552
+ return {"n": 0}
553
+ k, po, pe = cohen(pairs)
554
+ hits = sum(a == b for a, b in pairs)
555
+ r = {"n": len(pairs), "exact": hits, "accuracy": po, "accuracy_ci": wilson(hits, len(pairs)),
556
+ "kappa": k, "chance": pe, "kappa_ci": boot(pairs, lambda s: cohen(s)[0], reps, rng)}
557
+ if ordinal:
558
+ npairs = [(to_float(a), to_float(b)) for a, b in pairs]
559
+ errs = [a - b for a, b in npairs]
560
+ r.update({"kappa_quadratic": weighted_kappa(npairs, "quadratic"),
561
+ "kappa_quadratic_ci": boot(npairs, lambda s: weighted_kappa(s, "quadratic"), reps, rng),
562
+ "bias": mean(errs), "bias_ci": boot(errs, mean, reps, rng)})
563
+ if pos:
564
+ r["classes"] = class_metrics(pairs, pos, reps, rng)
565
+ return r
566
+
567
+
568
+ def analyse(recs, expert, cols, warnings, reps, seed, forced_pos, scale=None):
569
+ rng = random.Random(seed)
570
+ judges = sorted({r["judge"] for r in recs}, key=natural)
571
+ by_ji = defaultdict(list) # (judge, item) -> recs sorted by trial
572
+ for r in recs:
573
+ by_ji[(r["judge"], r["item"])].append(r)
574
+ for k in by_ji:
575
+ by_ji[k].sort(key=trial_key)
576
+ first = {k: v[0] for k, v in by_ji.items()}
577
+ items = sorted({r["item"] for r in recs}, key=natural)
578
+ expert = {i: e for i, e in expert.items() if i in set(items)}
579
+ pairwise = "position" in cols or "first_shown" in cols
580
+ all_labels = {r["label"] for r in recs} | set(expert.values())
581
+ pos = None if pairwise else positive_class(all_labels, forced_pos)
582
+ numeric = all(to_float(l) is not None for l in all_labels)
583
+ ordinal = (not pairwise and numeric and pos is None
584
+ and len({to_float(l) for l in all_labels}) > 2)
585
+ if scale is not None:
586
+ ordinal = scale == "ordinal"
587
+ binary = (not pairwise and not ordinal
588
+ and len({l for l in all_labels if l not in TIES}) == 2)
589
+ if binary and pos is None:
590
+ # the class the decision acts on: the expert's minority class unless --positive names it
591
+ c = Counter(expert.values()) if expert else Counter(r["label"] for r in recs)
592
+ for l in all_labels:
593
+ c.setdefault(l, 0)
594
+ pos = sorted(((v, natural(k), k) for k, v in c.items() if k not in TIES))[0][2]
595
+ if forced_pos and not pairwise and pos not in all_labels:
596
+ warnings = list(warnings) + ["--positive %s matches no label (labels: %s)"
597
+ % (forced_pos, ", ".join(sorted(all_labels)))]
598
+ trials = sorted({r["trial"] for r in recs}, key=lambda t: trial_key({"trial": t}))
599
+
600
+ out = {"rows": len(recs), "items": len(items), "judges": judges,
601
+ "trials_max": max(len(v) for v in by_ji.values()), "trials": trials,
602
+ "expert_items": len(expert),
603
+ "mode": "pairwise" if pairwise else "pointwise",
604
+ "scale": "pairwise" if pairwise else "ordinal" if ordinal else "nominal",
605
+ "positive_label": pos, "binary": binary, "warnings": list(warnings)}
606
+
607
+ # label distribution, and the judge's mean on every row when scores are numeric
608
+ dist = {}
609
+ for j in judges:
610
+ labs = [first[(j, i)]["label"] for i in items if (j, i) in first]
611
+ c = Counter(labs)
612
+ n = len(labs)
613
+ d = {"n": n, "counts": dict(sorted(c.items(), key=lambda kv: natural(kv[0]))),
614
+ "top_share": c.most_common(1)[0][1] / n if n else None}
615
+ if ordinal:
616
+ d["mean_all_rows"] = mean(to_float(l) for l in labs)
617
+ dist[j] = d
618
+ if expert:
619
+ c = Counter(expert.values())
620
+ d = {"n": len(expert), "counts": dict(sorted(c.items(), key=lambda kv: natural(kv[0]))),
621
+ "top_share": c.most_common(1)[0][1] / len(expert)}
622
+ if ordinal:
623
+ d["mean_all_rows"] = mean(to_float(e) for e in expert.values())
624
+ dist["expert"] = d
625
+ out["distribution"] = dist
626
+
627
+ length_of = {}
628
+ for r in recs:
629
+ if r["length"] is not None:
630
+ length_of.setdefault(r["item"], r["length"])
631
+ if length_of and not pairwise:
632
+ out["mean_length"] = mean(length_of.values())
633
+
634
+ # test-retest
635
+ flipped, stability = {}, {}
636
+ for j in judges:
637
+ multi = [i for i in items if len(by_ji.get((j, i), [])) >= 2]
638
+ fl = [i for i in multi if len({r["label"] for r in by_ji[(j, i)]}) > 1]
639
+ flipped[j] = set(fl)
640
+ stability[j] = {"items_with_repeats": len(multi), "flipped": len(fl),
641
+ "flip_rate": len(fl) / len(multi) if multi else None,
642
+ "ci": wilson(len(fl), len(multi)), "flipped_items": fl}
643
+ for j in judges:
644
+ retest = []
645
+ for ta, tb in zip(trials, trials[1:]):
646
+ la = {i: r["label"] for (jj, i), v in by_ji.items() if jj == j for r in v if r["trial"] == ta}
647
+ lb = {i: r["label"] for (jj, i), v in by_ji.items() if jj == j for r in v if r["trial"] == tb}
648
+ both = sorted(set(la) & set(lb), key=natural)
649
+ if not both:
650
+ continue
651
+ pairs = [(la[i], lb[i]) for i in both]
652
+ k, po, pe = cohen(pairs)
653
+ flips = [{"item": i, "run_a": la[i], "run_b": lb[i], "expert": expert.get(i)}
654
+ for i in both if la[i] != lb[i]]
655
+ entry = {"runs": [ta, tb], "joined_on_item": len(both),
656
+ "only_in_a": len(set(la) - set(lb)), "only_in_b": len(set(lb) - set(la)),
657
+ "agreed": sum(a == b for a, b in pairs), "agreement": po, "chance": pe,
658
+ "kappa": k, "kappa_ci": boot(pairs, lambda s: cohen(s)[0], reps, rng),
659
+ "flips": flips}
660
+ if pos:
661
+ entry["positive_in_either"] = sorted([i for i in both if pos in (la[i], lb[i])], key=natural)
662
+ entry["positive_in_both"] = sorted([i for i in both if la[i] == lb[i] == pos], key=natural)
663
+ entry["expert_positive_flips"] = [f["item"] for f in flips if f["expert"] == pos]
664
+ entry["expert_positive_missed_in_both"] = sorted(
665
+ [i for i in both if expert.get(i) == pos and la[i] != pos and lb[i] != pos], key=natural)
666
+ retest.append(entry)
667
+ stability[j]["retest"] = retest
668
+ out["stability"] = stability
669
+ unstable = set().union(*flipped.values()) if flipped else set()
670
+ out["unstable_items"] = sorted(unstable, key=natural)
671
+
672
+ # agreement with expert
673
+ vs_expert = {}
674
+ if expert:
675
+ for j in judges:
676
+ res = {}
677
+ for subset, excl in (("all", set()), ("stable", flipped[j])):
678
+ its = [i for i in items if i in expert and (j, i) in first and i not in excl]
679
+ pairs = [(first[(j, i)]["label"], expert[i]) for i in its]
680
+ if not pairs:
681
+ res[subset] = {"n": 0}
682
+ continue
683
+ k, po, pe = cohen(pairs)
684
+ hits = sum(a == b for a, b in pairs)
685
+ r = {"n": len(pairs), "exact": hits, "accuracy": po, "accuracy_ci": wilson(hits, len(pairs)),
686
+ "kappa": k, "chance": pe,
687
+ "kappa_ci": boot(pairs, lambda s: cohen(s)[0], reps, rng)}
688
+ if ordinal:
689
+ npairs = [(to_float(a), to_float(b)) for a, b in pairs]
690
+ errs = [a - b for a, b in npairs]
691
+ r.update({
692
+ "kappa_quadratic": weighted_kappa(npairs, "quadratic"),
693
+ "kappa_quadratic_ci": boot(npairs, lambda s: weighted_kappa(s, "quadratic"), reps, rng),
694
+ "kappa_linear": weighted_kappa(npairs, "linear"),
695
+ "kappa_linear_ci": boot(npairs, lambda s: weighted_kappa(s, "linear"), reps, rng),
696
+ "within_one": sum(abs(e) <= 1 for e in errs),
697
+ "judge_mean": mean(a for a, _ in npairs),
698
+ "expert_mean": mean(b for _, b in npairs),
699
+ "bias": mean(errs), "bias_ci": boot(errs, mean, reps, rng),
700
+ "judge_higher": sum(e > 0 for e in errs), "judge_lower": sum(e < 0 for e in errs)})
701
+ if length_of:
702
+ pts = [(length_of[i], to_float(first[(j, i)]["label"]) - to_float(expert[i]))
703
+ for i in its if i in length_of]
704
+ r["bias_vs_length"] = {"n": len(pts), "pearson": pearson(pts),
705
+ "pearson_ci": boot(pts, pearson, reps, rng),
706
+ "spearman": spearman(pts)}
707
+ if subset == "all" and pos:
708
+ r["classes"] = class_metrics(pairs, pos, reps, rng)
709
+ if subset == "all":
710
+ conf = Counter(pairs)
711
+ r["confusion"] = {"%s|%s" % (a, b): v
712
+ for (a, b), v in sorted(conf.items(), key=lambda kv: natural(kv[0]))}
713
+ if pos:
714
+ neg_items = [(a, b) for a, b in pairs if b != pos and b not in TIES]
715
+ pos_items = [(a, b) for a, b in pairs if b == pos]
716
+ fp = sum(a == pos for a, _ in neg_items)
717
+ ff = sum(a != pos for a, _ in pos_items)
718
+ r["false_pass"] = {"k": fp, "n": len(neg_items),
719
+ "rate": fp / len(neg_items) if neg_items else None,
720
+ "ci": wilson(fp, len(neg_items))}
721
+ r["false_fail"] = {"k": ff, "n": len(pos_items),
722
+ "rate": ff / len(pos_items) if pos_items else None,
723
+ "ci": wilson(ff, len(pos_items))}
724
+ r["pass_rate_judge"] = sum(a == pos for a, _ in pairs) / len(pairs)
725
+ r["pass_rate_expert"] = sum(b == pos for _, b in pairs) / len(pairs)
726
+ res[subset] = r
727
+ if len(trials) > 1:
728
+ res["by_trial"] = {}
729
+ for t in trials:
730
+ tp_ = []
731
+ for i in items:
732
+ rs = [x for x in by_ji.get((j, i), []) if x["trial"] == t]
733
+ if rs and i in expert:
734
+ tp_.append((rs[0]["label"], expert[i]))
735
+ res["by_trial"][t] = agree_block(tp_, ordinal, pos, reps, rng)
736
+ vs_expert[j] = res
737
+ out["vs_expert"] = vs_expert
738
+
739
+ # agreement between judges
740
+ between = []
741
+ for a, b in combinations(judges, 2):
742
+ row = {"judges": [a, b]}
743
+ for subset, excl in (("all", set()), ("stable", unstable)):
744
+ its = [i for i in items if (a, i) in first and (b, i) in first and i not in excl]
745
+ pairs = [(first[(a, i)]["label"], first[(b, i)]["label"]) for i in its]
746
+ k, po, pe = cohen(pairs)
747
+ row[subset] = {"n": len(pairs), "agreement": po, "chance": pe, "kappa": k,
748
+ "kappa_ci": boot(pairs, lambda s: cohen(s)[0], reps, rng)}
749
+ if ordinal and pairs:
750
+ npairs = [(to_float(x), to_float(y)) for x, y in pairs]
751
+ row[subset]["kappa_quadratic"] = weighted_kappa(npairs, "quadratic")
752
+ if len(trials) > 1:
753
+ per_item = defaultdict(list) # item -> [(label_a, label_b)] matched on trial
754
+ per_trial = {}
755
+ for t in trials:
756
+ tp = []
757
+ for i in items:
758
+ ra = [r for r in by_ji.get((a, i), []) if r["trial"] == t]
759
+ rb = [r for r in by_ji.get((b, i), []) if r["trial"] == t]
760
+ if ra and rb:
761
+ tp.append((ra[0]["label"], rb[0]["label"]))
762
+ per_item[i].append((ra[0]["label"], rb[0]["label"]))
763
+ k, po, pe = cohen(tp)
764
+ per_trial[t] = {"n": len(tp), "agreement": po, "kappa": k}
765
+ units = list(per_item.values())
766
+ flat = [p for u in units for p in u]
767
+ k, po, pe = cohen(flat)
768
+ row["matched_trials"] = {
769
+ "n": len(flat), "items": len(units), "agreed": sum(x == y for x, y in flat),
770
+ "agreement": po, "chance": pe, "kappa": k,
771
+ "kappa_ci": boot(units, lambda s: cohen([p for u in s for p in u])[0], reps, rng),
772
+ "per_trial": per_trial}
773
+ if expert:
774
+ both = [i for i in items if i in expert and (a, i) in first and (b, i) in first]
775
+ agree = [i for i in both if first[(a, i)]["label"] == first[(b, i)]["label"]]
776
+ wrong = [i for i in agree if first[(a, i)]["label"] != expert[i]]
777
+ row["shared_errors"] = {"agreed_on_expert_items": len(agree), "both_wrong": len(wrong),
778
+ "items": wrong}
779
+ units = [(first[(a, i)]["label"] == expert[i], first[(b, i)]["label"] == expert[i])
780
+ for i in both]
781
+ if units:
782
+ d = lambda s: mean(1.0 * x - 1.0 * y for x, y in s)
783
+ row["accuracy_diff_vs_expert"] = {"n": len(units), "diff": d(units),
784
+ "ci": boot(units, d, reps, rng),
785
+ "only_a_right": sum(x and not y for x, y in units),
786
+ "only_b_right": sum(y and not x for x, y in units)}
787
+ between.append(row)
788
+ out["between_judges"] = between
789
+
790
+ if len(judges) >= 3:
791
+ full = [i for i in items if all((j, i) in first for j in judges)]
792
+ table = [[first[(j, i)]["label"] for j in judges] for i in full]
793
+ out["fleiss"] = {"n": len(full), "raters": len(judges), "kappa": fleiss(table),
794
+ "kappa_ci": boot(table, fleiss, reps, rng), "basis": "first trial per judge"}
795
+ if len(judges) >= 2 and len(trials) > 1:
796
+ raters = [(j, t) for j in judges for t in trials]
797
+ lab = {(r["judge"], r["trial"], r["item"]): r["label"] for r in recs}
798
+ full = [i for i in items if all((j, t, i) in lab for j, t in raters)]
799
+ table = [[lab[(j, t, i)] for j, t in raters] for i in full]
800
+ if full:
801
+ out["fleiss_all_ratings"] = {"n": len(full), "raters": len(raters), "kappa": fleiss(table),
802
+ "kappa_ci": boot(table, fleiss, reps, rng),
803
+ "basis": "every judge x trial rating"}
804
+
805
+ if pairwise:
806
+ out["pairwise"] = pairwise_stats(recs, judges, items, reps, rng)
807
+
808
+ # length bias
809
+ if "length" in cols:
810
+ lb = {}
811
+ if pairwise:
812
+ for j in judges:
813
+ rs = [r for r in recs if r["judge"] == j and r["label"] not in TIES
814
+ and r["length"] is not None and r["other_length"] is not None
815
+ and r["length"] != r["other_length"]]
816
+ k = sum(r["length"] > r["other_length"] for r in rs)
817
+ lb[j] = {"decided_with_lengths": len(rs), "chose_longer": k,
818
+ "rate": k / len(rs) if rs else None, "ci": wilson(k, len(rs))}
819
+ if "other_length" not in cols:
820
+ out["warnings"].append("pairwise length check needs an 'other_length' column")
821
+ else:
822
+ def num(rec_label, rec_score):
823
+ if rec_score is not None and not pos:
824
+ return rec_score
825
+ if pos:
826
+ return 1.0 if rec_label == pos else 0.0
827
+ return to_float(rec_label)
828
+
829
+ for j in judges:
830
+ pts = [(length_of[i], num(first[(j, i)]["label"], first[(j, i)]["score"]))
831
+ for i in items if (j, i) in first and i in length_of]
832
+ pts = [p for p in pts if p[1] is not None]
833
+ entry = {"n": len(pts), "spearman": spearman(pts), "ci": boot(pts, spearman, reps, rng)}
834
+ if expert and pos:
835
+ negs = [(length_of[i], 1.0 if first[(j, i)]["label"] == pos else 0.0)
836
+ for i in items if i in expert and expert[i] != pos
837
+ and (j, i) in first and i in length_of]
838
+ entry["within_expert_fails"] = {"n": len(negs), "spearman": spearman(negs)}
839
+ lb[j] = entry
840
+ if expert:
841
+ pts = []
842
+ for i, e in expert.items():
843
+ v = (1.0 if e == pos else 0.0) if pos else to_float(e)
844
+ if v is not None and i in length_of:
845
+ pts.append((length_of[i], v))
846
+ lb["expert"] = {"n": len(pts), "spearman": spearman(pts),
847
+ "ci": boot(pts, spearman, reps, rng)}
848
+ out["length"] = lb
849
+
850
+ out["flags"] = flags_for(out)
851
+ return out
852
+
853
+
854
+ def pairwise_stats(recs, judges, items, reps, rng):
855
+ """Position and win statistics. Ties count as judgements no variant won."""
856
+ variants = sorted({r["label"] for r in recs if r["label"] not in TIES}
857
+ | {r["first"] for r in recs if r["first"] and r["first"] not in TIES}, key=natural)
858
+ two = len(variants) == 2
859
+
860
+ def first_variant(r):
861
+ if r["first"]:
862
+ return r["first"]
863
+ if r["position"] and two and r["label"] in variants:
864
+ other = [v for v in variants if v != r["label"]][0]
865
+ return r["label"] if r["position"] == "first" else other
866
+ return None
867
+
868
+ rows = []
869
+ for r in recs:
870
+ fv = first_variant(r)
871
+ rows.append({"item": r["item"], "judge": r["judge"], "trial": r["trial"], "label": r["label"],
872
+ "first": fv, "tie": r["label"] in TIES})
873
+ out = {"variants": variants, "judgements": len(rows),
874
+ "order_known": sum(r["first"] is not None for r in rows),
875
+ "ties": sum(r["tie"] for r in rows)}
876
+
877
+ def by_item(rs):
878
+ d = defaultdict(list)
879
+ for r in rs:
880
+ d[r["item"]].append(r)
881
+ return list(d.values())
882
+
883
+ def first_win_rate(units):
884
+ rs = [r for u in units for r in u if r["first"]]
885
+ return sum(r["label"] == r["first"] for r in rs) / len(rs) if rs else None
886
+
887
+ def position_block(rs):
888
+ known = [r for r in rs if r["first"]]
889
+ units = by_item(known)
890
+ fw = sum(r["label"] == r["first"] for r in known)
891
+ sw = sum((not r["tie"]) and r["label"] != r["first"] for r in known)
892
+ blk = {"judgements": len(rs), "order_known": len(known), "ties": sum(r["tie"] for r in known),
893
+ "first_shown_won": fw, "second_shown_won": sw,
894
+ "first_shown_win_rate": fw / len(known) if known else None,
895
+ "first_shown_win_rate_decided": fw / (fw + sw) if fw + sw else None,
896
+ "ci_by_item": boot(units, first_win_rate, reps, rng)}
897
+ if two:
898
+ per = {}
899
+ for v in variants:
900
+ f = [r for r in known if r["first"] == v]
901
+ s = [r for r in known if r["first"] != v]
902
+ wf = sum(r["label"] == v for r in f)
903
+ ws = sum(r["label"] == v for r in s)
904
+
905
+ def balanced(us, v=v):
906
+ ff = [r for u in us for r in u if r["first"] == v]
907
+ ss = [r for u in us for r in u if r["first"] != v]
908
+ if not ff or not ss:
909
+ return None
910
+ return (sum(r["label"] == v for r in ff) / len(ff)
911
+ + sum(r["label"] == v for r in ss) / len(ss)) / 2
912
+
913
+ def raw(us, v=v):
914
+ rr = [r for u in us for r in u]
915
+ return sum(r["label"] == v for r in rr) / len(rr) if rr else None
916
+
917
+ per[v] = {"shown_first": len(f), "wins_when_first": wf,
918
+ "win_rate_when_first": wf / len(f) if f else None,
919
+ "shown_second": len(s), "wins_when_second": ws,
920
+ "win_rate_when_second": ws / len(s) if s else None,
921
+ "wins": wf + ws, "raw_win_rate": (wf + ws) / len(known) if known else None,
922
+ "raw_ci_by_item": boot(units, raw, reps, rng),
923
+ "order_balanced_win_rate": balanced(units),
924
+ "order_balanced_ci_by_item": boot(units, balanced, reps, rng)}
925
+ blk["by_variant"] = per
926
+ return blk
927
+
928
+ out["all_judges"] = position_block(rows)
929
+ out["per_judge"] = {j: position_block([r for r in rows if r["judge"] == j]) for j in judges}
930
+
931
+ # how order was assigned, per item
932
+ orders = defaultdict(set)
933
+ for r in rows:
934
+ if r["first"]:
935
+ orders[r["item"]].add(r["first"])
936
+ assign = Counter()
937
+ for i in items:
938
+ o = orders.get(i)
939
+ assign["unknown" if not o else "mixed" if len(o) > 1 else "always " + next(iter(o))] += 1
940
+ out["order_by_item"] = dict(sorted(assign.items()))
941
+ out["items_order_fixed_across_trials"] = sum(1 for i in items if len(orders.get(i, ())) == 1)
942
+ return out
943
+
944
+
945
+ def flags_for(o):
946
+ flags = []
947
+ for j, res in o["vs_expert"].items():
948
+ n = res.get("all", {}).get("n", 0)
949
+ if n and n < SMALL_N:
950
+ flags.append("%s vs expert: n=%d is under %d; the interval is too wide to conclude much"
951
+ % (j, n, SMALL_N))
952
+ cl = res.get("all", {}).get("classes")
953
+ if cl:
954
+ for side, k in (("expert_positives", "recall"), ("expert_negatives", "false_positive_rate")):
955
+ m = cl[side]
956
+ c = cl[k]["ci"]
957
+ if 0 < m < 20 and c:
958
+ flags.append("%s: the expert has only %d %s`%s` item(s), so %s rests on %d; its "
959
+ "95%% interval runs %s to %s" % (
960
+ j, m, "" if side == "expert_positives" else "non-",
961
+ cl["positive"], k.replace("_", " "), m, pct(c[0]), pct(c[1])))
962
+ for row in o["between_judges"]:
963
+ n = row["all"]["n"]
964
+ if n and n < SMALL_N:
965
+ flags.append("%s vs %s: n=%d is under %d" % (row["judges"][0], row["judges"][1], n, SMALL_N))
966
+ if not o["expert_items"]:
967
+ flags.append("no expert labels: nothing here measures whether the judges are right, "
968
+ "only whether they are consistent")
969
+ pw = o.get("pairwise")
970
+ if pw:
971
+ a = pw["all_judges"]
972
+ c = a.get("ci_by_item")
973
+ if c and (c[0] > 0.5 or c[1] < 0.5):
974
+ flags.append("the first-shown output won %d of %d judgements with known order (%s); "
975
+ "the item-level interval excludes 50%%"
976
+ % (a["first_shown_won"], a["order_known"], pct(a["first_shown_win_rate"])))
977
+ for v, b in a.get("by_variant", {}).items():
978
+ n = b["shown_first"] + b["shown_second"]
979
+ if n and abs(b["shown_first"] / n - 0.5) >= 0.1:
980
+ flags.append("`%s` was shown first in %d of %d judgements, so order was not balanced; "
981
+ "raw win rates mix quality with position" % (v, b["shown_first"], n))
982
+ break
983
+ if pw["items_order_fixed_across_trials"] and o["trials_max"] > 1:
984
+ flags.append("%d of %d items kept the same order in every judgement; on those items "
985
+ "repeats never swapped order, so they cannot separate position from quality"
986
+ % (pw["items_order_fixed_across_trials"], o["items"]))
987
+ if o["trials_max"] < 2:
988
+ flags.append("one trial per judge and item: stability (flip rate) was not measured")
989
+ if o["expert_items"] and o.get("expert_raters", 1) == 1:
990
+ flags.append("one expert label per item: there is no expert-expert baseline to compare the "
991
+ "judge with")
992
+ return flags
993
+
994
+
995
+ def group_compare(groups, ordinal, reps, seed, factor=1.0):
996
+ """Differences between groups, each against the first group, on the judge means, the expert
997
+ means and the bias, with intervals that resample items within each group. For two-label data
998
+ the 'scores' are 1/0 for the positive class and factor=100 gives percentage points."""
999
+ rng = random.Random(seed + 1)
1000
+ names = list(groups)
1001
+ if len(names) < 2:
1002
+ return []
1003
+ ref = names[0]
1004
+ out = []
1005
+ for g in names[1:]:
1006
+ row = {"group": g, "reference": ref, "by_judge": {}}
1007
+ for j in groups[g]["_judges"]:
1008
+ if j not in groups[ref]["_judges"]:
1009
+ continue
1010
+ entry = {}
1011
+ if ordinal:
1012
+ ja, jb = groups[ref]["_scores"][j], groups[g]["_scores"][j]
1013
+ fm = lambda s: factor * mean(s)
1014
+ entry["judge_mean_all_rows_diff"] = (fm(jb) - fm(ja)) if ja and jb else None
1015
+ entry["judge_mean_all_rows_diff_ci"] = boot2(ja, jb, fm, fm, reps, rng)
1016
+ entry["units"] = "percentage points of the '%s' rate" % groups[ref].get("_pos") \
1017
+ if factor != 1.0 else "score points"
1018
+ pa, pb = groups[ref]["_pairs"].get(j, []), groups[g]["_pairs"].get(j, [])
1019
+ if pa and pb:
1020
+ ex = lambda s: factor * mean(b for _, b in s)
1021
+ jd = lambda s: factor * mean(a for a, _ in s)
1022
+ bi = lambda s: factor * mean(a - b for a, b in s)
1023
+ entry["expert_mean_diff"] = ex(pb) - ex(pa)
1024
+ entry["expert_mean_diff_ci"] = boot2(pa, pb, ex, ex, reps, rng)
1025
+ entry["judge_mean_labelled_diff"] = jd(pb) - jd(pa)
1026
+ entry["judge_mean_labelled_diff_ci"] = boot2(pa, pb, jd, jd, reps, rng)
1027
+ entry["bias_diff"] = bi(pb) - bi(pa)
1028
+ entry["bias_diff_ci"] = boot2(pa, pb, bi, bi, reps, rng)
1029
+ row["by_judge"][j] = entry
1030
+ out.append(row)
1031
+ return out
1032
+
1033
+
1034
+ def analyse_groups(recs, expert, cols, reps, seed, forced_pos, scale, binary=False):
1035
+ names = sorted({r["group"] for r in recs}, key=natural)
1036
+ groups, raw = {}, {}
1037
+ for g in names:
1038
+ sub = [r for r in recs if r["group"] == g]
1039
+ res = analyse(sub, expert, cols, [], reps, seed, forced_pos, scale)
1040
+ groups[g] = res
1041
+ js = res["judges"]
1042
+ first = {}
1043
+ for r in sorted(sub, key=trial_key):
1044
+ first.setdefault((r["judge"], r["item"]), r)
1045
+ raw[g] = {"_judges": js, "_scores": {}, "_pairs": {}, "_pos": forced_pos}
1046
+ if scale == "ordinal" or binary:
1047
+ v = (lambda l: 1.0 if l == forced_pos else 0.0) if binary else to_float
1048
+ for j in js:
1049
+ its = sorted({i for (jj, i) in first if jj == j}, key=natural)
1050
+ raw[g]["_scores"][j] = [v(first[(j, i)]["label"]) for i in its]
1051
+ raw[g]["_pairs"][j] = [(v(first[(j, i)]["label"]), v(expert[i]))
1052
+ for i in its if i in expert]
1053
+ return groups, group_compare(raw, scale == "ordinal" or binary, reps, seed,
1054
+ 100.0 if binary else 1.0)
1055
+
1056
+
1057
+ # ---------- the user's gate ----------
1058
+
1059
+ GATE_METRICS = {
1060
+ "accuracy": "rate", "kappa": "num", "quadratic": "num", "precision": "rate", "recall": "rate",
1061
+ "fpr": "rate", "false_positives": "count", "false_negatives": "count", "flip_rate": "rate",
1062
+ "bias": "abs", "bias_diff": "abs",
1063
+ }
1064
+ GATE_ALIASES = {"diff": "bias_diff", "kappa_quadratic": "quadratic", "false_positive_rate": "fpr",
1065
+ "fp": "false_positives", "fn": "false_negatives", "flips": "flip_rate"}
1066
+
1067
+
1068
+ def parse_gate(spec):
1069
+ s = spec.replace(" ", "").lower()
1070
+ for op in (">=", "<=", ">", "<", "="):
1071
+ if op in s:
1072
+ name, _, val = s.partition(op)
1073
+ break
1074
+ else:
1075
+ fail("--gate %s: expected METRIC>=VALUE or METRIC<=VALUE (metrics: %s)"
1076
+ % (spec, ", ".join(GATE_METRICS)))
1077
+ name = GATE_ALIASES.get(name, name)
1078
+ if name not in GATE_METRICS:
1079
+ fail("--gate %s: unknown metric '%s' (metrics: %s)" % (spec, name, ", ".join(GATE_METRICS)))
1080
+ if op == "=":
1081
+ op = "<=" if GATE_METRICS[name] == "abs" else ">="
1082
+ is_pct = val.endswith("%")
1083
+ v = to_float(val.rstrip("%"))
1084
+ if v is None:
1085
+ fail("--gate %s: '%s' is not a number" % (spec, val))
1086
+ if GATE_METRICS[name] == "rate" and (is_pct or v > 1):
1087
+ v /= 100.0
1088
+ return {"spec": spec, "metric": name, "op": op, "value": v}
1089
+
1090
+
1091
+ def judge_gate(g, value, interval):
1092
+ """clears when the whole interval meets the criterion, fails when none of it does."""
1093
+ if value is None:
1094
+ return "not measurable"
1095
+ kind, op, v = GATE_METRICS[g["metric"]], g["op"], g["value"]
1096
+ if kind == "abs":
1097
+ x = abs(value)
1098
+ lo_hi = None
1099
+ if interval:
1100
+ lo, hi = interval
1101
+ lo_hi = (0.0 if lo <= 0 <= hi else min(abs(lo), abs(hi)), max(abs(lo), abs(hi)))
1102
+ value, interval = x, lo_hi
1103
+ meets = (lambda x: x >= v) if op == ">=" else (lambda x: x > v) if op == ">" else \
1104
+ (lambda x: x <= v) if op == "<=" else (lambda x: x < v)
1105
+ point = meets(value)
1106
+ if not interval:
1107
+ return "clears" if point else "fails"
1108
+ lo, hi = interval
1109
+ if meets(lo) and meets(hi):
1110
+ return "clears"
1111
+ if not meets(lo) and not meets(hi):
1112
+ return "fails"
1113
+ return "can't tell: the point estimate %s, the interval spans the criterion" % (
1114
+ "meets it" if point else "misses it")
1115
+
1116
+
1117
+ def evaluate_gates(gates, o):
1118
+ res = []
1119
+ for g in gates:
1120
+ m = g["metric"]
1121
+ rows = []
1122
+ if m == "flip_rate":
1123
+ for j, st in o["stability"].items():
1124
+ if st["items_with_repeats"]:
1125
+ rows.append((j, None, st["flip_rate"], st["ci"]))
1126
+ elif m == "bias_diff":
1127
+ for row in o.get("group_compare", []):
1128
+ for j, e in row["by_judge"].items():
1129
+ if e.get("bias_diff") is not None:
1130
+ rows.append((j, "%s minus %s" % (row["group"], row["reference"]),
1131
+ e["bias_diff"], e.get("bias_diff_ci")))
1132
+ if not o.get("group_compare"):
1133
+ res.append({"gate": g, "results": [], "note": "bias_diff needs --group-by"})
1134
+ continue
1135
+ else:
1136
+ for j, ve in o["vs_expert"].items():
1137
+ blocks = ve.get("by_trial") or {o["trials"][0]: ve.get("all", {})}
1138
+ for t, b in blocks.items():
1139
+ c = b.get("classes", {})
1140
+ val = ci = None
1141
+ if m in ("accuracy", "kappa"):
1142
+ val, ci = b.get(m), b.get(m + "_ci")
1143
+ elif m == "quadratic":
1144
+ val, ci = b.get("kappa_quadratic"), b.get("kappa_quadratic_ci")
1145
+ elif m in ("precision", "recall"):
1146
+ val, ci = c.get(m, {}).get("rate"), c.get(m, {}).get("ci")
1147
+ elif m == "fpr":
1148
+ val, ci = c.get("false_positive_rate", {}).get("rate"), c.get("false_positive_rate", {}).get("ci")
1149
+ elif m == "false_positives":
1150
+ val = c.get("fp")
1151
+ elif m == "false_negatives":
1152
+ val = c.get("fn")
1153
+ elif m == "bias":
1154
+ if "bias" in b:
1155
+ val, ci = b["bias"], b.get("bias_ci")
1156
+ else:
1157
+ val, ci = c.get("rate_bias_points"), c.get("rate_bias_points_ci")
1158
+ rows.append((j, t if len(blocks) > 1 else None, val, ci))
1159
+ res.append({"gate": g, "results": [{"judge": j, "run": t, "value": v, "ci": c,
1160
+ "outcome": judge_gate(g, v, c)} for j, t, v, c in rows]})
1161
+ return res
1162
+
1163
+
1164
+ # ---------- markdown ----------
1165
+
1166
+ def pct(x):
1167
+ return "n/a" if x is None else "%.0f%%" % (100 * x)
1168
+
1169
+
1170
+ def num2(x):
1171
+ return "n/a" if x is None else "%.2f" % x
1172
+
1173
+
1174
+ def sgn(x):
1175
+ return "n/a" if x is None else "%+.2f" % x
1176
+
1177
+
1178
+ def pts(x):
1179
+ return "n/a" if x is None else "%+.1f pts" % x
1180
+
1181
+
1182
+ def ci(c, as_pct=False, signed=False, points=False):
1183
+ if not c:
1184
+ return ""
1185
+ if points:
1186
+ return " [%+.1f, %+.1f]" % (c[0], c[1])
1187
+ f = pct if as_pct else sgn if signed else num2
1188
+ return " [%s, %s]" % (f(c[0]), f(c[1]))
1189
+
1190
+
1191
+ def run_name(o, t):
1192
+ n = o.get("trial_names", {}).get(t)
1193
+ return "run %s (%s)" % (t, n) if n else "trial %s" % t
1194
+
1195
+
1196
+ def rare_section(o):
1197
+ """Per judge and per run: confusion on the class the decision acts on, precision, recall."""
1198
+ pl = o.get("positive_label")
1199
+ L = ["### Against the expert on `%s`, per judge and run" % pl, "",
1200
+ "Joined to the expert on the item id. TP = both say `%s`; FP = judge says `%s`, expert "
1201
+ "does not; FN = expert says `%s`, judge does not." % (pl, pl, pl), "",
1202
+ "| Judge | Run | n | Judge / expert say `%s` | TP | FP | FN | TN | Precision | Recall | "
1203
+ "False-positive rate | Accuracy (always-majority baseline) | Cohen's kappa |" % pl,
1204
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
1205
+ for j, ve in o["vs_expert"].items():
1206
+ blocks = ve.get("by_trial") or {o["trials"][0]: ve.get("all", {})}
1207
+ for t, b in blocks.items():
1208
+ c = b.get("classes")
1209
+ if not b.get("n") or not c:
1210
+ continue
1211
+ p, r, f = c["precision"], c["recall"], c["false_positive_rate"]
1212
+ L.append("| %s | %s | %d | %s | %d | %d | %d | %d | %s%s | %s%s | %s%s | %s%s (%s) | %s%s |" % (
1213
+ j, run_name(o, t) if len(blocks) > 1 else "%s only" % run_name(o, t) if o["trials_max"] > 1 else "-",
1214
+ b["n"], "%s / %s" % (frac(c["judge_positives"], b["n"]), frac(c["expert_positives"], b["n"])),
1215
+ c["tp"], c["fp"], c["fn"], c["tn"],
1216
+ frac(p["k"], p["n"]) if p["n"] else "n/a", ci(p["ci"], True),
1217
+ frac(r["k"], r["n"]) if r["n"] else "n/a", ci(r["ci"], True),
1218
+ frac(f["k"], f["n"]) if f["n"] else "n/a", ci(f["ci"], True),
1219
+ frac(b["exact"], b["n"]), ci(b["accuracy_ci"], True),
1220
+ "always `%s`: %s" % (c["majority_label"], pct(c["majority_baseline"])),
1221
+ num2(b["kappa"]), ci(b["kappa_ci"])))
1222
+ L.append("")
1223
+ return L
1224
+
1225
+
1226
+ def retest_section(o):
1227
+ pl = o.get("positive_label")
1228
+ L = ["### Run against run (joined on item id)", "",
1229
+ "| Judge | Runs | Items in both | Only in one | Same label | Chance | Cohen's kappa | Flipped |",
1230
+ "|---|---|---|---|---|---|---|---|"]
1231
+ notes = []
1232
+ for j, st in o["stability"].items():
1233
+ for e in st.get("retest", []):
1234
+ a, b = e["runs"]
1235
+ L.append("| %s | %s vs %s | %d | %d / %d | %s | %s | %s%s | %d |" % (
1236
+ j, a, b, e["joined_on_item"], e["only_in_a"], e["only_in_b"],
1237
+ frac(e["agreed"], e["joined_on_item"]), pct(e["chance"]), num2(e["kappa"]),
1238
+ ci(e["kappa_ci"]), len(e["flips"])))
1239
+ fl = ", ".join("%s (%s → %s%s)" % (f["item"], f["run_a"], f["run_b"],
1240
+ ", expert " + (f["expert"] or "unlabelled") if o["expert_items"] else "")
1241
+ for f in e["flips"][:25]) + (" …" if len(e["flips"]) > 25 else "")
1242
+ notes.append("- **%s**, run %s → run %s flips: %s" % (j, a, b, fl or "none"))
1243
+ if pl and "positive_in_either" in e:
1244
+ ids = lambda xs: (" (%s)" % ", ".join(xs)) if 0 < len(xs) <= 25 else ""
1245
+ line = " - `%s` in either run: %d%s; in both runs: %d%s." % (
1246
+ pl, len(e["positive_in_either"]), ids(e["positive_in_either"]),
1247
+ len(e["positive_in_both"]), ids(e["positive_in_both"]))
1248
+ if o["expert_items"]:
1249
+ line += " Expert-`%s` items that flipped: %s. Expert-`%s` items missed in both runs: %s." % (
1250
+ pl, ", ".join(e["expert_positive_flips"]) or "none",
1251
+ pl, ", ".join(e["expert_positive_missed_in_both"]) or "none")
1252
+ notes.append(line)
1253
+ return L + [""] + notes + [""]
1254
+
1255
+
1256
+ def gate_section(o):
1257
+ L = ["## Against the stated criterion", ""]
1258
+ for gr in o["gates"]:
1259
+ g = gr["gate"]
1260
+ if gr.get("note"):
1261
+ L.append("- `%s`: %s" % (g["spec"], gr["note"]))
1262
+ continue
1263
+ if not gr["results"]:
1264
+ L.append("- `%s`: not measurable on this data" % g["spec"])
1265
+ for r in gr["results"]:
1266
+ kind = GATE_METRICS[g["metric"]]
1267
+ v, c = r["value"], r["ci"]
1268
+ shown = "n/a" if v is None else pct(v) if kind == "rate" else \
1269
+ str(v) if kind == "count" else "%+.2f" % v
1270
+ cshown = ci(c, as_pct=True) if kind == "rate" else ci(c, signed=True) if c else ""
1271
+ L.append("- `%s` · %s%s: %s%s → **%s**" % (
1272
+ g["spec"], r["judge"], (", " + (run_name(o, r["run"]) if g["metric"] != "bias_diff"
1273
+ else r["run"])) if r["run"] else "",
1274
+ shown, cshown, r["outcome"]))
1275
+ L.append("")
1276
+ return L
1277
+
1278
+
1279
+ def frac(k, n):
1280
+ return "%d/%d (%s)" % (k, n, pct(k / n if n else None))
1281
+
1282
+
1283
+ def group_section(o, col):
1284
+ groups = o["groups"]
1285
+ names = list(groups)
1286
+ L = ["## By %s (the comparison the decision makes)" % col, ""]
1287
+ first = groups[names[0]]
1288
+ if first["mode"] == "pairwise":
1289
+ L += ["| %s | Judge | Judgements | First-shown won | Order-balanced win rate | Flip rate |" % col,
1290
+ "|---|---|---|---|---|---|"]
1291
+ for g in names:
1292
+ pw = groups[g]["pairwise"]
1293
+ blocks = list(pw["per_judge"].items())
1294
+ if len(blocks) > 1:
1295
+ blocks = [("all judges", pw["all_judges"])] + blocks
1296
+ for j, b in blocks:
1297
+ bal = "; ".join("%s %s%s" % (v, pct(x["order_balanced_win_rate"]),
1298
+ ci(x["order_balanced_ci_by_item"], True))
1299
+ for v, x in b.get("by_variant", {}).items())
1300
+ st = groups[g]["stability"].get(j)
1301
+ flip = ("%d/%d" % (st["flipped"], st["items_with_repeats"])) if st and st["items_with_repeats"] else "n/a"
1302
+ L.append("| %s | %s | %d | %s | %s | %s |"
1303
+ % (g, j, b["judgements"], frac(b["first_shown_won"], b["order_known"]),
1304
+ bal or "n/a", flip))
1305
+ L.append("")
1306
+ return L
1307
+
1308
+ ordinal = any(groups[g]["scale"] == "ordinal" for g in names)
1309
+ for j in o["judges"]:
1310
+ rows = []
1311
+ for g in names:
1312
+ res = groups[g]
1313
+ r = res["vs_expert"].get(j, {}).get("all", {})
1314
+ d = res["distribution"].get(j, {})
1315
+ if not d.get("n"):
1316
+ continue
1317
+ ln = res.get("mean_length")
1318
+ if ordinal:
1319
+ bl = r.get("bias_vs_length", {})
1320
+ rows.append("| %s | %d | %s | %d | %s | %s | %s | %s | %s%s | %s%s | %s / %s | %s | %s |" % (
1321
+ g, d.get("n", 0), num2(d.get("mean_all_rows")), r.get("n", 0),
1322
+ num2(r.get("judge_mean")), num2(r.get("expert_mean")),
1323
+ frac(r["exact"], r["n"]) if r.get("n") else "n/a",
1324
+ frac(r["within_one"], r["n"]) if r.get("n") else "n/a",
1325
+ num2(r.get("kappa_quadratic")), ci(r.get("kappa_quadratic_ci")),
1326
+ sgn(r.get("bias")), ci(r.get("bias_ci"), signed=True),
1327
+ r.get("judge_higher", "n/a"), r.get("judge_lower", "n/a"),
1328
+ "n/a" if ln is None else "%.0f" % ln, num2(bl.get("pearson"))))
1329
+ else:
1330
+ c = r.get("classes")
1331
+ pl = o.get("positive_label")
1332
+ jr = d.get("counts", {}).get(pl, 0) if pl else None
1333
+ rows.append("| %s | %d | %s | %d | %s | %s%s | %s | %s | %s%s |" % (
1334
+ g, d.get("n", 0), frac(jr, d["n"]) if jr is not None else "n/a", r.get("n", 0),
1335
+ frac(r["exact"], r["n"]) if r.get("n") else "n/a",
1336
+ num2(r.get("kappa")), ci(r.get("kappa_ci")),
1337
+ "%d/%d/%d/%d" % (c["tp"], c["fp"], c["fn"], c["tn"]) if c else "n/a",
1338
+ "%d/%d" % (c["fp"], c["expert_negatives"]) if c else "n/a",
1339
+ pts(c["rate_bias_points"]) if c else "n/a",
1340
+ ci(c["rate_bias_points_ci"], points=True) if c else ""))
1341
+ L.append("**%s**" % j)
1342
+ L.append("")
1343
+ if ordinal:
1344
+ L += ["| %s | Rows | Judge mean, all rows | Expert-labelled | Judge mean, labelled | "
1345
+ "Expert mean, labelled | Exact agreement | Within 1 | Quadratic kappa | "
1346
+ "Bias judge-expert | Judge higher / lower | Mean length | Bias vs length r |" % col,
1347
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
1348
+ else:
1349
+ pl = o.get("positive_label")
1350
+ L += ["| %s | Rows | Judge says `%s`, all rows | Expert-labelled | Exact agreement | "
1351
+ "Cohen's kappa | TP/FP/FN/TN on `%s` | Judge `%s` where expert not | "
1352
+ "`%s` rate bias judge-expert |" % (col, pl, pl, pl, pl),
1353
+ "|---|---|---|---|---|---|---|---|---|"]
1354
+ L += rows + [""]
1355
+ for row in o.get("group_compare", []):
1356
+ for j, e in row["by_judge"].items():
1357
+ if not e:
1358
+ continue
1359
+ what = "mean" if e.get("units", "score points") == "score points" else \
1360
+ "'%s' rate, in percentage points," % o.get("positive_label")
1361
+ L.append(("- **%s minus %s**, %s: judge " + what + " on all rows %s%s; on expert-labelled rows "
1362
+ "judge %s%s, expert %s%s; bias (judge-expert) differs by %s%s.")
1363
+ % (row["group"], row["reference"], j,
1364
+ sgn(e.get("judge_mean_all_rows_diff")), ci(e.get("judge_mean_all_rows_diff_ci"), signed=True),
1365
+ sgn(e.get("judge_mean_labelled_diff")), ci(e.get("judge_mean_labelled_diff_ci"), signed=True),
1366
+ sgn(e.get("expert_mean_diff")), ci(e.get("expert_mean_diff_ci"), signed=True),
1367
+ sgn(e.get("bias_diff")), ci(e.get("bias_diff_ci"), signed=True)))
1368
+ L.append("")
1369
+ return L
1370
+
1371
+
1372
+ def markdown(o, source):
1373
+ L = ["# Judge statistics", "",
1374
+ "Source: `%s` · %d judgements · %d items · judges: %s · up to %d trial(s) · "
1375
+ "expert-labelled items: %d · mode: %s · scale: %s"
1376
+ % (source, o["rows"], o["items"], ", ".join(o["judges"]), o["trials_max"],
1377
+ o["expert_items"], o["mode"], o["scale"]),
1378
+ "", "Intervals are 95%: Wilson for single rates, percentile bootstrap over items for kappa, "
1379
+ "means, differences, correlations and every pairwise rate. Every comparison joins on the "
1380
+ "item id." + (" Where a table does not say which run, agreement with the expert uses each "
1381
+ "judge's first trial per item." if o["trials_max"] > 1 else ""), ""]
1382
+ if o.get("trial_names"):
1383
+ L += ["Runs: " + "; ".join(run_name(o, t) for t in o["trials"]), ""]
1384
+ if o["flags"]:
1385
+ L += ["## Flags", ""] + ["- " + f for f in o["flags"]] + [""]
1386
+ if o.get("gates"):
1387
+ L += gate_section(o)
1388
+ if o.get("binary") and o["vs_expert"]:
1389
+ L += rare_section(o)
1390
+ if o["trials_max"] > 1 and any(s_.get("retest") for s_ in o["stability"].values()):
1391
+ L += retest_section(o)
1392
+ if "groups" in o:
1393
+ L += group_section(o, o["group_by"])
1394
+ L += ["## Pooled across every %s" % o["group_by"], ""]
1395
+
1396
+ L += ["### Label distribution (first trial)", "", "| Rater | n | Counts | Most common share | Mean |",
1397
+ "|---|---|---|---|---|"]
1398
+ for r, d in o["distribution"].items():
1399
+ counts = ", ".join("%s %d" % (k, v) for k, v in d["counts"].items())
1400
+ L.append("| %s | %d | %s | %s | %s |" % (r, d["n"], counts, pct(d["top_share"]),
1401
+ num2(d.get("mean_all_rows"))))
1402
+ L.append("")
1403
+
1404
+ if o["vs_expert"]:
1405
+ L += ["### Agreement with expert labels", ""]
1406
+ if o["scale"] == "ordinal":
1407
+ L += ["| Judge | Subset | n | Exact | Within 1 | Cohen's kappa (unweighted) | Quadratic kappa | "
1408
+ "Linear kappa | Judge mean | Expert mean | Bias judge-expert | Higher / lower |",
1409
+ "|---|---|---|---|---|---|---|---|---|---|---|---|"]
1410
+ else:
1411
+ L += ["| Judge | Subset | n | Accuracy | Chance | Cohen's kappa |", "|---|---|---|---|---|---|"]
1412
+ for j, res in o["vs_expert"].items():
1413
+ for sub in ("all", "stable"):
1414
+ r = res.get(sub, {})
1415
+ if not r.get("n") or (sub == "stable" and o["trials_max"] < 2):
1416
+ continue
1417
+ multi = o["trials_max"] > 1
1418
+ label = ("first trial only" if multi else "all labelled") if sub == "all" \
1419
+ else "stable across trials"
1420
+ if o["scale"] == "ordinal":
1421
+ L.append("| %s | %s | %d | %s | %s | %s%s | %s%s | %s%s | %s | %s | %s%s | %d / %d |"
1422
+ % (j, label, r["n"], frac(r["exact"], r["n"]), frac(r["within_one"], r["n"]),
1423
+ num2(r["kappa"]), ci(r["kappa_ci"]),
1424
+ num2(r["kappa_quadratic"]), ci(r["kappa_quadratic_ci"]),
1425
+ num2(r["kappa_linear"]), ci(r["kappa_linear_ci"]),
1426
+ num2(r["judge_mean"]), num2(r["expert_mean"]),
1427
+ sgn(r["bias"]), ci(r["bias_ci"], signed=True),
1428
+ r["judge_higher"], r["judge_lower"]))
1429
+ else:
1430
+ L.append("| %s | %s | %d | %s%s | %s | %s%s |"
1431
+ % (j, label, r["n"], pct(r["accuracy"]), ci(r["accuracy_ci"], True),
1432
+ pct(r["chance"]), num2(r["kappa"]), ci(r["kappa_ci"])))
1433
+ L.append("")
1434
+ for j, res in o["vs_expert"].items():
1435
+ r = res.get("all", {})
1436
+ if not r.get("n") or r.get("classes"):
1437
+ continue
1438
+ conf = ", ".join("judge %s / expert %s: %d" % tuple(k.split("|") + [v])
1439
+ for k, v in r["confusion"].items())
1440
+ L.append("- **%s** confusion: %s" % (j, conf))
1441
+ if "false_pass" in r:
1442
+ fp, ff = r["false_pass"], r["false_fail"]
1443
+ L.append(" - passes %d of %d items the expert failed (%s%s); fails %d of %d the expert "
1444
+ "passed (%s%s). Pass rate on these items: judge %s, expert %s."
1445
+ % (fp["k"], fp["n"], pct(fp["rate"]), ci(fp["ci"], True), ff["k"], ff["n"],
1446
+ pct(ff["rate"]), ci(ff["ci"], True), pct(r["pass_rate_judge"]),
1447
+ pct(r["pass_rate_expert"])))
1448
+ bl = r.get("bias_vs_length")
1449
+ if bl and bl["n"]:
1450
+ L.append(" - bias (judge-expert) vs length on %d labelled items: Pearson r %s%s, "
1451
+ "Spearman %s." % (bl["n"], num2(bl["pearson"]), ci(bl["pearson_ci"]),
1452
+ num2(bl["spearman"])))
1453
+ L.append("")
1454
+
1455
+ if o["between_judges"]:
1456
+ L += ["### Agreement between judges", "",
1457
+ "| Pair | Basis | n | Raw agreement | Chance | Cohen's kappa |", "|---|---|---|---|---|---|"]
1458
+ for row in o["between_judges"]:
1459
+ pair = "%s vs %s" % tuple(row["judges"])
1460
+ for sub in ("all", "stable"):
1461
+ r = row[sub]
1462
+ if not r["n"] or (sub == "stable" and o["trials_max"] < 2):
1463
+ continue
1464
+ L.append("| %s | %s | %d | %s | %s | %s%s |"
1465
+ % (pair, "first trial" if sub == "all" else "first trial, stable items",
1466
+ r["n"], pct(r["agreement"]), pct(r["chance"]), num2(r["kappa"]), ci(r["kappa_ci"])))
1467
+ m = row.get("matched_trials")
1468
+ if m:
1469
+ L.append("| %s | every trial, matched by trial | %d (%d items) | %d/%d (%s) | %s | %s%s |"
1470
+ % (pair, m["n"], m["items"], m["agreed"], m["n"], pct(m["agreement"]),
1471
+ pct(m["chance"]), num2(m["kappa"]), ci(m["kappa_ci"])))
1472
+ for t, pt in m["per_trial"].items():
1473
+ L.append("| %s | trial %s only | %d | %s | | %s |"
1474
+ % (pair, t, pt["n"], pct(pt["agreement"]), num2(pt["kappa"])))
1475
+ L.append("")
1476
+ for row in o["between_judges"]:
1477
+ se = row.get("shared_errors")
1478
+ if se:
1479
+ L.append("- %s and %s agree on %d expert-labelled items; on %d of them both are wrong%s."
1480
+ % (row["judges"][0], row["judges"][1], se["agreed_on_expert_items"],
1481
+ se["both_wrong"], (" (" + ", ".join(se["items"]) + ")") if se["items"] else ""))
1482
+ for row in o["between_judges"]:
1483
+ ad = row.get("accuracy_diff_vs_expert")
1484
+ if ad:
1485
+ L.append("- accuracy vs expert, %s minus %s on the same %d items: %s points%s "
1486
+ "(only %s right on %d, only %s right on %d)."
1487
+ % (row["judges"][0], row["judges"][1], ad["n"], "%+.1f" % (100 * ad["diff"]),
1488
+ ci([100 * x for x in ad["ci"]], points=True) if ad["ci"] else "",
1489
+ row["judges"][0], ad["only_a_right"], row["judges"][1], ad["only_b_right"]))
1490
+ if any(row.get("shared_errors") for row in o["between_judges"]):
1491
+ L.append("")
1492
+ for key in ("fleiss", "fleiss_all_ratings"):
1493
+ if key in o:
1494
+ f = o[key]
1495
+ L += ["**Fleiss' kappa** (%s), %d raters on %d items: %s%s"
1496
+ % (f["basis"], f["raters"], f["n"], num2(f["kappa"]), ci(f["kappa_ci"])), ""]
1497
+
1498
+ if o["trials_max"] > 1:
1499
+ L += ["### Test-retest stability", "",
1500
+ "| Judge | Items with repeats | Flipped | Flip rate | Flipped items |", "|---|---|---|---|---|"]
1501
+ for j, s in o["stability"].items():
1502
+ its = s["flipped_items"]
1503
+ shown = ", ".join(its[:20]) + (" …" if len(its) > 20 else "")
1504
+ L.append("| %s | %d | %d | %s%s | %s |" % (j, s["items_with_repeats"], s["flipped"],
1505
+ pct(s["flip_rate"]), ci(s["ci"], True), shown or "none"))
1506
+ L.append("")
1507
+
1508
+ pw = o.get("pairwise")
1509
+ if pw:
1510
+ L += ["### Position (pairwise)", "",
1511
+ "Order assignment per item: " + ", ".join("%s %d" % kv for kv in pw["order_by_item"].items())
1512
+ + ". Ties count as judgements no variant won.", "",
1513
+ "| Judge | Judgements with known order | First shown won | Second shown won | Ties |",
1514
+ "|---|---|---|---|---|"]
1515
+ for j, b in [("all judges", pw["all_judges"])] + list(pw["per_judge"].items()):
1516
+ L.append("| %s | %d | %s%s | %d | %d |"
1517
+ % (j, b["order_known"], frac(b["first_shown_won"], b["order_known"]),
1518
+ ci(b["ci_by_item"], True), b["second_shown_won"], b["ties"]))
1519
+ L.append("")
1520
+ for j, b in [("all judges", pw["all_judges"])] + list(pw["per_judge"].items()):
1521
+ for v, x in b.get("by_variant", {}).items():
1522
+ L.append("- **%s**, `%s`: shown first %d, won %s; shown second %d, won %s. Raw win rate "
1523
+ "%s%s; order-balanced %s%s (intervals bootstrapped over items)."
1524
+ % (j, v, x["shown_first"], frac(x["wins_when_first"], x["shown_first"]),
1525
+ x["shown_second"], frac(x["wins_when_second"], x["shown_second"]),
1526
+ pct(x["raw_win_rate"]), ci(x["raw_ci_by_item"], True),
1527
+ pct(x["order_balanced_win_rate"]), ci(x["order_balanced_ci_by_item"], True)))
1528
+ L.append("")
1529
+
1530
+ if "length" in o:
1531
+ L += ["### Length", ""]
1532
+ if o["mode"] == "pairwise":
1533
+ for j, e in o["length"].items():
1534
+ L.append("- **%s** chose the longer output in %d of %d decided pairs (%s%s)."
1535
+ % (j, e["chose_longer"], e["decided_with_lengths"], pct(e["rate"]), ci(e["ci"], True)))
1536
+ else:
1537
+ L += ["| Rater | n | Spearman (score or pass vs length) |", "|---|---|---|"]
1538
+ for r, e in o["length"].items():
1539
+ L.append("| %s | %d | %s%s |" % (r, e["n"], num2(e["spearman"]), ci(e.get("ci"))))
1540
+ within = [(r, e["within_expert_fails"]) for r, e in o["length"].items()
1541
+ if e.get("within_expert_fails", {}).get("n")]
1542
+ if within:
1543
+ L.append("")
1544
+ for r, w in within:
1545
+ L.append("- **%s**, items the expert failed only (n=%d): Spearman pass vs length %s."
1546
+ % (r, w["n"], num2(w["spearman"])))
1547
+ L.append("")
1548
+
1549
+ if o["warnings"]:
1550
+ L += ["## Input warnings", ""] + ["- " + w for w in o["warnings"][:30]] + [""]
1551
+ return "\n".join(L)
1552
+
1553
+
1554
+ # ---------- self-test ----------
1555
+
1556
+ def self_test():
1557
+ """Check the statistics against tables worked out by hand (see reference.md)."""
1558
+ ok = True
1559
+
1560
+ def check(name, got, want, tol=1e-9):
1561
+ nonlocal ok
1562
+ good = got is not None and abs(got - want) <= tol
1563
+ ok = ok and good
1564
+ sys.stdout.write("%s %s: got %s, want %s\n" % ("ok " if good else "FAIL", name, got, want))
1565
+
1566
+ # 10 items on a 1-3 scale: (1,1)x2 (2,2)x3 (3,3)x2 (1,2) (2,3) (3,1).
1567
+ t = [(1, 1)] * 2 + [(2, 2)] * 3 + [(3, 3)] * 2 + [(1, 2), (2, 3), (3, 1)]
1568
+ # quadratic: observed (1+1+4)/10 = 0.6; expected 1.2 from marginals .3/.4/.3 each; 1 - 0.6/1.2
1569
+ check("quadratic kappa", weighted_kappa(t, "quadratic"), 0.5)
1570
+ # linear: observed (1+1+2)/10 = 0.4; expected 0.84; 1 - 0.4/0.84 = 11/21
1571
+ check("linear kappa", weighted_kappa(t, "linear"), 11 / 21)
1572
+ # unweighted: po 0.7, pe 0.34, (0.7-0.34)/0.66 = 6/11
1573
+ check("unweighted kappa", cohen(t)[0], 6 / 11)
1574
+ check("mean bias", mean(a - b for a, b in t), 0.0)
1575
+ # reference.md worked example: 50 items, kappa 0.40
1576
+ t2 = [("yes", "yes")] * 20 + [("no", "no")] * 15 + [("yes", "no")] * 5 + [("no", "yes")] * 10
1577
+ check("cohen kappa (reference.md)", cohen(t2)[0], 0.4)
1578
+ # binary data: weighted and unweighted kappa coincide
1579
+ tb = [(1, 1)] * 20 + [(0, 0)] * 15 + [(1, 0)] * 5 + [(0, 1)] * 10
1580
+ check("quadratic = unweighted on binary", weighted_kappa(tb, "quadratic"), 0.4)
1581
+ # Fleiss on 3 items x 2 raters equals Scott's pi: labels a/a, a/b, b/b -> pbar 2/3, pe 0.5
1582
+ check("fleiss kappa", fleiss([["a", "a"], ["a", "b"], ["b", "b"]]), 1 / 3)
1583
+ check("pearson", pearson([(1, 2), (2, 4), (3, 6), (4, 8)]), 1.0)
1584
+ # rare class: 100 items, 5 expert positives; judge flags 4, 3 of them right
1585
+ t3 = [("v", "v")] * 3 + [("v", "o")] + [("o", "v")] * 2 + [("o", "o")] * 94
1586
+ cm = class_metrics(t3, "v", 0, random.Random(1))
1587
+ check("precision 3/4", cm["precision"]["rate"], 0.75)
1588
+ check("recall 3/5", cm["recall"]["rate"], 0.6)
1589
+ check("false-positive rate 1/95", cm["false_positive_rate"]["rate"], 1 / 95)
1590
+ check("majority baseline 95/100", cm["majority_baseline"], 0.95)
1591
+ # Wilson 3/5: centre (0.6 + 1.92/5) / 1.768 = 0.557, half 0.326 -> [0.231, 0.882]
1592
+ check("wilson 3/5 lower", wilson(3, 5)[0], 0.2307, 1e-3)
1593
+ g = parse_gate("recall>=90%")
1594
+ check("gate parses 90% as 0.9", g["value"], 0.9)
1595
+ check("gate fails when the whole interval is below", 1.0 if judge_gate(g, 0.6, [0.23, 0.88]) == "fails" else 0.0, 1.0)
1596
+ check("gate can't tell when the interval spans", 1.0 if judge_gate(g, 0.95, [0.8, 0.99]).startswith("can't") else 0.0, 1.0)
1597
+ return 0 if ok else 1
1598
+
1599
+
1600
+ def main():
1601
+ ap = argparse.ArgumentParser(description="Agreement, stability and bias statistics for LLM judges.")
1602
+ ap.add_argument("inputs", nargs="*", metavar="input",
1603
+ help="CSV or JSONL of judgements, or - for stdin; several files = one run each, "
1604
+ "joined on the item id")
1605
+ ap.add_argument("--gold", help="expert labels in their own file, joined on the item id")
1606
+ ap.add_argument("--judges", help="wide file: comma-separated columns, one per judge, each holding "
1607
+ "that judge's label for the row's item")
1608
+ ap.add_argument("--map", action="append", default=[], metavar="FROM=TO[,FROM=TO]",
1609
+ help="rename labels before anything is compared, e.g. true=violation,false=ok")
1610
+ ap.add_argument("--gate", action="append", default=[], metavar="SPEC",
1611
+ help="the user's own criterion, e.g. 'recall>=0.9', 'bias_diff<=1.0', "
1612
+ "'false_positives<=0' (only a criterion the user stated)")
1613
+ ap.add_argument("-o", "--output", help="write the report here instead of stdout")
1614
+ ap.add_argument("--json", action="store_true", help="emit JSON instead of markdown")
1615
+ ap.add_argument("--threshold", type=float,
1616
+ help="score at or above this counts as pass (only a threshold the user stated)")
1617
+ ap.add_argument("--positive", help="the class the decision acts on or counts (pass, violation, "
1618
+ "relevant); default: pass/yes/good when present, else the "
1619
+ "expert's minority class")
1620
+ ap.add_argument("--expert-judge", help="treat this judge id's rows as the expert labels")
1621
+ ap.add_argument("--group-by", help="stratify every statistic by this column (version, arm, judge, ...)")
1622
+ ap.add_argument("--col", action="append", default=[], metavar="NAME=COLUMN",
1623
+ help="map a column, e.g. --col score=grader_points (repeatable)")
1624
+ ap.add_argument("--boot", type=int, default=2000, help="bootstrap resamples (default 2000)")
1625
+ ap.add_argument("--seed", type=int, default=7, help="bootstrap seed (default 7)")
1626
+ ap.add_argument("--self-test", action="store_true", help="check the maths on hand-computed tables")
1627
+ a = ap.parse_args()
1628
+ if a.self_test:
1629
+ sys.exit(self_test())
1630
+ if not a.inputs:
1631
+ ap.error("an input file (or -) is required")
1632
+ if len(a.inputs) > 1 and "-" in a.inputs:
1633
+ fail("stdin (-) can only be the single input")
1634
+ overrides = {}
1635
+ for spec in a.col:
1636
+ name, sep, col = spec.partition("=")
1637
+ if not sep or name.strip().lower() not in ALIASES or not col.strip():
1638
+ fail("--col expects NAME=COLUMN with NAME one of: " + ", ".join(ALIASES))
1639
+ overrides[name.strip().lower()] = col.strip().lower()
1640
+ for spec in a.map:
1641
+ for part in spec.split(","):
1642
+ frm, sep, to = part.partition("=")
1643
+ if not sep or not frm.strip() or not to.strip():
1644
+ fail("--map expects FROM=TO[,FROM=TO], got " + spec)
1645
+ LABEL_MAP[frm.strip().lower()] = to.strip()
1646
+ gates = [parse_gate(g) for g in a.gate]
1647
+ wide = [c.strip().lower() for c in a.judges.split(",") if c.strip()] if a.judges else None
1648
+
1649
+ multi = len(a.inputs) > 1
1650
+ used = set() if multi else None
1651
+ recs, expert, cols, warnings, orders, names = [], {}, {}, [], {}, {}
1652
+ for idx, path in enumerate(a.inputs, 1):
1653
+ t = str(idx) if multi else None
1654
+ r, e, c, w, order = load(path, a.threshold, a.expert_judge, a.group_by, overrides,
1655
+ trial_override=t, wide=wide, used=used)
1656
+ recs += r
1657
+ warnings += w
1658
+ for k, v in c.items():
1659
+ cols.setdefault(k, v)
1660
+ for i, lab in e.items():
1661
+ if i in expert and expert[i] != lab:
1662
+ warnings.append("item %s: expert label %s in %s conflicts with %s; kept the first"
1663
+ % (i, lab, Path(path).name, expert[i]))
1664
+ else:
1665
+ expert.setdefault(i, lab)
1666
+ if multi:
1667
+ orders[t], names[t] = order, Path(path).name
1668
+ if multi:
1669
+ missing = set(overrides) - used
1670
+ if missing:
1671
+ fail("--col %s: no such column in any input" % ", ".join(sorted(missing)))
1672
+ ts = sorted(orders, key=int)
1673
+ base = orders[ts[0]]
1674
+ for t in ts[1:]:
1675
+ common = set(base) & set(orders[t])
1676
+ if [i for i in base if i in common] != [i for i in orders[t] if i in common]:
1677
+ warnings.append("%s lists the items in a different order from %s; every comparison "
1678
+ "here joins on the item id, so line order plays no part"
1679
+ % (names[t], names[ts[0]]))
1680
+ if set(base) != set(orders[t]):
1681
+ warnings.append("%s and %s cover different items (%d only in the first, %d only in the "
1682
+ "second); run-against-run figures use the items in both"
1683
+ % (names[ts[0]], names[t], len(set(base) - set(orders[t])),
1684
+ len(set(orders[t]) - set(base))))
1685
+ if a.gold:
1686
+ gold, w = load_gold(a.gold, a.threshold, overrides)
1687
+ warnings += w
1688
+ for i, lab in gold.items():
1689
+ if i in expert and expert[i] != lab:
1690
+ warnings.append("item %s: --gold says %s, the judgement file says %s; used --gold"
1691
+ % (i, lab, expert[i]))
1692
+ expert[i] = lab
1693
+ judged = {r["item"] for r in recs}
1694
+ if not set(gold) & judged:
1695
+ fail("--gold: none of its item ids match the judgement files (first gold ids: %s; first "
1696
+ "judged: %s)" % (", ".join(sorted(gold)[:3]), ", ".join(sorted(judged)[:3])))
1697
+ if set(gold) - judged:
1698
+ warnings.append("%d gold items were never judged" % len(set(gold) - judged))
1699
+ merge_case(recs, expert)
1700
+ labels_by_trial = defaultdict(set)
1701
+ for r in recs:
1702
+ labels_by_trial[r["trial"]].add(r["label"])
1703
+ ev = set(expert.values())
1704
+ label_flags = []
1705
+ for t, labs in labels_by_trial.items():
1706
+ if ev and not (labs & ev):
1707
+ label_flags.append("the judge labels%s (%s) share no value with the expert labels (%s); "
1708
+ "rename them with --map before reading any agreement figure"
1709
+ % (" in " + names[t] if t in names else "", ", ".join(sorted(labs)),
1710
+ ", ".join(sorted(ev))))
1711
+
1712
+ result = analyse(recs, expert, cols, warnings, a.boot, a.seed, a.positive)
1713
+ result["flags"] = label_flags + result["flags"]
1714
+ if names:
1715
+ result["trial_names"] = names
1716
+ if a.group_by:
1717
+ result["group_by"] = a.group_by
1718
+ result["groups"], result["group_compare"] = analyse_groups(
1719
+ recs, expert, cols, a.boot, a.seed, result["positive_label"] if result["binary"] else a.positive,
1720
+ result["scale"], result["binary"])
1721
+ if gates:
1722
+ result["gates"] = evaluate_gates(gates, result)
1723
+ src = "stdin" if a.inputs == ["-"] else ", ".join(Path(p).name for p in a.inputs)
1724
+ if a.gold:
1725
+ src += " + gold " + Path(a.gold).name
1726
+ text = json.dumps(result, indent=2) if a.json else markdown(result, src)
1727
+ if a.output:
1728
+ with open(a.output, "w", encoding="utf-8") as f:
1729
+ f.write(text + "\n")
1730
+ else:
1731
+ sys.stdout.write(text + "\n")
1732
+
1733
+
1734
+ if __name__ == "__main__":
1735
+ main()