Spaces:
Running on Zero
Running on Zero
Download src/graphtrace/runner.py from ARslan-Ahamd/graphtrace-bench: direct link, hf CLI and curl.
- Browser
- Download file 13.3 kB
-
https://huggingface.co/spaces/ARslan-Ahamd/graphtrace-bench/resolve/main/src/graphtrace/runner.py
- Command line
-
hf download hf://spaces/ARslan-Ahamd/graphtrace-bench/src/graphtrace/runner.py
-
curl -L -o runner.py https://huggingface.co/spaces/ARslan-Ahamd/graphtrace-bench/resolve/main/src/graphtrace/runner.py
13.3 kB
| """Experiment runner: predictions (resumable), evaluation, failure examples. | |
| Layout of ``runs/<name>/``:: | |
| manifest.json config snapshot, prompt version, packages, git commit, model specs | |
| predictions.jsonl one MethodOutput per (method, item), with full trace | |
| judgments.jsonl evaluation-judge labels for non-gold cited chunks (optional) | |
| per_item.csv every metric for every (method, item) | |
| metrics.json aggregates with bootstrap CIs, per-category tables, paired tests, calibration | |
| failures.jsonl automatically selected failure examples per method | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import hashlib | |
| import json | |
| import math | |
| import threading | |
| from collections import defaultdict | |
| from concurrent.futures import ThreadPoolExecutor | |
| from pathlib import Path | |
| from .bench import read_items | |
| from .config import ExperimentConfig, ProjectConfig, load_models, make_client, manifest, write_json | |
| from .evaluation.metrics import by_key, calibration, item_metrics | |
| from .evaluation.stats import bootstrap_ci, paired_test | |
| from .graph import KG | |
| from .linking import EntityLinker | |
| from .llm import ModelSpec | |
| from .methods import MethodConfig, Resources, run_method | |
| from .pipeline import load_corpus, read_jsonl | |
| from .prompts import PROMPT_VERSION | |
| from .schemas import BenchItem, MethodOutput | |
| METRICS = [ | |
| "correct", | |
| "decision_correct", | |
| "answer_f1", | |
| "answer_exact", | |
| "path_valid_fraction", | |
| "path_all_valid", | |
| "path_completeness", | |
| "citation_rate", | |
| "grounding_precision", | |
| "false_premise_rejected", | |
| "conflict_flagged", | |
| "insufficient_abstained", | |
| "answered", | |
| "errored", | |
| "context_tokens", | |
| "calls", | |
| "input_tokens", | |
| "output_tokens", | |
| "cost_usd", | |
| "llm_latency_s", | |
| ] | |
| # Headline = categories whose gold does not rely on the result-record agreement rule that the | |
| # GraphTrace verifier also uses. C1/C2 are reported separately (protocol, threats to validity). | |
| HEADLINE_CATEGORIES = ("single_hop", "multi_hop", "false_premise") | |
| def method_hash(method: MethodConfig, base_spec: ModelSpec, project: ProjectConfig, exp: ExperimentConfig) -> str: | |
| """Identity of everything that determines a method's output for an item. Predictions | |
| with a different hash are stale and are re-run.""" | |
| payload = { | |
| "method": method.model_dump(), | |
| "base_model": base_spec.model_dump(), | |
| "prompt_version": PROMPT_VERSION, | |
| "nli": exp.nli_model if exp.nli_verifier else None, | |
| "min_edge_confidence": project.bench.min_edge_confidence, | |
| } | |
| return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16] | |
| def load_bench(exp: ExperimentConfig) -> list[BenchItem]: | |
| items = read_items(exp.bench_file) | |
| unreviewed = [i for i in items if i.review_status not in ("accepted", "edited")] | |
| if unreviewed and not exp.allow_unreviewed: | |
| raise ValueError( | |
| f"{len(unreviewed)} items in {exp.bench_file} are not human-accepted. Freeze the benchmark first " | |
| "(`graphtrace freeze`) or set allow_unreviewed: true for a smoke test that must not be reported." | |
| ) | |
| return items[: exp.limit] if exp.limit else items | |
| def resources(project: ProjectConfig, exp: ExperimentConfig, offline: bool = False) -> Resources: | |
| papers, chunks = load_corpus(project) | |
| kg = KG.load(project.paths.graph, project.bench.min_edge_confidence) | |
| nli = None | |
| if exp.nli_verifier: | |
| from .verification import NLIVerifier | |
| nli = NLIVerifier(exp.nli_model) | |
| return Resources( | |
| base=make_client(exp.base_model, project, offline=offline), | |
| kg=kg, | |
| linker=EntityLinker(kg), | |
| chunks={c.chunk_id: c for c in chunks}, | |
| titles={d: p.title for d, p in papers.items()}, | |
| nli=nli, | |
| ) | |
| def run_experiment(project: ProjectConfig, exp: ExperimentConfig, offline: bool = False, res: Resources | None = None) -> Path: | |
| run_dir = Path(project.paths.runs) / exp.name | |
| run_dir.mkdir(parents=True, exist_ok=True) | |
| items = load_bench(exp) | |
| res = res or resources(project, exp, offline) | |
| models = load_models(project.models_file) if Path(project.models_file).exists() else {} | |
| hashes = {m.name: method_hash(m, res.base.spec, project, exp) for m in exp.methods} | |
| write_json( | |
| run_dir / "manifest.json", | |
| manifest( | |
| { | |
| "experiment": exp.model_dump(), | |
| "model_specs": {k: v.model_dump() for k, v in models.items() if k in (exp.base_model, exp.judge_model)}, | |
| "n_items": len(items), | |
| "method_config_hashes": hashes, | |
| "smoke_test_unreviewed_items": exp.allow_unreviewed, | |
| } | |
| ), | |
| ) | |
| pred_path = run_dir / "predictions.jsonl" | |
| # Completed = same method name AND same config hash AND no error. | |
| done = {(r["method"], r["item_id"], r.get("config_hash")) for r in read_jsonl(pred_path) if not r.get("error")} | |
| todo = [(m, it) for m in exp.methods for it in items if (m.name, it.item_id, hashes[m.name]) not in done] | |
| lock = threading.Lock() | |
| def one(t: tuple[MethodConfig, BenchItem]) -> MethodOutput: | |
| return run_method(t[0], t[1], res).model_copy(update={"config_hash": hashes[t[0].name]}) | |
| with pred_path.open("a", encoding="utf-8") as f, ThreadPoolExecutor(max_workers=exp.workers) as pool: | |
| for out in pool.map(one, todo): | |
| with lock: | |
| f.write(out.model_dump_json() + "\n") | |
| f.flush() | |
| return run_dir | |
| def latest_predictions(run_dir: Path, hashes: dict[str, str] | None = None) -> list[MethodOutput]: | |
| """Latest prediction per (method, item); with ``hashes``, only rows of the current config.""" | |
| latest: dict[tuple[str, str], dict] = {} | |
| for r in read_jsonl(run_dir / "predictions.jsonl"): | |
| if hashes is not None and r.get("config_hash") != hashes.get(r["method"]): | |
| continue | |
| latest[(r["method"], r["item_id"])] = r # later lines (successful re-runs) win | |
| return [MethodOutput(**r) for r in latest.values()] | |
| def _agg(rows: list[dict], seed: int = 0) -> dict: | |
| out = {"n": len(rows), "errors": sum(1 for r in rows if r.get("errored"))} | |
| for m in METRICS: | |
| vals = [r[m] for r in rows if r.get(m) is not None and not (isinstance(r[m], float) and math.isnan(r[m]))] | |
| mean, lo, hi = bootstrap_ci(vals, seed=seed) | |
| out[m] = {"mean": mean, "low": lo, "high": hi, "n": len(vals)} | |
| return out | |
| def evaluate_run(project: ProjectConfig, exp: ExperimentConfig, run_judge: bool = True, offline: bool = False) -> dict: | |
| run_dir = Path(project.paths.runs) / exp.name | |
| items = {i.item_id: i for i in load_bench(exp)} | |
| kg = KG.load(project.paths.graph, project.bench.min_edge_confidence) | |
| linker = EntityLinker(kg) | |
| man_path = run_dir / "manifest.json" | |
| hashes = json.loads(man_path.read_text(encoding="utf-8")).get("method_config_hashes") if man_path.exists() else None | |
| outputs = [o for o in latest_predictions(run_dir, hashes) if o.item_id in items] | |
| judged: dict[tuple[str, str], set[str]] = defaultdict(set) | |
| jpath = run_dir / "judgments.jsonl" | |
| reused = "; reused existing judgments.jsonl" if jpath.exists() else "" | |
| if not run_judge: | |
| judge_status = "skipped (--no-judge)" + reused | |
| elif not exp.judge_model: | |
| judge_status = "skipped (no judge_model configured)" + reused | |
| else: | |
| judge_status = f"ran ({exp.judge_model})" | |
| if run_judge and exp.judge_model: | |
| from .evaluation.judge import LLMJudge, judge_outputs | |
| _, chunks = load_corpus(project) | |
| rows = judge_outputs(outputs, items, {c.chunk_id: c for c in chunks}, LLMJudge(make_client(exp.judge_model, project, offline=offline))) | |
| with jpath.open("w", encoding="utf-8") as f: | |
| for r in rows: | |
| f.write(json.dumps(r, ensure_ascii=False) + "\n") | |
| for r in read_jsonl(jpath): | |
| if r.get("label") == "supported": | |
| judged[(r["method"], r["item_id"])].add(r["chunk_id"]) | |
| rows = [item_metrics(o, items[o.item_id], kg, linker, judged.get((o.method, o.item_id)), exp.min_support) for o in outputs] | |
| rows.sort(key=lambda r: (r["method"], r["item_id"])) | |
| with (run_dir / "per_item.csv").open("w", newline="", encoding="utf-8") as f: | |
| w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["item_id"]) | |
| w.writeheader() | |
| w.writerows(rows) | |
| per_method = by_key(rows, "method") | |
| metrics: dict = { | |
| "methods": {}, | |
| "headline": {}, | |
| "conflict_insufficient": {}, | |
| "by_category": {}, | |
| "calibration": {}, | |
| "paired_vs_reference": {}, | |
| "headline_categories": list(HEADLINE_CATEGORIES), | |
| "judge_used": bool(jpath.exists()), | |
| "judge_status": judge_status, | |
| } | |
| for m, rs in per_method.items(): | |
| metrics["methods"][m] = _agg(rs) | |
| metrics["headline"][m] = _agg([r for r in rs if r["category"] in HEADLINE_CATEGORIES]) | |
| metrics["conflict_insufficient"][m] = _agg([r for r in rs if r["category"] not in HEADLINE_CATEGORIES]) | |
| metrics["by_category"][m] = {c: _agg(crs) for c, crs in by_key(rs, "category").items()} | |
| metrics["calibration"][m] = calibration(rs) | |
| ref = per_method.get(exp.reference_method, []) | |
| ref_by_item = {r["item_id"]: r for r in ref} | |
| for m, rs in per_method.items(): | |
| if m == exp.reference_method: | |
| continue | |
| # Paired tests on the headline categories only. | |
| pair = [(r, ref_by_item[r["item_id"]]) for r in rs if r["item_id"] in ref_by_item and r["category"] in HEADLINE_CATEGORIES] | |
| metrics["paired_vs_reference"][m] = { | |
| k: paired_test([a[k] for a, _ in pair], [b[k] for _, b in pair]) | |
| for k in ( | |
| "correct", | |
| "path_valid_fraction", | |
| "path_all_valid", | |
| "citation_rate", | |
| "grounding_precision", | |
| "false_premise_rejected", | |
| "answer_f1", | |
| ) | |
| } | |
| write_json(run_dir / "metrics.json", metrics) | |
| write_failures(run_dir, outputs, items, rows) | |
| return metrics | |
| def write_failures(run_dir: Path, outputs: list[MethodOutput], items: dict[str, BenchItem], rows: list[dict], per_kind: int = 5) -> None: | |
| """Pick concrete failure cases per method, deterministically (sorted by item ID).""" | |
| out_by = {(o.method, o.item_id): o for o in outputs} | |
| kinds = { | |
| "accepted_false_premise": lambda r: r["category"] == "false_premise" and r["decision"] == "answer", | |
| "missed_conflict": lambda r: r["template_id"] == "C1" and r["decision"] != "flag_conflict", | |
| "wrong_answer_with_invalid_path": lambda r: r["gold_decision"] == "answer" and r["correct"] == 0 and r["path_all_valid"] == 0, | |
| "correct_answer_invalid_path": lambda r: r["correct"] == 1 and r["path_all_valid"] == 0, | |
| "over_abstention": lambda r: r["gold_decision"] == "answer" and r["decision"] == "abstain", | |
| "error": lambda r: bool(r["error"]), | |
| } | |
| with (run_dir / "failures.jsonl").open("w", encoding="utf-8") as f: | |
| for method, rs in sorted(by_key(rows, "method").items()): | |
| for kind, pred in kinds.items(): | |
| for r in [r for r in rs if pred(r)][:per_kind]: | |
| o = out_by[(method, r["item_id"])] | |
| it = items[r["item_id"]] | |
| f.write( | |
| json.dumps( | |
| { | |
| "method": method, | |
| "kind": kind, | |
| "item_id": it.item_id, | |
| "question": it.question, | |
| "gold_decision": it.gold_decision, | |
| "gold_answer": it.gold_answer, | |
| "decision": o.decision, | |
| "answer": o.answer, | |
| "confidence": o.confidence, | |
| "graph_path": o.graph_path, | |
| "source_chunk_ids": o.source_chunk_ids, | |
| "verifier": o.trace.get("verifier"), | |
| }, | |
| ensure_ascii=False, | |
| ) | |
| + "\n" | |
| ) | |
| def estimate_cost(exp: ExperimentConfig, project: ProjectConfig, n_items: int, avg_output_tokens: int = 350) -> dict: | |
| """Upper-bound style estimate: one answer call per (method, item) using the full budget, | |
| plus a retry for half of GraphTrace's items, plus ~600 tokens of instructions per call.""" | |
| spec = load_models(project.models_file)[exp.base_model] | |
| calls = 0 | |
| tokens_in = 0 | |
| for m in exp.methods: | |
| per = 1.5 if m.kind == "graphtrace" else 1.0 | |
| calls += int(n_items * per) | |
| tokens_in += int(n_items * per * (m.context_budget + 600)) | |
| tokens_out = calls * avg_output_tokens | |
| cost = (tokens_in * spec.price_in_per_mtok + tokens_out * spec.price_out_per_mtok) / 1e6 | |
| return {"model": spec.model, "calls": calls, "input_tokens": tokens_in, "output_tokens": tokens_out, "usd": round(cost, 2)} | |