207 lines
7.2 KiB
Python
207 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
||
"""Inter-rater reliability for the IETF landscape survey.
|
||
|
||
Reads the two re-rating result files written by rerate-intercoder.py
|
||
(data/rerate/sonnet.jsonl, data/rerate/haiku.jsonl) plus the existing
|
||
production labels (ratings table), and reports:
|
||
|
||
- Cohen's kappa on PRIMARY CATEGORY (nominal) for each rater pair
|
||
- Quadratic-weighted kappa on each ordinal dimension (novelty, maturity,
|
||
overlap, momentum, relevance)
|
||
- Raw agreement %, and the category confusion (most-confused pairs)
|
||
|
||
Pairs compared: sonnet-rerate vs haiku-rerate (the controlled inter-coder run),
|
||
and each rerate vs the existing production labels (drift check).
|
||
|
||
Pure stdlib + numpy. No API calls. Read-only on the DB.
|
||
Writes data/reports/survey-kappa.md.
|
||
|
||
Usage: PYTHONPATH=src python3 scripts/survey-kappa.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
from ietf_analyzer.config import Config
|
||
from ietf_analyzer.db import Database
|
||
|
||
RERATE_DIR = Path("data/rerate")
|
||
OUT = Path("data/reports/survey-kappa.md")
|
||
DIMS = ["novelty", "maturity", "overlap", "momentum", "relevance"]
|
||
DIM_KEYS = {"novelty": ("n", "novelty"), "maturity": ("m", "maturity"),
|
||
"overlap": ("o", "overlap"), "momentum": ("mo", "momentum"),
|
||
"relevance": ("r", "relevance")}
|
||
|
||
|
||
def _strip_fence(text: str) -> str:
|
||
t = text.strip()
|
||
if t.startswith("```"):
|
||
t = t.split("\n", 1)[1] if "\n" in t else t[3:]
|
||
if t.rstrip().endswith("```"):
|
||
t = t.rstrip()[:-3]
|
||
return t.strip()
|
||
|
||
|
||
def _clamp(v):
|
||
try:
|
||
v = int(round(float(v)))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return min(5, max(1, v))
|
||
|
||
|
||
def parse_rerate(alias: str) -> dict[str, dict]:
|
||
"""draft_name -> {primary_cat, dims...} from a rerate jsonl."""
|
||
out = {}
|
||
p = RERATE_DIR / f"{alias}.jsonl"
|
||
if not p.exists():
|
||
return out
|
||
for line in p.read_text().splitlines():
|
||
if not line.strip():
|
||
continue
|
||
rec = json.loads(line)
|
||
if "raw" not in rec:
|
||
continue
|
||
try:
|
||
data = json.loads(_strip_fence(rec["raw"]))
|
||
except json.JSONDecodeError:
|
||
continue
|
||
cats = data.get("c", data.get("categories", []))
|
||
primary = cats[0] if isinstance(cats, list) and cats else None
|
||
entry = {"primary": primary}
|
||
for dim, (k1, k2) in DIM_KEYS.items():
|
||
entry[dim] = _clamp(data.get(k1, data.get(k2)))
|
||
out[rec["draft_name"]] = entry
|
||
return out
|
||
|
||
|
||
def load_prod(db: Database) -> dict[str, dict]:
|
||
out = {}
|
||
rows = db.conn.execute(
|
||
"""SELECT r.draft_name, r.categories, r.novelty, r.maturity,
|
||
r.overlap, r.momentum, r.relevance
|
||
FROM drafts d JOIN ratings r ON d.name = r.draft_name
|
||
WHERE d.source='ietf' AND (r.false_positive=0 OR r.false_positive IS NULL)"""
|
||
).fetchall()
|
||
for name, cats_json, *dimvals in rows:
|
||
try:
|
||
cats = json.loads(cats_json) if cats_json else []
|
||
except json.JSONDecodeError:
|
||
cats = []
|
||
e = {"primary": cats[0] if cats else None}
|
||
for dim, v in zip(DIMS, dimvals):
|
||
e[dim] = _clamp(v)
|
||
out[name] = e
|
||
return out
|
||
|
||
|
||
def cohen_kappa(a: list, b: list) -> tuple[float, float, int]:
|
||
"""Nominal Cohen's kappa. Returns (kappa, raw_agreement, n)."""
|
||
labels = sorted(set(a) | set(b))
|
||
idx = {l: i for i, l in enumerate(labels)}
|
||
k = len(labels)
|
||
m = np.zeros((k, k))
|
||
for x, y in zip(a, b):
|
||
m[idx[x], idx[y]] += 1
|
||
n = m.sum()
|
||
po = np.trace(m) / n
|
||
pe = (m.sum(0) @ m.sum(1)) / (n * n)
|
||
kappa = (po - pe) / (1 - pe) if (1 - pe) else 1.0
|
||
return kappa, po, int(n)
|
||
|
||
|
||
def weighted_kappa(a: list, b: list, k: int = 5) -> tuple[float, float, int]:
|
||
"""Quadratic-weighted kappa for ordinal 1..k ratings."""
|
||
pairs = [(x, y) for x, y in zip(a, b) if x is not None and y is not None]
|
||
if not pairs:
|
||
return float("nan"), float("nan"), 0
|
||
a2, b2 = zip(*pairs)
|
||
o = np.zeros((k, k))
|
||
for x, y in pairs:
|
||
o[x - 1, y - 1] += 1
|
||
n = o.sum()
|
||
w = np.zeros((k, k))
|
||
for i in range(k):
|
||
for j in range(k):
|
||
w[i, j] = (i - j) ** 2 / (k - 1) ** 2
|
||
ha = np.array([a2.count(v) for v in range(1, k + 1)], float)
|
||
hb = np.array([b2.count(v) for v in range(1, k + 1)], float)
|
||
e = np.outer(ha, hb) / n
|
||
num = (w * o).sum()
|
||
den = (w * e).sum()
|
||
kappa = 1 - num / den if den else 1.0
|
||
raw_agree = np.trace(o) / n
|
||
return kappa, raw_agree, int(n)
|
||
|
||
|
||
def interpret(k: float) -> str:
|
||
if k != k:
|
||
return "n/a"
|
||
if k < 0: return "worse than chance"
|
||
if k < 0.20: return "slight"
|
||
if k < 0.40: return "fair"
|
||
if k < 0.60: return "moderate"
|
||
if k < 0.80: return "substantial"
|
||
return "almost perfect"
|
||
|
||
|
||
def compare(name: str, A: dict, B: dict, lines: list):
|
||
shared = sorted(set(A) & set(B))
|
||
lines.append(f"\n## {name} (n shared = {len(shared)})\n")
|
||
# primary category
|
||
pa = [A[d]["primary"] for d in shared if A[d]["primary"] and B[d]["primary"]]
|
||
pb = [B[d]["primary"] for d in shared if A[d]["primary"] and B[d]["primary"]]
|
||
kappa, po, n = cohen_kappa(pa, pb)
|
||
lines.append(f"**Primary category** (Cohen's κ): κ = {kappa:.3f} ({interpret(kappa)}), "
|
||
f"raw agreement {po:.1%}, n = {n}\n")
|
||
# confusion: top disagreements
|
||
dis = Counter()
|
||
for d in shared:
|
||
x, y = A[d]["primary"], B[d]["primary"]
|
||
if x and y and x != y:
|
||
dis[tuple(sorted((x, y)))] += 1
|
||
if dis:
|
||
lines.append("\nMost-confused category pairs:\n")
|
||
lines.append("| A | B | count |\n|---|---|------:|\n")
|
||
for (x, y), c in dis.most_common(8):
|
||
lines.append(f"| {x} | {y} | {c} |\n")
|
||
# ordinal dims
|
||
lines.append("\n**Ordinal dimensions** (quadratic-weighted κ):\n\n")
|
||
lines.append("| dimension | κ_w | raw agree | n |\n|---|---:|---:|---:|\n")
|
||
for dim in DIMS:
|
||
a = [A[d][dim] for d in shared]
|
||
b = [B[d][dim] for d in shared]
|
||
kw, ra, n = weighted_kappa(a, b)
|
||
lines.append(f"| {dim} | {kw:.3f} ({interpret(kw)}) | {ra:.1%} | {n} |\n")
|
||
|
||
|
||
def main():
|
||
db = Database(Config.load())
|
||
sonnet = parse_rerate("sonnet")
|
||
haiku = parse_rerate("haiku")
|
||
prod = load_prod(db)
|
||
print(f"parsed: sonnet={len(sonnet)} haiku={len(haiku)} prod={len(prod)}")
|
||
if not sonnet or not haiku:
|
||
print("rerate files incomplete — run rerate-intercoder.py --collect first")
|
||
return
|
||
|
||
lines = ["# Inter-rater reliability — IETF landscape survey\n",
|
||
f"\nCorpus: clean IETF (n≈524). Sonnet={len(sonnet)}, Haiku={len(haiku)}, prod labels={len(prod)}.\n",
|
||
"\nκ interpretation (Landis & Koch): <0.2 slight, 0.2–0.4 fair, "
|
||
"0.4–0.6 moderate, 0.6–0.8 substantial, >0.8 almost perfect.\n"]
|
||
compare("Sonnet (re-rate) vs Haiku (re-rate) — controlled inter-coder", sonnet, haiku, lines)
|
||
compare("Sonnet (re-rate) vs Production labels — drift/stability", sonnet, prod, lines)
|
||
compare("Haiku (re-rate) vs Production labels", haiku, prod, lines)
|
||
|
||
OUT.write_text("".join(lines))
|
||
print(f"wrote {OUT}")
|
||
print("".join(lines))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|