RAGForge / src /ragforge /eval_metrics.py
ArchitSharma's picture
Fix contradictory numeric answer evaluation
824b4f1
Raw
History Blame Contribute Delete
13.6 kB
from __future__ import annotations
import math
import re
import statistics
from typing import Any
_CITATION_RE = re.compile(r"\[((?:D|W|T)\d+)\]")
_CITATION_GROUP_RE = re.compile(r"\[((?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*)\]")
def extract_citation_ids(text: str) -> list[str]:
ids: list[str] = []
for match in _CITATION_GROUP_RE.findall(text or ""):
for part in match.split(","):
sid = part.strip()
if sid:
ids.append(sid)
return ids
def safe_div(num: float, den: float) -> float:
return float(num / den) if den else 0.0
def mean(values: list[float]) -> float:
return float(statistics.mean(values)) if values else 0.0
def percentile(values: list[float], q: float) -> float:
if not values:
return 0.0
ordered = sorted(float(v) for v in values)
if len(ordered) == 1:
return ordered[0]
pos = (len(ordered) - 1) * q
lo = math.floor(pos)
hi = math.ceil(pos)
if lo == hi:
return ordered[lo]
return ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo)
def _contains_expected_term(answer: str, term: str) -> bool:
"""Match a labeled answer term without accepting alphanumeric substrings.
Plain ``term in answer`` makes numeric labels unsafe: for example ``5 min``
is a substring of ``15 minutes``. Benchmark matching should recognize the
expected phrase as its own token/phrase while remaining tolerant of ordinary
whitespace differences.
"""
text = re.sub(r"\s+", " ", (answer or "").casefold()).strip()
expected = re.sub(r"\s+", " ", (term or "").casefold()).strip()
if not expected:
return False
pattern = re.escape(expected).replace(r"\ ", r"\s+")
if expected[0].isalnum():
pattern = r"(?<![0-9A-Za-z])" + pattern
if expected[-1].isalnum():
pattern = pattern + r"(?![0-9A-Za-z])"
return re.search(pattern, text) is not None
_NUMERIC_TOKEN_RE = re.compile(r"(?<![0-9A-Za-z])\d+(?:\.\d+)?%?(?![0-9A-Za-z])")
_QA_STOPWORDS = {
"a", "an", "and", "are", "as", "at", "be", "by", "does", "for", "from",
"how", "in", "is", "it", "of", "on", "or", "the", "to", "what", "which",
"with", "within", "do", "has", "have", "that", "this",
}
def _numeric_tokens(text: str) -> set[str]:
return {m.group(0).casefold() for m in _NUMERIC_TOKEN_RE.finditer(text or "")}
def _content_tokens(text: str) -> set[str]:
tokens = re.findall(r"[A-Za-z][A-Za-z0-9-]*", (text or "").casefold())
return {tok for tok in tokens if tok not in _QA_STOPWORDS and len(tok) > 1}
def _has_primary_numeric_conflict(answer: str, case: dict[str, Any]) -> bool:
"""Reject a wrong primary numeric answer that is later hedged with the right value.
A phrase-only matcher can still be fooled by a response such as
``"The target is 15 minutes. Note: the source says 5 minutes."``. For
benchmark cases whose labeled answer contains a number, identify the sentence
most directly about the question. If that primary sentence asserts a
different numeric value and contains none of the labeled numeric values, the
response is contradictory and should not pass merely because a later sentence
mentions the expected value.
Numbers already present in the question (for example the ``1`` in ``Sev-1``)
are ignored so identifiers are not mistaken for answer values.
"""
expected_terms = [str(x) for x in case.get("expected_all", [])] + [str(x) for x in case.get("expected_any", [])]
expected_numbers: set[str] = set()
for term in expected_terms:
expected_numbers.update(_numeric_tokens(term))
if not expected_numbers:
return False
question = str(case.get("question", ""))
question_numbers = _numeric_tokens(question)
question_tokens = _content_tokens(question)
if not question_tokens:
return False
# Split prose into sentence-like units while also respecting bullet/newline boundaries.
sentences = [part.strip() for part in re.split(r"(?<=[.!?])\s+|\n+", answer or "") if part.strip()]
candidates: list[tuple[int, int, str, set[str]]] = []
for idx, sentence in enumerate(sentences):
nums = _numeric_tokens(sentence) - question_numbers
if not nums:
continue
overlap = len(question_tokens & _content_tokens(sentence))
if overlap <= 0:
continue
candidates.append((overlap, -idx, sentence, nums))
if not candidates:
return False
# Highest question-token overlap wins; on a tie prefer the earlier statement.
_, _, _primary, primary_numbers = max(candidates, key=lambda row: (row[0], row[1]))
return bool(primary_numbers and primary_numbers.isdisjoint(expected_numbers))
def answer_key_match(answer: str, case: dict[str, Any]) -> bool:
if _has_primary_numeric_conflict(answer, case):
return False
expected_all = [str(x) for x in case.get("expected_all", [])]
expected_any = [str(x) for x in case.get("expected_any", [])]
if expected_all and not all(_contains_expected_term(answer, term) for term in expected_all):
return False
if expected_any and not any(_contains_expected_term(answer, term) for term in expected_any):
return False
return bool(expected_all or expected_any)
def missing_answer_match(answer: str, case: dict[str, Any] | None = None) -> bool:
"""Recognize a grounded "not present in the evidence" answer.
Missing-answer evaluation should reward calibrated uncertainty, not require a
single canned phrase. The matcher therefore accepts benchmark-specific cues
plus a conservative generic vocabulary for absence/insufficiency.
"""
text = re.sub(r"\s+", " ", (answer or "").strip().casefold())
if not text:
return False
case = case or {}
expected = [str(x).casefold() for x in case.get("expected_missing_any", [])]
generic = [
"not specified", "does not specify", "doesn't specify",
"not provided", "does not provide", "doesn't provide",
"does not mention", "doesn't mention", "do not mention", "not mentioned",
"does not contain", "doesn't contain", "no information", "no fee information",
"insufficient to answer", "insufficient evidence", "cannot determine", "can't determine",
"not stated", "not available in", "not present in", "no evidence of",
]
return any(cue in text for cue in [*expected, *generic] if cue)
def substantive_claim_units(answer: str) -> list[str]:
"""Extract Markdown-aware factual units for citation coverage.
Headings and generic list introductions are presentation structure, not
factual claims. Short numbered/bulleted values are factual units even when
they are much shorter than prose sentences.
"""
units: list[str] = []
in_code = False
for raw_line in (answer or "").splitlines():
line = raw_line.strip()
if line.startswith("```"):
in_code = not in_code
continue
if in_code or not line:
continue
if re.match(r"^#{1,6}\s+", line):
continue
is_list = bool(re.match(r"^(?:[-*+]\s+|\d+[.)]\s+)", line))
content = re.sub(r"^(?:[-*+]\s+|\d+[.)]\s+)", "", line).strip()
plain = re.sub(r"\[(?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*\]", "", content)
plain = re.sub(r"[`*_#>]", "", plain).strip()
words = re.findall(r"[A-Za-z0-9][A-Za-z0-9_.%$+-]*", plain)
# Preambles such as "The following documents contain:" introduce the
# claims in following bullets and should not depress citation coverage.
if not is_list and plain.endswith(":"):
continue
if is_list:
if len(words) >= 1 and len(plain) >= 3:
units.append(content)
continue
# Split long prose lines into sentence-level claims. Citations are often
# written after punctuation (``claim. [D1]``); move that citation tail
# onto the claim before splitting. Protect common abbreviations such as
# ``vs.`` so they do not become fake uncited sentence fragments.
split_text = re.sub(
r"([.!?])\s+((?:\[(?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*\]\s*)+)",
r" \2\1 ",
content,
)
protected = (
split_text.replace("vs.", "vs<prd>")
.replace("e.g.", "e<prd>g<prd>")
.replace("i.e.", "i<prd>e<prd>")
.replace("etc.", "etc<prd>")
)
segments = [
seg.strip().replace("<prd>", ".")
for seg in re.split(r"(?<=[.!?])\s+", protected)
if seg.strip()
]
for segment in segments:
segment_plain = re.sub(r"\[(?:D|W|T)\d+(?:\s*,\s*(?:D|W|T)\d+)*\]", "", segment)
segment_plain = re.sub(r"[`*_#>]", "", segment_plain).strip()
seg_words = re.findall(r"[A-Za-z0-9][A-Za-z0-9_.%$+-]*", segment_plain)
if len(segment_plain) >= 24 or len(seg_words) >= 5:
units.append(segment)
return units
def scalar_value_match(observed: Any, expected: Any) -> bool:
"""Compare tabular scalar values without relying on Markdown rendering.
DuckDB/pandas may expose booleans and numerics as numpy scalar types. The
benchmark should judge the computed value itself, not whether a rendered
table happened to spell a boolean as ``true``, ``True`` or ``1``.
"""
try:
if hasattr(observed, "item"):
observed = observed.item()
except Exception:
pass
if isinstance(expected, bool):
if isinstance(observed, bool):
return observed is expected
text = str(observed).strip().lower()
truthy = {"true", "1", "yes", "y", "t"}
falsy = {"false", "0", "no", "n", "f"}
return text in (truthy if expected else falsy)
if isinstance(expected, (int, float)) and not isinstance(expected, bool):
try:
return abs(float(observed) - float(expected)) <= 1e-9
except Exception:
return False
return str(observed).strip().casefold() == str(expected).strip().casefold()
def _unique_sources(values: list[str], k: int = 5) -> list[str]:
"""Return the first k distinct sources while preserving retrieval order.
RAG retrieval commonly returns several chunks from the same file. Source-level
metrics must not count the same relevant file multiple times, otherwise AP can
exceed 1.0 and source precision becomes difficult to interpret.
"""
out: list[str] = []
seen: set[str] = set()
for value in values[:k]:
if value not in seen:
out.append(value)
seen.add(value)
return out
def source_metrics(returned: list[str], relevant: list[str]) -> dict[str, float]:
relevant_set = set(relevant)
if not relevant_set:
return {
"source_precision@5": 1.0,
"source_recall@5": 1.0,
"source_hit@1": 1.0,
"source_mrr": 1.0,
"source_ap@5": 1.0,
"source_ndcg@5": 1.0,
"source_duplicate_rate@5": 0.0,
}
raw_top = returned[:5]
ranked = _unique_sources(returned, 5)
hits = [1 if source in relevant_set else 0 for source in ranked]
precision = safe_div(sum(hits), len(ranked))
recall = safe_div(len(set(ranked) & relevant_set), len(relevant_set))
hit_at_1 = float(bool(ranked and ranked[0] in relevant_set))
reciprocal_rank = 0.0
precisions_at_relevant: list[float] = []
relevant_seen = 0
for rank, hit in enumerate(hits, start=1):
if hit:
relevant_seen += 1
if reciprocal_rank == 0.0:
reciprocal_rank = 1.0 / rank
precisions_at_relevant.append(relevant_seen / rank)
# Average Precision divides by the number of relevant sources that could be
# retrieved within the cutoff, and each source contributes at most once.
ap = safe_div(sum(precisions_at_relevant), min(len(relevant_set), 5))
ap = max(0.0, min(1.0, ap))
dcg = sum(hit / math.log2(rank + 1) for rank, hit in enumerate(hits, start=1))
ideal_hits = min(len(relevant_set), 5)
idcg = sum(1.0 / math.log2(rank + 1) for rank in range(1, ideal_hits + 1))
ndcg = safe_div(dcg, idcg)
duplicate_rate = safe_div(len(raw_top) - len(set(raw_top)), len(raw_top)) if raw_top else 0.0
return {
"source_precision@5": max(0.0, min(1.0, precision)),
"source_recall@5": max(0.0, min(1.0, recall)),
"source_hit@1": hit_at_1,
"source_mrr": max(0.0, min(1.0, reciprocal_rank)),
"source_ap@5": ap,
"source_ndcg@5": max(0.0, min(1.0, ndcg)),
"source_duplicate_rate@5": max(0.0, min(1.0, duplicate_rate)),
}
def citation_metrics(answer: str, result_sources: list[dict[str, Any]]) -> dict[str, float | int]:
cited = extract_citation_ids(answer or "")
valid_ids = {str(source.get("id", "")) for source in result_sources}
valid = sum(1 for citation in cited if citation in valid_ids)
validity = safe_div(valid, len(cited)) if cited else 0.0
units = substantive_claim_units(answer or "")
cited_units = sum(1 for unit in units if extract_citation_ids(unit))
coverage = safe_div(cited_units, len(units)) if units else 0.0
return {
"citation_count": len(cited),
"citation_validity": validity,
"citation_coverage": coverage,
"substantive_units": len(units),
"cited_units": cited_units,
}