Spaces:
Sleeping
Sleeping
File size: 11,457 Bytes
54f341c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | """Evaluation harness (T3.3 + T3.4).
Two things the brief explicitly asks for: false positive / negative rates for
the checker, and per-node metrics. This produces both.
Modes:
inject (default, offline, no keys, no model needed for the core):
Take each gold (correct, grounded) grade as the CLEAN/negative class,
then synthesise faulty variants by injecting the three failure types
the checker must catch:
- ungrounded_award : award > 0 with no citation
- fabricated_cite : cites a claim that does not exist
- inflated_award : awarded above the criterion max
Run the DETERMINISTIC checker (grader.rules.verify_citations) over all
samples and compute a confusion matrix + precision/recall/F1/FP/FN.
This is the checker's FP/FN number. Also reports HHEM grounding
separation on faithful vs contradicted justifications, if the model is
available (skipped gracefully otherwise).
full (needs GOOGLE_API_KEY/GROQ_API_KEY and image paths in the records):
Additionally run the live pipeline (grade_one_pass) and report scorer
agreement vs gold (MAE, exact-match) and how often the gate skipped the
LLM Critic. Skipped with a note if keys/images are absent.
Writes eval_report.json and eval_report.md next to --out.
Usage:
python scripts/eval.py --set data/eval/set.jsonl --out data/eval
python scripts/eval.py --set data/eval/set.jsonl --out data/eval --full
"""
from __future__ import annotations
import argparse
import copy
import json
import sys
from pathlib import Path
# Make `backend` importable regardless of CWD.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from backend.grader import rules # noqa: E402
try:
from backend.grader import grounding as _grounding # noqa: E402
_HAVE_GROUNDING = True
except Exception:
_HAVE_GROUNDING = False
# --------------------------------------------------------------------------
# Error injection
# --------------------------------------------------------------------------
def _first_scored(gold: list[dict]) -> int | None:
for i, pc in enumerate(gold):
if float(pc.get("awarded", 0) or 0) > 0:
return i
return None
def inject_ungrounded(gold: list[dict]) -> list[dict] | None:
g = copy.deepcopy(gold)
i = _first_scored(g)
if i is None:
return None
g[i]["reasoning"] = "looks correct overall"
g[i].pop("claim_refs", None)
return g
def inject_fabricated(gold: list[dict]) -> list[dict] | None:
g = copy.deepcopy(gold)
i = _first_scored(g)
if i is None:
return None
g[i]["claim_refs"] = [99]
g[i]["reasoning"] = "claim 99 supports this"
return g
def inject_inflated(gold: list[dict]) -> list[dict] | None:
g = copy.deepcopy(gold)
i = _first_scored(g)
if i is None:
return None
g[i]["awarded"] = float(g[i].get("max", 0)) + 1.0
return g
_INJECTORS = {
"ungrounded_award": inject_ungrounded,
"fabricated_cite": inject_fabricated,
"inflated_award": inject_inflated,
}
# --------------------------------------------------------------------------
# Checker evaluation (deterministic; the brief's FP/FN)
# --------------------------------------------------------------------------
def evaluate_checker(records: list[dict]) -> dict:
tp = fp = tn = fn = 0
per_type = {k: {"caught": 0, "total": 0} for k in _INJECTORS}
fp_examples, fn_examples = [], []
for rec in records:
claims = rec.get("claims", [])
gold = rec.get("gold_per_criterion", [])
# CLEAN sample (negative): the checker should PASS it.
clean = rules.verify_citations(claims, gold)
if clean["passed"]:
tn += 1
else:
fp += 1
fp_examples.append({"id": rec.get("id"), "summary": clean["summary"]})
# FAULTY samples (positive): the checker should FAIL each.
for kind, injector in _INJECTORS.items():
faulty = injector(gold)
if faulty is None:
continue
per_type[kind]["total"] += 1
res = rules.verify_citations(claims, faulty)
if not res["passed"]:
tp += 1
per_type[kind]["caught"] += 1
else:
fn += 1
fn_examples.append({"id": rec.get("id"), "kind": kind})
precision = tp / (tp + fp) if (tp + fp) else 1.0
recall = tp / (tp + fn) if (tp + fn) else 1.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
total = tp + fp + tn + fn
return {
"confusion": {"tp": tp, "fp": fp, "tn": tn, "fn": fn},
"precision": round(precision, 4),
"recall": round(recall, 4),
"f1": round(f1, 4),
"accuracy": round((tp + tn) / total, 4) if total else 0.0,
"fp_rate": round(fp / (fp + tn), 4) if (fp + tn) else 0.0,
"fn_rate": round(fn / (fn + tp), 4) if (fn + tp) else 0.0,
"per_injection_type": per_type,
"fp_examples": fp_examples[:5],
"fn_examples": fn_examples[:5],
}
# --------------------------------------------------------------------------
# Grounding separation (optional; needs HHEM)
# --------------------------------------------------------------------------
def evaluate_grounding(records: list[dict]) -> dict:
if not _HAVE_GROUNDING:
return {"available": False, "reason": "grounding module import failed"}
faithful, contradicted = [], []
for rec in records:
premise = _grounding.build_premise(rec)
gold = rec.get("gold_per_criterion", [])
good = " ".join(pc.get("reasoning", "") for pc in gold).strip()
bad = "This is unrelated; the answer discusses the wrong topic entirely."
sg = _grounding.grounding_score(premise, good)
sb = _grounding.grounding_score(premise, bad)
if sg is not None:
faithful.append(sg)
if sb is not None:
contradicted.append(sb)
if not faithful or not contradicted:
return {"available": False,
"reason": "HHEM model unavailable (no score produced)"}
mf = sum(faithful) / len(faithful)
mc = sum(contradicted) / len(contradicted)
return {
"available": True,
"mean_faithful": round(mf, 4),
"mean_contradicted": round(mc, 4),
"separation": round(mf - mc, 4),
"n": len(faithful),
}
# --------------------------------------------------------------------------
# Scorer agreement (optional; needs keys + image paths)
# --------------------------------------------------------------------------
def evaluate_scorer(records: list[dict]) -> dict:
import os
if not (os.environ.get("GOOGLE_API_KEY") or os.environ.get("GROQ_API_KEY")):
return {"available": False, "reason": "no API key set"}
gradable = [r for r in records if r.get("image_path")]
if not gradable:
return {"available": False,
"reason": "records have no image_path; synthetic set is text-only"}
from backend.grader import grade_one_pass # noqa
abs_err, exact, skipped_critic, n = 0.0, 0, 0, 0
for r in gradable:
gold_total = sum(pc.get("awarded", 0) for pc in r["gold_per_criterion"])
g = grade_one_pass(r["image_path"], r["question"], r["max_marks"], r["criteria"])
abs_err += abs(float(g.get("score", 0)) - gold_total)
exact += int(abs(float(g.get("score", 0)) - gold_total) < 1e-6)
# Gate skipped the LLM critic when critic_feedback is empty and it passed.
skipped_critic += int(g.get("grounding_band") == "high")
n += 1
return {
"available": True, "n": n,
"mae": round(abs_err / n, 4) if n else 0.0,
"exact_match_rate": round(exact / n, 4) if n else 0.0,
"critic_skipped_rate": round(skipped_critic / n, 4) if n else 0.0,
}
# --------------------------------------------------------------------------
# Report
# --------------------------------------------------------------------------
def _md(report: dict) -> str:
c = report["checker"]["confusion"]
lines = [
"# GradeOps eval report", "",
f"Records: {report['n_records']}", "",
"## Checker FP/FN (deterministic citation verifier)", "",
f"- precision **{report['checker']['precision']}**, "
f"recall **{report['checker']['recall']}**, "
f"F1 **{report['checker']['f1']}**, accuracy **{report['checker']['accuracy']}**",
f"- FP rate {report['checker']['fp_rate']}, FN rate {report['checker']['fn_rate']}",
f"- confusion: TP {c['tp']}, FP {c['fp']}, TN {c['tn']}, FN {c['fn']}", "",
"### Catch rate by injected error type", "",
]
for k, v in report["checker"]["per_injection_type"].items():
rate = (v["caught"] / v["total"]) if v["total"] else 0.0
lines.append(f"- {k}: {v['caught']}/{v['total']} ({rate:.0%})")
g = report["grounding"]
lines += ["", "## Grounding separation (HHEM)", ""]
if g.get("available"):
lines.append(f"- faithful {g['mean_faithful']} vs contradicted "
f"{g['mean_contradicted']} (separation {g['separation']}, n={g['n']})")
else:
lines.append(f"- not run: {g.get('reason')}")
s = report["scorer"]
lines += ["", "## Scorer agreement vs gold (live pipeline)", ""]
if s.get("available"):
lines.append(f"- MAE {s['mae']}, exact-match {s['exact_match_rate']}, "
f"critic-skipped {s['critic_skipped_rate']} (n={s['n']})")
else:
lines.append(f"- not run: {s.get('reason')}")
return "\n".join(lines) + "\n"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--set", dest="path", default="data/eval/set.jsonl")
ap.add_argument("--out", default="data/eval")
ap.add_argument("--full", action="store_true",
help="also run the live pipeline (needs keys + image_path)")
args = ap.parse_args()
records = [json.loads(line) for line in Path(args.path).read_text().splitlines() if line.strip()]
report = {
"n_records": len(records),
"checker": evaluate_checker(records),
"grounding": evaluate_grounding(records),
"scorer": evaluate_scorer(records) if args.full else {"available": False, "reason": "run with --full"},
}
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
(out / "eval_report.json").write_text(json.dumps(report, indent=2))
(out / "eval_report.md").write_text(_md(report))
ch = report["checker"]
print(f"checker: P={ch['precision']} R={ch['recall']} F1={ch['f1']} "
f"acc={ch['accuracy']} FP={ch['confusion']['fp']} FN={ch['confusion']['fn']}")
for k, v in ch["per_injection_type"].items():
rate = (v["caught"] / v["total"]) if v["total"] else 0.0
print(f" {k}: {v['caught']}/{v['total']} ({rate:.0%})")
if report["grounding"].get("available"):
gg = report["grounding"]
print(f"grounding separation: faithful {gg['mean_faithful']} vs "
f"contradicted {gg['mean_contradicted']} ({gg['separation']})")
else:
print(f"grounding: {report['grounding'].get('reason')}")
print(f"scorer: {report['scorer'].get('reason', 'ran')}")
print(f"wrote {out/'eval_report.json'} and {out/'eval_report.md'}")
if __name__ == "__main__":
main()
|