Spaces:
Running
Running
File size: 7,495 Bytes
7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 7ab3f4b 6ba3ef3 | 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 | """Evaluate FinChat against the FinanceBench benchmark (corpus-aligned subset).
Runs FinChat on every FinanceBench 10-K question whose company + fiscal year is
present in our EDGAR corpus, then grades each answer against FinanceBench's gold
answer with an LLM-as-judge. Writes eval/results.md.
FinanceBench is a deliberately hard, expert-written benchmark, so the goal is an
honest, measured score on real questions — not a perfect one.
Run from the project root:
python -m eval.run_eval
"""
from __future__ import annotations
import re
import sys
import time
from pathlib import Path
# Make `src` and `eval` importable no matter how this is launched.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from datasets import load_dataset
from langchain_core.prompts import ChatPromptTemplate
from langchain_groq import ChatGroq
from src import config
from src.rag import answer, get_llm
# Cap the number of questions so a full run fits Groq's free-tier daily token
# budget. Sampled with an even stride across companies for representativeness.
MAX_QUESTIONS = 30
# Grade with a small, cheap, high-rate-limit model (judging is easy); this
# keeps the expensive 70B model's token budget for answering.
JUDGE_MODEL = "llama-3.1-8b-instant"
_judge_llm = None
def get_judge_llm() -> ChatGroq:
global _judge_llm
if _judge_llm is None:
_judge_llm = ChatGroq(model=JUDGE_MODEL, temperature=0.0, max_retries=5)
return _judge_llm
# Windows consoles default to cp1252 and crash when print() emits Unicode
# (curly quotes etc. from FinanceBench questions). Force UTF-8 output.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# FinanceBench company name -> ticker (aligned with config.TARGET_FILINGS).
FB_COMPANY_TO_TICKER = {
"AMD": "AMD", "American Express": "AXP", "Boeing": "BA", "PepsiCo": "PEP",
"Amcor": "AMCR", "3M": "MMM", "Johnson & Johnson": "JNJ", "CVS Health": "CVS",
"Pfizer": "PFE", "AES Corporation": "AES", "Verizon": "VZ", "Best Buy": "BBY",
"Adobe": "ADBE", "Ulta Beauty": "ULTA", "Coca-Cola": "KO", "Microsoft": "MSFT",
"Nike": "NKE", "Corning": "GLW",
}
JUDGE_PROMPT = ChatPromptTemplate.from_messages(
[
(
"system",
"You grade a financial question-answering system against a reference "
"answer from the FinanceBench benchmark.\n"
"Grade CORRECT if the system answer agrees with the reference on the "
"key fact(s) or number(s) (minor wording or rounding is fine), "
"PARTIAL if it is partially right or incomplete, and INCORRECT if it "
"is wrong, empty, or says it cannot find the answer.\n"
"Respond in EXACTLY this format:\n"
"VERDICT: <CORRECT|PARTIAL|INCORRECT>\n"
"REASON: <one short sentence>",
),
(
"human",
"QUESTION:\n{question}\n\nREFERENCE ANSWER:\n{reference}\n\n"
"SYSTEM ANSWER:\n{system}",
),
]
)
SCORE = {"CORRECT": 1.0, "PARTIAL": 0.5, "INCORRECT": 0.0}
def judge(question: str, reference: str, system: str) -> tuple[str, str]:
text = (JUDGE_PROMPT | get_judge_llm()).invoke(
{"question": question, "reference": reference, "system": system}
).content
v = re.search(r"VERDICT:\s*(CORRECT|PARTIAL|INCORRECT)", text, re.I)
r = re.search(r"REASON:\s*(.+)", text, re.I)
verdict = v.group(1).upper() if v else "INCORRECT"
reason = r.group(1).strip() if r else "(unparsed)"
return verdict, reason
def select_questions() -> list[dict]:
"""FinanceBench 10-K questions whose (company, fiscal year) is in our corpus."""
ingested = {(t, str(y)) for t, _name, y in config.TARGET_FILINGS}
fb = load_dataset("PatronusAI/financebench", split="train")
picked = []
for ex in fb:
if "10K" not in str(ex.get("doc_name", "")):
continue
ticker = FB_COMPANY_TO_TICKER.get(str(ex.get("company")))
if ticker and (ticker, str(ex.get("doc_period"))) in ingested:
picked.append(ex)
return picked
def main() -> None:
rows = select_questions()
if len(rows) > MAX_QUESTIONS: # even-stride sample
stride = len(rows) // MAX_QUESTIONS
rows = rows[::stride][:MAX_QUESTIONS]
print(f"Evaluating {len(rows)} corpus-aligned FinanceBench 10-K questions.\n")
results = []
total = 0.0
for ex in rows:
res = answer(ex["question"])
verdict, reason = judge(ex["question"], ex.get("answer", ""), res["answer"])
total += SCORE[verdict]
results.append((ex, res, verdict, reason))
print(f"[{verdict:9}] {ex.get('company')}: {ex['question'][:70]}")
time.sleep(1.0) # ease off the free-tier rate limit
n = len(rows)
accuracy = total / n if n else 0.0
correct = sum(1 for _e, _r, v, _j in results if v == "CORRECT")
partial = sum(1 for _e, _r, v, _j in results if v == "PARTIAL")
lines = [
"# FinChat Evaluation — FinanceBench (corpus-aligned subset)\n",
"FinChat is graded by an LLM-as-judge against gold answers from the "
"[FinanceBench](https://huggingface.co/datasets/PatronusAI/financebench) "
"benchmark, on every 10-K question whose company + fiscal year is in the "
"corpus. FinanceBench is expert-written and intentionally hard.\n",
f"- **Questions evaluated:** {n}",
f"- **CORRECT:** {correct} **PARTIAL:** {partial} "
f"**INCORRECT:** {n - correct - partial}",
f"- **Score:** {total:.1f} / {n}",
f"- **Accuracy (CORRECT=1.0, PARTIAL=0.5):** {accuracy:.0%}\n",
]
# Accuracy by FinanceBench question type -> shows the qualitative-vs-numeric
# split (metrics-generated questions require computation over tables).
by_type: dict[str, list[float]] = {}
for ex, _res, verdict, _reason in results:
agg = by_type.setdefault(str(ex.get("question_type", "unknown")), [0.0, 0])
agg[0] += SCORE[verdict]
agg[1] += 1
print("\nAccuracy by question type:")
lines += ["**Accuracy by FinanceBench question type:**\n",
"| Question type | Accuracy | N |", "|---|---|---|"]
for qt, (s, c) in sorted(by_type.items()):
print(f" {qt:24} {s / c:.0%} ({c})")
lines.append(f"| {qt} | {s / c:.0%} | {c} |")
lines += ["\n| # | Company | FY | Verdict | Question |",
"|---|---------|----|---------|----------|"]
for i, (ex, _res, verdict, _reason) in enumerate(results, 1):
q = ex["question"].replace("|", "\\|")
lines.append(
f"| {i} | {ex.get('company')} | {ex.get('doc_period')} | {verdict} | {q} |"
)
lines += ["\n---\n", "## Detailed results\n"]
for i, (ex, res, verdict, reason) in enumerate(results, 1):
lines += [
f"### {i}. {ex['question']}",
f"- **Company / FY:** {ex.get('company')} {ex.get('doc_period')} | "
f"**Routed to:** {res['routed_to']} | **Verdict:** {verdict}",
f"- **Judge:** {reason}",
f"- **FinanceBench gold:** {ex.get('answer', '')}",
f"- **FinChat:** {res['answer']}\n",
]
out_path = Path(__file__).resolve().parent / "results.md"
out_path.write_text("\n".join(lines), encoding="utf-8")
print(f"\nAccuracy: {accuracy:.0%} ({total:.1f}/{n}) -> wrote {out_path}")
if __name__ == "__main__":
main()
|