Spaces:
Running
Running
Devdan Schretlen
Claude Opus 5 (1M context)
Expand the calendar corpus to every faculty, and rewrite the UI copy
fcdba1d Download eval/run_eval.py from DevSchretl/UBCHelper: direct link, hf CLI and curl.
- Browser
- Download file 12.4 kB
-
https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/eval/run_eval.py
- Command line
-
hf download hf://spaces/DevSchretl/UBCHelper/eval/run_eval.py
-
curl -L -o run_eval.py https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/eval/run_eval.py
12.4 kB
| """ | |
| Orchestrator — run the whole evaluation and record the result. | |
| For every question in the frozen test set it does ONE retrieval pass, then (unless | |
| --retrieval-only) generates an answer and judges it, computing both metric tiers: | |
| Tier 1 (retrieval) : hit@k, recall@k, MRR -- deterministic, from eval/retrieval_metrics | |
| Tier 2 (generation): faithfulness, answer_relevancy, context_precision/recall -- eval/judge | |
| It writes two things to eval/reports/: | |
| report.md ONE self-contained doc: summary metrics + every question's full test | |
| data (question, reference answer, retrieved excerpts, answer, scores). | |
| Overwritten each run. | |
| history.csv ONE row per run -- the cross-run performance ledger (kept so you can | |
| compare runs, e.g. phase1 vs phase2, since report.md only holds the last). | |
| Run (needs an index built; the judge defaults to a local LM Studio server — see | |
| config.JUDGE_MODEL): | |
| python -m eval.run_eval --name phase1-dense | |
| python -m eval.run_eval --retrieval-only # fast: skip generation + judge | |
| python -m eval.run_eval --limit 3 # quick smoke test | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| from datetime import datetime | |
| from statistics import mean | |
| from src import config, generate, retrieve | |
| from eval import judge | |
| from eval import retrieval_metrics as rm | |
| GEN_METRICS = ["faithfulness", "answer_relevancy", "context_precision", "context_recall"] | |
| def _mean_or_none(values: list[float]): | |
| vals = [v for v in values if v is not None] | |
| return round(mean(vals), 4) if vals else None | |
| def run(testset: list[dict], k: int, do_judge: bool) -> dict: | |
| """Evaluate every test item and return the full report dict.""" | |
| per_item = [] | |
| for n, item in enumerate(testset, start=1): | |
| # --- Tier 1: retrieve once, score deterministically ----------------------------- | |
| results = retrieve.retrieve(item["question"], top_k=k) | |
| retrieved_ids = [r.id for r in results] | |
| gold_ids = item["gold_ids"] | |
| record = { | |
| "id": item["id"], | |
| "question": item["question"], | |
| "reference_answer": item.get("reference_answer", ""), | |
| "gold_ids": gold_ids, | |
| "retrieved_ids": retrieved_ids, | |
| "retrieved_titles": [r.recipe["title"] for r in results], | |
| "retrieval": { | |
| "hit": rm.hit_at_k(retrieved_ids, gold_ids, k), | |
| "recall": rm.recall_at_k(retrieved_ids, gold_ids, k), | |
| "rr": rm.reciprocal_rank(retrieved_ids, gold_ids), | |
| }, | |
| } | |
| # --- Tier 2: generate an answer, then judge it ---------------------------------- | |
| if do_judge: | |
| contexts = [r.recipe["text"] for r in results] | |
| answer = generate.generate_answer(item["question"], results) | |
| record["answer"] = answer | |
| record["judge"] = judge.judge_item( | |
| item["question"], answer, contexts, item["reference_answer"] | |
| ) | |
| faith = record["judge"]["faithfulness"] | |
| print( | |
| f" [{n}/{len(testset)}] {item['id']} hit={record['retrieval']['hit']:.0f}" | |
| f" faithfulness={'-' if faith is None else f'{faith:.2f}'}" | |
| ) | |
| else: | |
| print(f" [{n}/{len(testset)}] {item['id']} hit={record['retrieval']['hit']:.0f}") | |
| per_item.append(record) | |
| return _aggregate(per_item, k, do_judge) | |
| def _aggregate(per_item: list[dict], k: int, do_judge: bool) -> dict: | |
| n = len(per_item) or 1 | |
| retrieval = { | |
| "hit@k": round(sum(x["retrieval"]["hit"] for x in per_item) / n, 4), | |
| "recall@k": round(sum(x["retrieval"]["recall"] for x in per_item) / n, 4), | |
| "mrr": round(sum(x["retrieval"]["rr"] for x in per_item) / n, 4), | |
| } | |
| generation = None | |
| hallucination_rate = None | |
| if do_judge: | |
| generation = { | |
| m: _mean_or_none([x["judge"][m] for x in per_item]) for m in GEN_METRICS | |
| } | |
| if generation["faithfulness"] is not None: | |
| hallucination_rate = round(1.0 - generation["faithfulness"], 4) | |
| return { | |
| "run": { | |
| "timestamp": datetime.now().isoformat(timespec="seconds"), | |
| "k": k, | |
| "num_questions": len(per_item), | |
| "judged": do_judge, | |
| "retrieval_mode": config.RETRIEVAL_MODE, | |
| "chat_model": generate.active_model(), | |
| "judge_model": config.JUDGE_MODEL, | |
| "embedding_model": config.EMBEDDING_MODEL, | |
| "corpus_size": len(retrieve._load_index()[1]), # indexed chunk count | |
| }, | |
| "aggregate": { | |
| "retrieval": retrieval, | |
| "generation": generation, | |
| "hallucination_rate": hallucination_rate, | |
| }, | |
| "per_item": per_item, | |
| } | |
| # ---------------------------------------------------------------------------------------- | |
| # Output: a single, self-contained markdown report, plus the history.csv ledger. | |
| # ---------------------------------------------------------------------------------------- | |
| HISTORY_COLUMNS = [ | |
| "timestamp", "name", "k", "num_questions", "judged", | |
| # The two knobs that move retrieval most. Without them a sweep's rows are distinguishable | |
| # only by their --name label, which is no use comparing runs across a corpus change. | |
| "retrieval_mode", "corpus_size", | |
| "chat_model", "judge_model", | |
| "hit@k", "recall@k", "mrr", | |
| "faithfulness", "answer_relevancy", "context_precision", "context_recall", | |
| "hallucination_rate", | |
| ] | |
| def _fmt(value) -> str: | |
| return "-" if value is None else f"{value:.3f}" | |
| def write_report(report: dict, name: str): | |
| """Write the single markdown report and append one row to the history ledger.""" | |
| config.REPORTS_DIR.mkdir(parents=True, exist_ok=True) | |
| config.REPORT_PATH.write_text(_render_markdown(report, name), encoding="utf-8") | |
| _append_history(report, name) | |
| return config.REPORT_PATH | |
| def _render_markdown(report: dict, name: str) -> str: | |
| """One concise markdown doc: a summary table, then every question's full test data.""" | |
| run = report["run"] | |
| agg = report["aggregate"] | |
| ret = agg["retrieval"] | |
| gen = agg["generation"] | |
| k = run["k"] | |
| # --- header + summary metric table --- | |
| lines = [ | |
| f"# UBCHelper eval — {name}", | |
| "", | |
| f"{run['timestamp']} · {run['num_questions']} questions · k={k} · " | |
| f"judge {'on' if run['judged'] else 'off'}", | |
| "", | |
| f"Models: chat=`{run['chat_model']}`, judge=`{run['judge_model']}`, " | |
| f"embed=`{run['embedding_model']}` · corpus={run['corpus_size']} · " | |
| f"retrieval={run['retrieval_mode']}", | |
| "", | |
| "## Summary", | |
| "", | |
| "| metric | score |", | |
| "|--------|-------|", | |
| f"| hit@{k} | {_fmt(ret['hit@k'])} |", | |
| f"| recall@{k} | {_fmt(ret['recall@k'])} |", | |
| f"| MRR | {_fmt(ret['mrr'])} |", | |
| ] | |
| if gen is not None: | |
| lines += [ | |
| f"| faithfulness | {_fmt(gen['faithfulness'])} |", | |
| f"| answer_relevancy | {_fmt(gen['answer_relevancy'])} |", | |
| f"| context_precision | {_fmt(gen['context_precision'])} |", | |
| f"| context_recall | {_fmt(gen['context_recall'])} |", | |
| f"| **hallucination_rate** | **{_fmt(agg['hallucination_rate'])}** |", | |
| ] | |
| # --- per-question detail: all test data, one block each --- | |
| lines += ["", "## Questions", ""] | |
| for x in report["per_item"]: | |
| r = x["retrieval"] | |
| marker = "hit" if r["hit"] else "miss" | |
| retrieved = " · ".join( | |
| f"[{i}] {t}" for i, t in zip(x["retrieved_ids"], x["retrieved_titles"]) | |
| ) | |
| lines += [ | |
| f"### {x['id']} — {marker} — gold {x['gold_ids']}", | |
| "", | |
| f"**Q:** {x['question']}", | |
| "", | |
| f"**Reference:** {x['reference_answer']}", | |
| "", | |
| f"**Retrieved:** {retrieved}", | |
| ] | |
| if "judge" in x: | |
| j = x["judge"] | |
| lines += [ | |
| "", | |
| f"**Answer:** {x['answer']}", | |
| "", | |
| f"**Scores:** rr {r['rr']:.2f} · faithfulness {_fmt(j['faithfulness'])} " | |
| f"· answer_relevancy {_fmt(j['answer_relevancy'])} " | |
| f"· context_precision {_fmt(j['context_precision'])} " | |
| f"· context_recall {_fmt(j['context_recall'])}" | |
| + (f" · parse-failed: {', '.join(j['errors'])}" if j["errors"] else ""), | |
| ] | |
| else: | |
| lines += ["", f"**Scores:** rr {r['rr']:.2f}"] | |
| lines.append("") | |
| return "\n".join(lines) + "\n" | |
| def _append_history(report: dict, name: str) -> None: | |
| run = report["run"] | |
| ret = report["aggregate"]["retrieval"] | |
| gen = report["aggregate"]["generation"] or {} | |
| row = { | |
| "timestamp": run["timestamp"], | |
| "name": name, | |
| "k": run["k"], | |
| "num_questions": run["num_questions"], | |
| "judged": run["judged"], | |
| "retrieval_mode": run["retrieval_mode"], | |
| "corpus_size": run["corpus_size"], | |
| "chat_model": run["chat_model"], | |
| "judge_model": run["judge_model"], | |
| "hit@k": ret["hit@k"], | |
| "recall@k": ret["recall@k"], | |
| "mrr": ret["mrr"], | |
| "faithfulness": gen.get("faithfulness"), | |
| "answer_relevancy": gen.get("answer_relevancy"), | |
| "context_precision": gen.get("context_precision"), | |
| "context_recall": gen.get("context_recall"), | |
| "hallucination_rate": report["aggregate"]["hallucination_rate"], | |
| } | |
| is_new = not config.HISTORY_PATH.exists() | |
| if not is_new: | |
| # Appending a row in a new column order under an old header silently shifts every | |
| # value into the wrong column, so refuse rather than corrupt the ledger. | |
| with open(config.HISTORY_PATH, "r", encoding="utf-8", newline="") as f: | |
| header = next(csv.reader(f), []) | |
| if header != HISTORY_COLUMNS: | |
| raise SystemExit( | |
| f"{config.HISTORY_PATH} header does not match HISTORY_COLUMNS.\n" | |
| f" file: {header}\n code: {HISTORY_COLUMNS}\n" | |
| f"Migrate the existing rows to the new columns before appending." | |
| ) | |
| with open(config.HISTORY_PATH, "a", encoding="utf-8", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=HISTORY_COLUMNS) | |
| if is_new: | |
| writer.writeheader() | |
| writer.writerow(row) | |
| def _print_summary(report: dict) -> None: | |
| agg = report["aggregate"] | |
| ret = agg["retrieval"] | |
| k = report["run"]["k"] | |
| print("\n=== Summary ===") | |
| print(f"Retrieval : hit@{k}={_fmt(ret['hit@k'])} " | |
| f"recall@{k}={_fmt(ret['recall@k'])} mrr={_fmt(ret['mrr'])}") | |
| gen = agg["generation"] | |
| if gen is not None: | |
| print(f"Generation: faithfulness={_fmt(gen['faithfulness'])} " | |
| f"answer_relevancy={_fmt(gen['answer_relevancy'])}") | |
| print(f" context_precision={_fmt(gen['context_precision'])} " | |
| f"context_recall={_fmt(gen['context_recall'])}") | |
| print(f"Hallucination rate (1 - faithfulness): " | |
| f"{_fmt(agg['hallucination_rate'])}") | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Run the UBCHelper evaluation harness.") | |
| parser.add_argument("--name", default="run", help="label for this run (e.g. phase1-dense)") | |
| parser.add_argument("--k", type=int, default=config.EVAL_K, help="retrieval depth / @k") | |
| parser.add_argument("--limit", type=int, default=None, help="only the first N questions") | |
| parser.add_argument( | |
| "--retrieval-only", | |
| action="store_true", | |
| help="skip generation + the LLM judge (fast, deterministic, no chat model needed)", | |
| ) | |
| args = parser.parse_args() | |
| testset = rm.load_testset() | |
| if args.limit: | |
| testset = testset[: args.limit] | |
| print(f"Running '{args.name}' over {len(testset)} questions (k={args.k}, " | |
| f"mode={config.RETRIEVAL_MODE}, judge={'off' if args.retrieval_only else 'on'}) ...") | |
| report = run(testset, k=args.k, do_judge=not args.retrieval_only) | |
| _print_summary(report) | |
| report_path = write_report(report, args.name) | |
| print(f"\nWrote:\n {report_path}\n {config.HISTORY_PATH}") | |
| if __name__ == "__main__": | |
| main() | |