""" Evaluation metrics for retrieval: - Recall@k - MRR (Mean Reciprocal Rank) - nDCG@k Evaluation metrics for answers: - Exact Match (EM) - Token-level F1 - BLEU-1 (lightweight) - ROUGE-L (lightweight) - Faithfulness (context overlap heuristic) - Citation accuracy (retrieval-gold id consistency) """ from __future__ import annotations import math import re from typing import Dict, List def _normalize_doc_id(doc_id: str) -> str: """Strip whitespace so JSONL ids match retrieved chunk source_ids reliably.""" return str(doc_id).strip() def _unique_preserve_order(ids: List[str]) -> List[str]: """Remove duplicates while preserving original ranking order.""" seen = set() out: List[str] = [] for x in ids: nx = _normalize_doc_id(x) if nx in seen: continue seen.add(nx) out.append(nx) return out def _gold_ids_from_sample(sample: Dict) -> List[str]: """ Accept ``relevant_source_ids``, ``gold_doc_ids``, or a single ``source_id`` (HF_* / KG_* corpus ids). """ if "relevant_source_ids" in sample: raw = sample["relevant_source_ids"] elif "gold_doc_ids" in sample: raw = sample["gold_doc_ids"] elif "source_id" in sample: raw = sample["source_id"] else: return [] if raw is None: return [] if isinstance(raw, str): return [_normalize_doc_id(raw)] if isinstance(raw, (list, tuple)): return [_normalize_doc_id(x) for x in raw] return [_normalize_doc_id(str(raw))] def gold_doc_ids_from_eval_item(sample: Dict) -> List[str]: """Public helper for demos: get gold document ids from a unified eval item.""" return _gold_ids_from_sample(sample) def recall_at_k(retrieved_source_ids: List[str], relevant_source_ids: List[str], k: int) -> float: """ Recall@k for a single query: Did we retrieve at least one relevant source in top-k? """ unique_ranked = _unique_preserve_order(retrieved_source_ids) top_k = unique_ranked[:k] rel = {_normalize_doc_id(x) for x in relevant_source_ids} hit = any(doc_id in rel for doc_id in top_k) return 1.0 if hit else 0.0 def reciprocal_rank(retrieved_source_ids: List[str], relevant_source_ids: List[str]) -> float: """ Reciprocal rank for a single query: 1 / rank of first relevant item, or 0 if none found. """ rel = {_normalize_doc_id(x) for x in relevant_source_ids} unique_ranked = _unique_preserve_order(retrieved_source_ids) for idx, doc_id in enumerate(unique_ranked, start=1): if _normalize_doc_id(doc_id) in rel: return 1.0 / idx return 0.0 def ndcg_at_k(retrieved_source_ids: List[str], relevant_source_ids: List[str], k: int = 10) -> float: """ Binary nDCG@k: - relevant source -> gain 1 - non-relevant source -> gain 0 """ if k <= 0: return 0.0 rel = {_normalize_doc_id(x) for x in relevant_source_ids} if not rel: return 0.0 top_k = _unique_preserve_order(retrieved_source_ids)[:k] dcg = 0.0 for i, doc_id in enumerate(top_k, start=1): gain = 1.0 if doc_id in rel else 0.0 if gain > 0: dcg += gain / math.log2(i + 1) ideal_hits = min(k, len(rel)) if ideal_hits == 0: return 0.0 idcg = 0.0 for i in range(1, ideal_hits + 1): idcg += 1.0 / math.log2(i + 1) if idcg == 0: return 0.0 # Numerical safety + bounded metric definition. return min(1.0, dcg / idcg) def evaluate_retrieval(qa_set: List[Dict], retrieve_fn, max_k: int = 10) -> Dict[str, float]: """ Evaluate a retrieval function over a QA set. qa_set item format: { "question": "...", "relevant_source_ids": ["HF_train_0", ...] # must match corpus record ``id`` / chunk ``source_id`` } (alias: ``gold_doc_ids``) """ recall5_scores: List[float] = [] recall10_scores: List[float] = [] rr_scores: List[float] = [] ndcg10_scores: List[float] = [] n_eval = 0 for sample in qa_set: question = sample["question"] relevant = _gold_ids_from_sample(sample) if not relevant: continue results = retrieve_fn(question, max_k) retrieved_ids = [r["source_id"] for r in results] recall5_scores.append(recall_at_k(retrieved_ids, relevant, k=5)) recall10_scores.append(recall_at_k(retrieved_ids, relevant, k=10)) rr_scores.append(reciprocal_rank(retrieved_ids, relevant)) ndcg10_scores.append(ndcg_at_k(retrieved_ids, relevant, k=10)) n_eval += 1 if n_eval == 0: return {"Recall@5": 0.0, "Recall@10": 0.0, "MRR": 0.0, "nDCG@10": 0.0} return { "Recall@5": sum(recall5_scores) / n_eval, "Recall@10": sum(recall10_scores) / n_eval, "MRR": sum(rr_scores) / n_eval, "nDCG@10": sum(ndcg10_scores) / n_eval, } def normalize_answer(text: str) -> str: """Lowercase + remove extra spaces and punctuation for answer comparison.""" text = text.lower().strip() text = re.sub(r"[^\w\s]", " ", text) text = re.sub(r"\s+", " ", text).strip() return text def exact_match_score(prediction: str, gold_answer: str) -> float: """Return 1.0 if normalized strings are identical, else 0.0.""" return 1.0 if normalize_answer(prediction) == normalize_answer(gold_answer) else 0.0 def token_f1_score(prediction: str, gold_answer: str) -> float: """ Token-level F1 for one prediction-gold pair. """ pred_tokens = normalize_answer(prediction).split() gold_tokens = normalize_answer(gold_answer).split() if not pred_tokens and not gold_tokens: return 1.0 if not pred_tokens or not gold_tokens: return 0.0 pred_counts = {} for tok in pred_tokens: pred_counts[tok] = pred_counts.get(tok, 0) + 1 gold_counts = {} for tok in gold_tokens: gold_counts[tok] = gold_counts.get(tok, 0) + 1 common = 0 for tok, count in pred_counts.items(): common += min(count, gold_counts.get(tok, 0)) if common == 0: return 0.0 precision = common / len(pred_tokens) recall = common / len(gold_tokens) return 2 * precision * recall / (precision + recall) def bleu1_score(prediction: str, gold_answer: str) -> float: """Lightweight BLEU-1 with brevity penalty.""" pred_tokens = normalize_answer(prediction).split() gold_tokens = normalize_answer(gold_answer).split() if not pred_tokens or not gold_tokens: return 0.0 pred_counts = {} for tok in pred_tokens: pred_counts[tok] = pred_counts.get(tok, 0) + 1 gold_counts = {} for tok in gold_tokens: gold_counts[tok] = gold_counts.get(tok, 0) + 1 overlap = 0 for tok, cnt in pred_counts.items(): overlap += min(cnt, gold_counts.get(tok, 0)) precision = overlap / len(pred_tokens) if precision <= 0: return 0.0 bp = 1.0 if len(pred_tokens) < len(gold_tokens): bp = math.exp(1.0 - (len(gold_tokens) / max(1, len(pred_tokens)))) return bp * precision def _lcs_len(a: List[str], b: List[str]) -> int: """Longest common subsequence length (token level).""" if not a or not b: return 0 dp = [0] * (len(b) + 1) for x in a: prev = 0 for j, y in enumerate(b, start=1): cur = dp[j] if x == y: dp[j] = prev + 1 else: dp[j] = max(dp[j], dp[j - 1]) prev = cur return dp[-1] def rouge_l_score(prediction: str, gold_answer: str) -> float: """Token-level ROUGE-L F-score.""" pred_tokens = normalize_answer(prediction).split() gold_tokens = normalize_answer(gold_answer).split() if not pred_tokens or not gold_tokens: return 0.0 lcs = _lcs_len(pred_tokens, gold_tokens) if lcs == 0: return 0.0 precision = lcs / len(pred_tokens) recall = lcs / len(gold_tokens) return 2 * precision * recall / (precision + recall) def faithfulness_score(prediction: str, contexts: List[str]) -> float: """ Heuristic faithfulness: ratio of unique answer tokens covered by concatenated contexts. """ pred_tokens = set(normalize_answer(prediction).split()) if not pred_tokens: return 0.0 ctx_tokens = set(normalize_answer(" ".join(contexts)).split()) if not ctx_tokens: return 0.0 covered = len(pred_tokens & ctx_tokens) return covered / max(1, len(pred_tokens)) def evaluate_answers(answer_set: List[Dict], answer_fn) -> Dict[str, float]: """ Evaluate generated answers on a small QA set. answer_set item format: { "question": "...", "gold_answer": "..." } Skips items with missing or empty ``gold_answer``. """ em_scores: List[float] = [] f1_scores: List[float] = [] bleu_scores: List[float] = [] rouge_scores: List[float] = [] n_eval = 0 for sample in answer_set: question = sample.get("question") gold = sample.get("gold_answer") if question is None or gold is None: continue gold = str(gold).strip() if not gold: continue try: pred = answer_fn(str(question).strip()) except Exception: pred = "" em_scores.append(exact_match_score(pred, gold)) f1_scores.append(token_f1_score(pred, gold)) bleu_scores.append(bleu1_score(pred, gold)) rouge_scores.append(rouge_l_score(pred, gold)) n_eval += 1 if n_eval == 0: return {"EM": 0.0, "TokenF1": 0.0, "BLEU1": 0.0, "ROUGE-L": 0.0} return { "EM": sum(em_scores) / n_eval, "TokenF1": sum(f1_scores) / n_eval, "BLEU1": sum(bleu_scores) / n_eval, "ROUGE-L": sum(rouge_scores) / n_eval, } def evaluate_groundedness(answer_set: List[Dict], answer_and_context_fn) -> Dict[str, float]: """ Evaluate source grounding on a QA set. ``answer_and_context_fn(question)`` should return: { "answer": str, "contexts": List[str], "retrieved_source_ids": List[str] } """ faith_scores: List[float] = [] cite_scores: List[float] = [] n_eval = 0 for sample in answer_set: question = sample.get("question") if not question: continue relevant = _gold_ids_from_sample(sample) try: out = answer_and_context_fn(str(question).strip()) except Exception: continue answer = str(out.get("answer", "")) contexts = list(out.get("contexts", [])) retrieved_ids = [_normalize_doc_id(x) for x in out.get("retrieved_source_ids", [])] faith_scores.append(faithfulness_score(answer, contexts)) if relevant: rel = {_normalize_doc_id(x) for x in relevant} hit = any(x in rel for x in retrieved_ids) cite_scores.append(1.0 if hit else 0.0) else: cite_scores.append(0.0) n_eval += 1 if n_eval == 0: return {"Faithfulness": 0.0, "CitationAccuracy": 0.0} return { "Faithfulness": sum(faith_scores) / n_eval, "CitationAccuracy": sum(cite_scores) / n_eval, }