How do our two LLM judges (claude-opus-4.8, gpt-5.5) and the deterministic checks agree when scoring the same answers? This notebook applies the standard method-comparison / inter-rater agreement toolkit, so the paper can claim the quality ranking is not a single-grader artifact.
Metrics (grounded in the inter-rater-reliability literature): - Cohen’s κ — chance-corrected agreement; quadratic-weighted (QWK) is the right variant for ordinal 1–5 scores (Cohen 1968). Bands: Landis & Koch (1977). - Pearson r / Spearman ρ / Kendall τ — linear / rank agreement. - ICC(2,1) — intraclass correlation, two-way random (Shrout & Fleiss 1979); ≈ QWK for two raters (Fleiss & Cohen 1973). - Exact / within-1 agreement — raw, interpretable.
Plots (how method/rater differences are conventionally shown): - Bland–Altman (a.k.a. Tukey mean-difference) — the method-comparison plot (Bland & Altman, Lancet 1986): mean of the pair vs their difference, with the bias line and 95 % limits of agreement. Reveals systematic bias and whether disagreement depends on the score level. - Scatter vs identity (y=x) + 5×5 confusion heatmap — where they land. - Score distributions — leniency / marginal bias. - Per-model ranking + deterministic-vs-judge + agreement by task class.
The deterministic checks use a different ruler (0–1 partial credit) than the judge (1–5 holistic), so expect a fixed offset: the Bland–Altman and the det-vs-judge cell quantify it. Same ordering, different altitude.
show code
# === Environment bootstrap (Colab / Kaggle / Binder / local) — run me first ===# If the committed data is not reachable (Colab/Kaggle opened just the .ipynb),# clone the repo so data/snapshots/*.csv resolve. No-op on Binder/local.# Kaggle: enable Settings -> Internet first so the clone can run.import sys, os, subprocessdef _repo_data_present(): here = os.getcwd()for _ inrange(4):if os.path.exists(os.path.join(here, "data/snapshots/results_snapshot.csv")):returnTrue here = os.path.dirname(here)returnFalseifnot _repo_data_present():ifnot os.path.isdir("apprenticeops"): subprocess.run(["git", "clone", "--depth", "1","https://github.com/dragoshont/apprenticeops.git"], check=True) os.chdir("apprenticeops") subprocess.run([sys.executable, "-m", "pip", "install", "-q","pandas", "numpy", "matplotlib", "scipy"], check=True)
show code
# --- ensure the analysis stack imports in WHATEVER kernel runs this ---# System Python has none of these; the repo .venv (kernel "apprenticeops (.venv)")# has them. If you're on the wrong kernel, this installs them into it so scipy &# friends still load. Best: pick the "apprenticeops (.venv)" kernel (top-right).import importlib, subprocess, sysfor _pkg in ("numpy", "pandas", "matplotlib", "scipy"):try: importlib.import_module(_pkg)exceptImportError:print(f"installing {_pkg} into {sys.executable} …") subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", _pkg])import os, jsonfrom pathlib import Pathimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom scipy import stats # <- the import you asked to make sure loads# --- repo root (so .tmp/ and data/ paths resolve from anywhere) ---ROOT = Path.cwd()whilenot (ROOT /"data"/"scenarios.json").exists() and ROOT != ROOT.parent: ROOT = ROOT.parentos.chdir(ROOT)# --- which pass? "var" = R=5 variance (five samples/scenario; the DEFAULT now# the variance judges are done) ; "det" = temp-0 single sample. Flip back to# "det" for the single-sample view. ---PASS ="var"PATHS = {"det": dict(claude=".tmp/judge/judged.det.jsonl", gpt=".tmp/judge/judged.det.gpt55.jsonl", results=".tmp/judge/results.det.jsonl"),"var": dict(claude=".tmp/judge/judged.var.claude.jsonl", gpt=".tmp/judge/judged.var.gpt55.jsonl", results=".tmp/judge/results.var.jsonl"),}[PASS]SCEN ="data/scenarios.json"plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.3})print("python :", sys.executable)print("scipy :", stats.__name__, "loaded | numpy", np.__version__, "| pandas", pd.__version__)print("root :", ROOT, "| pass:", PASS)
The chance-corrected, rank, and raw numbers in one place. QWK is the headline for ordinal scores; ICC(2,1) is the continuous analogue.
show code
def confusion(a, b, cats): idx = {c: i for i, c inenumerate(cats)} O = np.zeros((len(cats), len(cats)))for x, y inzip(a, b): O[idx[x], idx[y]] +=1return Odef cohen_kappa(a, b, weights=None): cats =sorted(set(a) |set(b)) k =len(cats) O = confusion(a, b, cats) n = O.sum() row, col = O.sum(1), O.sum(0) E = np.outer(row, col) / nif weights =="quadratic": W = np.array([[(i - j) **2for j inrange(k)] for i inrange(k)], float) / (k -1) **2elif weights =="linear": W = np.array([[abs(i - j) for j inrange(k)] for i inrange(k)], float) / (k -1)else: W =1- np.eye(k)return1- (W * O).sum() / (W * E).sum()def icc_2_1(a, b):# two-way random, single-measure ICC(2,1) (Shrout & Fleiss 1979) Y = np.column_stack([a, b]).astype(float) n, k = Y.shape gm = Y.mean() MSR = k * ((Y.mean(1) - gm) **2).sum() / (n -1) # between subjects MSC = n * ((Y.mean(0) - gm) **2).sum() / (k -1) # between raters MSE = ((Y - Y.mean(1, keepdims=True) - Y.mean(0, keepdims=True) + gm) **2).sum() / ((n -1) * (k -1))return (MSR - MSE) / (MSR + (k -1) * MSE + k * (MSC - MSE) / n)def landis_koch(kp):for lo, name in [(.81, "almost perfect"), (.61, "substantial"), (.41, "moderate"), (.21, "fair"), (0, "slight")]:if kp >= lo:return namereturn"poor"a, b = df.claude.values, df.gpt.valuesrows = [ ("Cohen's κ (unweighted)", cohen_kappa(a, b)), ("Cohen's κ (linear)", cohen_kappa(a, b, "linear")), ("Cohen's κ (quadratic, QWK)", cohen_kappa(a, b, "quadratic")), ("ICC(2,1)", icc_2_1(a, b)), ("Pearson r", stats.pearsonr(a, b)[0]), ("Spearman ρ", stats.spearmanr(a, b)[0]), ("Kendall τ", stats.kendalltau(a, b)[0]), ("exact agreement", (a == b).mean()), ("within-1 agreement", (np.abs(a - b) <=1).mean()), ("mean |Claude − GPT|", np.abs(a - b).mean()),]summary = pd.DataFrame(rows, columns=["metric", "value"]).set_index("metric")summary["band"] = [landis_koch(v) if"κ"in m else""for m, v in rows]print(f"Claude mean={a.mean():.2f} GPT-5.5 mean={b.mean():.2f} (n={len(a)} answers)")summary.round(3)
Claude mean=2.21 GPT-5.5 mean=2.23 (n=8909 answers)
value
band
metric
Cohen's κ (unweighted)
0.694
substantial
Cohen's κ (linear)
0.813
almost perfect
Cohen's κ (quadratic, QWK)
0.906
almost perfect
ICC(2,1)
0.906
Pearson r
0.909
Spearman ρ
0.902
Kendall τ
0.855
exact agreement
0.773
within-1 agreement
0.998
mean |Claude − GPT|
0.229
2. Bland–Altman (method-comparison) plot
The canonical way to show agreement between two raters/methods (Bland & Altman 1986). x = the average of the two scores, y = their difference. The solid line is the bias (mean difference); dashed lines are the 95 % limits of agreement (bias ± 1.96·SD). A flat cloud centred on 0 = unbiased agreement; a slope = the disagreement depends on the score level. Points are jittered (scores are discrete).
show code
rng = np.random.default_rng(0)mean_ab = (df.claude + df.gpt) /2diff = (df.claude - df.gpt).astype(float)bias = diff.mean()sd = diff.std(ddof=1)lo, hi = bias -1.96* sd, bias +1.96* sdjx = mean_ab + rng.uniform(-0.12, 0.12, len(df))jy = diff + rng.uniform(-0.12, 0.12, len(df))fig, ax = plt.subplots(figsize=(8, 5))ax.scatter(jx, jy, s=14, alpha=0.35, edgecolor="none")for y, t, c in [(bias, f"bias {bias:+.2f}", "C3"), (hi, f"+1.96 SD {hi:+.2f}", "C0"), (lo, f"-1.96 SD {lo:+.2f}", "C0")]: ax.axhline(y, color=c, ls="--"if c =="C0"else"-", lw=1.4) ax.text(ax.get_xlim()[1], y, " "+ t, va="center", color=c, fontsize=9)ax.axhline(0, color="k", lw=0.6, alpha=0.4)ax.set_xlabel("mean of the two judges' scores")ax.set_ylabel("Claude − GPT-5.5")ax.set_title(f"Bland–Altman: Claude vs GPT-5.5 (bias {bias:+.2f}, "f"95% LoA [{lo:+.2f}, {hi:+.2f}])")plt.tight_layout(); plt.show()
3. Scatter vs the identity line + confusion matrix
Left: each answer’s two scores, jittered, against y = x (perfect agreement). Right: the 5×5 confusion of the discrete scores — the diagonal is exact agreement, off-diagonal mass shows how they disagree.
If one judge systematically scores higher, the histograms shift. Near-identical marginal distributions mean no leniency bias — a precondition for a fair κ.
5. Per-model ranking — do the evaluators agree on which model is better?
The decision a practitioner cares about is the ranking. Each evaluator’s per-model mean as % of frontier (judge ÷ 5; det ×100). If the lines track, the ranking is robust to the choice of evaluator.
show code
pm = (df.groupby("model") .agg(claude=("claude", "mean"), gpt=("gpt", "mean"), det=("det", "mean"), bracket=("bracket", "first")) .assign(claude=lambda d: d.claude /5*100, gpt=lambda d: d.gpt /5*100, det=lambda d: d.det *100) .sort_values("claude", ascending=True))fig, ax = plt.subplots(figsize=(9, 8))y = np.arange(len(pm))ax.plot(pm.claude, y, "o-", label="Claude %frontier", color="C0")ax.plot(pm.gpt, y, "s-", label="GPT-5.5 %frontier", color="C1")ax.plot(pm.det, y, "^--", label="deterministic %", color="C2", alpha=0.8)ax.set_yticks(y, [f"{m} [{b}]"for m, b inzip(pm.index, pm.bracket)], fontsize=8)ax.set(xlabel="score (%)", title="Per-model evaluation by all three signals")ax.legend(); plt.tight_layout(); plt.show()pm.round(1)
claude
gpt
det
bracket
model
smollm:360m
21.1
22.3
48.5
0-1B
smollm2:135m-instruct-q8_0
22.3
23.4
45.5
0-1B
smollm2:135m
22.6
23.6
50.0
0-1B
deepseek-r1:1.5b
24.0
26.3
44.8
1-2B
smollm2:360m-instruct-q8_0
24.8
25.9
53.9
0-1B
...
...
...
...
...
qwen2.5:7b
66.5
66.3
88.4
4-5GB
qwen3:4b-instruct-2507-q4_K_M
70.5
66.7
91.3
3-4B
qwen3:4b-q8_0
72.0
69.9
87.5
3-4B
qwen3:4b-instruct-2507-q8_0
72.4
70.1
94.0
4-5GB
hf.co/unsloth/Qwen3-4B-GGUF:Q4_K_M
73.3
69.5
86.8
3-4B
94 rows × 4 columns
6. Deterministic vs LLM-judge — the two rulers
The det checks (partial-credit pattern matching) and the judge (holistic 1–5) score on different scales. This shows the systematic offset and whether the relationship is a clean line (det predicts judge up to a constant) or noisy.
VAR pass (this view): R=5 — five samples/scenario, all 94 functional models = 8,909 jointly-judged answers (consolidated, read from the committed data/site/judge_pairs.csv). Flip PASS = "det" for the temperature-0 single-sample view (475 answers). Fleiss’ κ becomes available when a 3rd judge is added.
References: Bland & Altman, Lancet 1986; Cohen, Psych. Bull. 1968 (weighted κ); Landis & Koch, Biometrics 1977 (bands); Shrout & Fleiss, Psych. Bull. 1979 (ICC); see also judge_agreement.py (the CLI version).