promptstat / eval /kappa.py
xxixx1028's picture
Deploy PromptStat — UI shell + MiniCPM4.1-8B + 4-LoRA hybrid (Modal)
dc9f530 verified
Raw
History Blame Contribute Delete
18.3 kB
"""Phase-2 measurement harness: base MiniCPM4.1-8B + bge-small embedder + rules vs Kim-verified
ground truth. Computes per-axis Cohen's kappa, per-category breakdowns (sparse → N/A), confusion
matrices, an OOD report, and embedder boundary-recall vs hand-identified Focus candidates.
Pure metrics (cohen_kappa, confusion) are dependency-free and unit-tested in tests/test_kappa.py.
8B calls go through a disk cache (eval/_cache/preds_8b.jsonl) keyed by sha1(prompt) + a thread pool,
so runs are resumable and Phase 3 (Critical) reuses every cached response.
Credentials (shared OpenBMB hackathon key) are read from the environment, never hardcoded:
OPENBMB_BASE_URL, OPENBMB_TOKEN
Run: python -m eval.kappa # 4 Phase-2 axes (Focus, Technique, Interaction, Input Quality)
python -m eval.kappa --limit 5 # quick probe on 5 convs (latency/parse check before full run)
"""
from __future__ import annotations
import concurrent.futures as cf
import hashlib
import json
import os
import sys
import threading
import numpy as np
from prompt_card.scoring import observable_axes as OA
from prompt_card.scoring.embedding import FastEmbedder
HERE = os.path.dirname(__file__)
ROOT = os.path.dirname(HERE)
VP = os.path.join(ROOT, "prompt_card", "training", "validation_pool")
CACHE = os.path.join(HERE, "_cache", "preds_8b.jsonl")
TECH = ["zero_shot_role", "few_shot", "thought_generation", "decomposition", "self_criticism", "flipped"]
IQ = ["goal_stated", "specific_specification"]
CE = ["skepticism", "rebuttal", "source_request", "independent_verification", "re_questioning"]
# ---------------- pure metrics (no deps) ----------------
def confusion(y_true, y_pred, labels):
idx = {l: i for i, l in enumerate(labels)}
m = [[0] * len(labels) for _ in labels]
for t, p in zip(y_true, y_pred):
m[idx[t]][idx[p]] += 1
return m
def cohen_kappa(y_true, y_pred):
"""Cohen's kappa. Returns None when undefined (empty, or both raters use one identical label
→ chance agreement is 1, kappa degenerate). Caller reports those as N/A."""
n = len(y_true)
if n == 0:
return None
labels = sorted(set(y_true) | set(y_pred))
if len(labels) == 1:
return None # degenerate: everything one class (e.g. an all-negative sparse category)
m = confusion(y_true, y_pred, labels)
po = sum(m[i][i] for i in range(len(labels))) / n
rows = [sum(r) for r in m]
cols = [sum(m[i][j] for i in range(len(labels))) for j in range(len(labels))]
pe = sum((rows[i] / n) * (cols[i] / n) for i in range(len(labels)))
if pe >= 1.0:
return None
return (po - pe) / (1 - pe)
def binary_counts(y_true, y_pred):
tp = sum(1 for t, p in zip(y_true, y_pred) if t and p)
fp = sum(1 for t, p in zip(y_true, y_pred) if not t and p)
fn = sum(1 for t, p in zip(y_true, y_pred) if t and not p)
tn = sum(1 for t, p in zip(y_true, y_pred) if not t and not p)
return dict(tp=tp, fp=fp, fn=fn, tn=tn)
def prf(c):
tp, fp, fn = c["tp"], c["fp"], c["fn"]
p = tp / (tp + fp) if tp + fp else None
r = tp / (tp + fn) if tp + fn else None
f1 = (2 * p * r / (p + r)) if (p and r) else None
return p, r, f1
# ---------------- cached concurrent 8B ----------------
class CachedClient:
"""Wraps any `generate(prompt)->str` client with a sha1-keyed disk cache + thread pool."""
def __init__(self, base, workers=8):
self.base = base
self.workers = workers
self._lock = threading.Lock()
self.cache = {}
if os.path.exists(CACHE):
for line in open(CACHE):
try:
d = json.loads(line)
self.cache[d["k"]] = d["v"]
except Exception:
pass
self.hits = 0
self.misses = 0
@staticmethod
def _key(prompt):
return hashlib.sha1(prompt.encode("utf-8")).hexdigest()
def _persist(self, k, v):
with self._lock:
with open(CACHE, "a") as f:
f.write(json.dumps({"k": k, "v": v}, ensure_ascii=False) + "\n")
def run_all(self, prompts):
"""Return {prompt: response} for a list of prompts, using cache + concurrency."""
uniq = list(dict.fromkeys(prompts))
todo = [p for p in uniq if self._key(p) not in self.cache]
self.hits += len(uniq) - len(todo)
def work(p):
v = self.base.generate(p)
k = self._key(p)
self.cache[k] = v
self._persist(k, v)
return p
if todo:
with cf.ThreadPoolExecutor(max_workers=self.workers) as ex:
for i, _ in enumerate(ex.map(work, todo), 1):
self.misses += 1
if i % 50 == 0:
print(f" ... {i}/{len(todo)} new 8B calls", flush=True)
return {p: self.cache[self._key(p)] for p in uniq}
# ---------------- data loading ----------------
def load_convs():
convs = {}
for fn in ("pool.jsonl", "supplement.jsonl"):
for line in open(os.path.join(VP, fn)):
c = json.loads(line)
convs[c["id"]] = c
return convs
def user_turns(conv):
return [t["text"] for t in conv["turns"] if t["role"] == "user"]
def load_gt():
return [json.loads(l) for l in open(os.path.join(VP, "ground_truth.jsonl"))]
# ---------------- prediction assembly ----------------
def build_prompts(gt, convs, embedder, focus_tmax=0.70, builders=OA,
axes=("technique", "input_quality", "interaction", "focus")):
"""Return (prompts, plan, focus_geom). `builders` supplies build_*_prompt (swap for prompt versions);
`axes` selects which axes to assemble (lets the cascade re-run only changed axes)."""
prompts, plan = [], []
focus_geom = {} # conv_id -> list of (i, cosine) for every adjacent user-turn boundary
for r in gt:
cid = r["id"]
ut = user_turns(convs[cid])
if "technique" in axes:
for row in r["technique"]:
i = int(row["turn"][1:]) - 1
p = builders.build_technique_prompt(ut[i])
prompts.append(p); plan.append(("technique", cid, row["turn"], p))
if "input_quality" in axes:
for row in r["input_quality"]:
i = int(row["turn"][1:]) - 1
p = builders.build_input_quality_prompt(ut[i])
prompts.append(p); plan.append(("input_quality", cid, row["turn"], p))
if "interaction" in axes:
for row in r["interaction"]:
i = int(row["turn"][1:]) - 1
p = builders.build_interaction_prompt(ut[i - 1], ut[i])
prompts.append(p); plan.append(("interaction", cid, row["turn"], p))
# focus: embedder geometry over ALL adjacent boundaries; 8B only on cos<focus_tmax
if "focus" in axes and len(ut) >= 2:
vecs = embedder.embed(ut)
geom = []
for i in range(len(ut) - 1):
cos = float(np.dot(vecs[i], vecs[i + 1]))
geom.append((i, cos))
if cos < focus_tmax:
p = builders.build_focus_boundary_prompt(ut[i], ut[i + 1])
prompts.append(p); plan.append(("focus", cid, f"U{i+1}→U{i+2}", p))
focus_geom[cid] = geom
return prompts, plan, focus_geom
# ---------------- axis scoring ----------------
def score_binary_axis(gt, responses, plan_index, axis, fields):
"""For a per-turn multi-binary axis (technique/input_quality): return per-field (yt,yp) + parse fails."""
per = {f: ([], []) for f in fields}
parse_fail = 0
gtmap = {r["id"]: r for r in gt}
key = "types" if axis in ("technique", "critical") else "features"
for (ax, cid, turn, prompt) in plan_index[axis]:
row = next(rr for rr in gtmap[cid][axis] if rr["turn"] == turn)
pred = OA.parse(responses[prompt], fields)
if pred is None:
parse_fail += 1
pred = {f: False for f in fields}
for f in fields:
per[f][0].append(int(f in row[key]))
per[f][1].append(int(bool(pred.get(f))))
return per, parse_fail
def score_interaction(gt, responses, plan_index):
yt, yp = [], []
parse_fail = 0
gtmap = {r["id"]: r for r in gt}
for (ax, cid, turn, prompt) in plan_index["interaction"]:
row = next(rr for rr in gtmap[cid]["interaction"] if rr["turn"] == turn)
pred = OA.parse(responses[prompt], ("refinement_attempt",))
if pred is None:
parse_fail += 1
pred = {}
yt.append(int(bool(row["refinement"])))
yp.append(int(bool(pred.get("refinement_attempt"))))
return yt, yp, parse_fail
def score_focus(gt, responses, plan_index, focus_geom, threshold):
"""Binary shift vs not over EVERY adjacent boundary; embedder gates 8B at `threshold`.
Also returns embedder recall on GT topic_shift boundaries and a 3-class tally on co-identified."""
rel_pred = {(cid, turn): OA.parse(responses[p], ("relation",)) for (ax, cid, turn, p) in plan_index["focus"]}
gtmap = {r["id"]: r for r in gt}
yt, yp = [], []
gt_shift_total = recall_hit = 0
cand_total = 0
three = {} # (gt_rel, pred_rel) -> count on co-identified candidates
for r in gt:
cid = r["id"]
gt_rel = {f"U{c['a'][1:]}→U{c['b'][1:]}" if False else f"{c['a']}{c['b']}": c["relation"] for c in r["focus"]}
for (i, cos) in focus_geom.get(cid, []):
bkey = f"U{i+1}→U{i+2}"
gtr = gt_rel.get(bkey)
is_gt_shift = (gtr == "topic_shift")
yt.append(int(is_gt_shift))
is_cand = cos < threshold
cand_total += int(is_cand)
pred_shift = 0
if is_cand:
pr = rel_pred.get((cid, bkey)) or {}
prel = pr.get("relation")
pred_shift = int(prel == "topic_shift")
if gtr is not None: # co-identified: both GT and embedder flagged this boundary
three[(gtr, prel)] = three.get((gtr, prel), 0) + 1
yp.append(pred_shift)
if is_gt_shift:
gt_shift_total += 1
recall_hit += int(is_cand)
recall = recall_hit / gt_shift_total if gt_shift_total else None
return yt, yp, dict(recall=recall, gt_shift=gt_shift_total, cand_total=cand_total, three=three)
# ---------------- OOD ----------------
def ood_report(gt, convs, embedder):
ids = [r["id"] for r in gt]
centroids = []
for cid in ids:
ut = user_turns(convs[cid])
v = embedder.embed(ut)
c = v.mean(axis=0)
centroids.append(c / (np.linalg.norm(c) + 1e-9))
M = np.array(centroids)
glob = M.mean(axis=0); glob /= np.linalg.norm(glob) + 1e-9
sims = M @ glob
mu, sd = float(sims.mean()), float(sims.std())
flagged = [(ids[i], float(sims[i])) for i in range(len(ids)) if sims[i] < mu - 2 * sd]
return dict(mu=mu, sd=sd, flagged=sorted(flagged, key=lambda x: x[1]))
# ---------------- main ----------------
def _fmt_k(k):
return "N/A" if k is None else f"{k:+.3f}"
def main(limit=None, workers=8):
base_url = os.environ.get("OPENBMB_BASE_URL")
token = os.environ.get("OPENBMB_TOKEN")
if not base_url or not token:
print("ERROR: set OPENBMB_BASE_URL and OPENBMB_TOKEN in the environment (shared hackathon key).",
file=sys.stderr)
sys.exit(2)
from prompt_card.llm.minicpm import MiniCPMClient
base = MiniCPMClient(base_url, token)
gt = load_gt()
if limit:
gt = gt[:limit]
convs = load_convs()
embedder = FastEmbedder()
print(f"[measure] {len(gt)} convs; embedding + assembling prompts ...", flush=True)
prompts, plan, focus_geom = build_prompts(gt, convs, embedder)
plan_index = {}
for item in plan:
plan_index.setdefault(item[0], []).append(item)
print(f"[measure] {len(prompts)} prompts "
f"(tech {len(plan_index.get('technique',[]))}, iq {len(plan_index.get('input_quality',[]))}, "
f"inter {len(plan_index.get('interaction',[]))}, focus {len(plan_index.get('focus',[]))})", flush=True)
client = CachedClient(base, workers=workers)
responses = client.run_all(prompts)
print(f"[measure] 8B done (cache hits {client.hits}, new {client.misses})", flush=True)
# axis scoring
tech_per, tech_fail = score_binary_axis(gt, responses, plan_index, "technique", TECH)
iq_per, iq_fail = score_binary_axis(gt, responses, plan_index, "input_quality", IQ)
inter_yt, inter_yp, inter_fail = score_interaction(gt, responses, plan_index)
# focus threshold sweep
sweep = {}
for T in [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]:
yt, yp, info = score_focus(gt, responses, plan_index, focus_geom, T)
sweep[T] = (cohen_kappa(yt, yp), info["recall"], info["cand_total"], yt, yp, info)
# pick T maximizing kappa (tie-break higher recall)
bestT = max(sweep, key=lambda T: (sweep[T][0] if sweep[T][0] is not None else -9, sweep[T][1] or 0))
fk, frec, fcand, fyt, fyp, finfo = sweep[bestT]
write_results(gt, tech_per, tech_fail, iq_per, iq_fail, inter_yt, inter_yp, inter_fail,
sweep, bestT, ood_report(gt, convs, embedder))
print("[measure] wrote eval/measurement_results.md", flush=True)
def write_results(gt, tech_per, tech_fail, iq_per, iq_fail, inter_yt, inter_yp, inter_fail,
sweep, bestT, ood):
L = ["# Phase-2 measurement — base MiniCPM4.1-8B + bge-small + rules vs ground truth", ""]
L.append(f"Validation set: {len(gt)} conversations. Metric: Cohen's κ (per axis & per category). "
"Sparse categories (no positives) → κ undefined → **N/A**. Confusion as [TN FP / FN TP].")
L.append("")
def axis_block(title, scope, per, fail, fields):
L.append(f"## {title}")
L.append(f"_{scope}_ · parse failures: {fail}")
L.append("| category | κ | TN | FP | FN | TP | precision | recall | f1 |")
L.append("|---|---|---|---|---|---|---|---|---|")
for f in fields:
yt, yp = per[f]
k = cohen_kappa(yt, yp); c = binary_counts(yt, yp); p, r, f1 = prf(c)
note = " ← N/A (0 positives)" if sum(yt) == 0 else ""
L.append(f"| {f} | {_fmt_k(k)}{note} | {c['tn']} | {c['fp']} | {c['fn']} | {c['tp']} | "
f"{'-' if p is None else f'{p:.2f}'} | {'-' if r is None else f'{r:.2f}'} | "
f"{'-' if f1 is None else f'{f1:.2f}'} |")
# axis-level "any" binary
anyt = [int(any(per[f][0][j] for f in fields)) for j in range(len(per[fields[0]][0]))]
anyp = [int(any(per[f][1][j] for f in fields)) for j in range(len(per[fields[0]][1]))]
L.append(f"\n**Axis-level (any {title.split()[0].lower()} present): κ = {_fmt_k(cohen_kappa(anyt, anyp))}** "
f"(n={len(anyt)} turns, {sum(anyt)} positive).")
L.append("")
axis_block("Technique (6 binary)", "first ≤3 user turns/conv", tech_per, tech_fail, TECH)
axis_block("Input Quality (2 binary)", "first ≤3 user turns/conv", iq_per, iq_fail, IQ)
L.append("## Interaction (refinement_attempt)")
L.append(f"_all follow-up turns_ · parse failures: {inter_fail}")
k = cohen_kappa(inter_yt, inter_yp); c = binary_counts(inter_yt, inter_yp); p, r, f1 = prf(c)
L.append(f"- **κ = {_fmt_k(k)}** · n={len(inter_yt)}, positives={sum(inter_yt)}")
L.append(f"- confusion [TN {c['tn']} · FP {c['fp']} / FN {c['fn']} · TP {c['tp']}] · "
f"P {'-' if p is None else f'{p:.2f}'} R {'-' if r is None else f'{r:.2f}'} F1 {'-' if f1 is None else f'{f1:.2f}'}")
L.append("")
L.append("## Focus (topic_shift detection, embedder-gated)")
L.append("Binary shift-vs-not over **every** adjacent user-turn boundary; the embedder gates which "
"boundaries the 8B classifies (cosine < T). Threshold swept; κ-maximizing T selected.")
L.append("| T | κ (shift) | embedder recall@T (on GT shifts) | candidates flagged |")
L.append("|---|---|---|---|")
for T in sorted(sweep):
k, rec, cand, *_ = sweep[T]
mark = " ← selected" if T == bestT else ""
L.append(f"| {T:.2f} | {_fmt_k(k)} | {'-' if rec is None else f'{rec:.2f}'} | {cand} |{mark}")
fk, frec, fcand, fyt, fyp, finfo = sweep[bestT]
cc = binary_counts(fyt, fyp)
rec_str = "-" if frec is None else f"{frec:.2f}"
hits = "" if frec is None else f" ({int(round(frec * finfo['gt_shift']))}/{finfo['gt_shift']} GT shifts flagged)"
L.append("")
L.append(f"**Selected T={bestT:.2f}: κ = {_fmt_k(fk)}**, embedder boundary-recall = {rec_str}{hits}. "
f"Confusion [TN {cc['tn']} · FP {cc['fp']} / FN {cc['fn']} · TP {cc['tp']}].")
if finfo["three"]:
L.append("\n3-class agreement on co-identified candidates (GT rel → 8B rel):")
for (g, p2), n in sorted(finfo["three"].items(), key=lambda x: -x[1]):
L.append(f"- {g}{p2}: {n}")
L.append("\n_Embedder boundary-recall is the model-agnostic metric Kim requested: of the boundaries Kim "
"hand-identified as topic_shift, how many the production embedder surfaces as candidates._")
L.append("")
L.append("## OOD report (validation-set self-check)")
L.append(f"Per-conv mean-user-turn embedding vs the set centroid: μ_sim={ood['mu']:.3f}, σ={ood['sd']:.3f}. "
f"Flagged (sim < μ−2σ, i.e. atypical conversations): {len(ood['flagged'])}.")
for cid, s in ood["flagged"][:15]:
L.append(f"- `{cid[:12]}` sim={s:.3f}")
L.append("\n_In production this same distance flags user uploads far from the validation distribution "
"(low-confidence / out-of-scope scoring)._")
L.append("")
with open(os.path.join(HERE, "measurement_results.md"), "w") as f:
f.write("\n".join(L) + "\n")
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--workers", type=int, default=8)
args = ap.parse_args()
main(limit=args.limit, workers=args.workers)