Spaces:
Sleeping
Sleeping
| """ | |
| src/evaluation/metrics.py | |
| -------------------------- | |
| Evaluation metrics for the Scientific RAG project. | |
| Retrieval: Recall@K, Precision@K, MRR, Hit Rate | |
| QA: Exact Match, F1, Groundedness | |
| Hallucination: hallucination rate, refusal accuracy | |
| """ | |
| import re | |
| import math | |
| from typing import Optional | |
| # =========================================================================== | |
| # Retrieval Metrics | |
| # =========================================================================== | |
| def recall_at_k(retrieved_ids: list, relevant_ids: list, k: int) -> float: | |
| """Recall@K: fraction of relevant docs found in top-k.""" | |
| if not relevant_ids: | |
| return 0.0 | |
| top_k = set(retrieved_ids[:k]) | |
| return len(top_k & set(relevant_ids)) / len(relevant_ids) | |
| def precision_at_k(retrieved_ids: list, relevant_ids: list, k: int) -> float: | |
| """Precision@K: fraction of top-k that are relevant.""" | |
| if k == 0: | |
| return 0.0 | |
| top_k = retrieved_ids[:k] | |
| relevant_set = set(relevant_ids) | |
| return sum(1 for d in top_k if d in relevant_set) / k | |
| def mrr(retrieved_ids: list, relevant_ids: list) -> float: | |
| """Mean Reciprocal Rank: 1/rank of first relevant doc.""" | |
| relevant_set = set(relevant_ids) | |
| for rank, doc_id in enumerate(retrieved_ids, start=1): | |
| if doc_id in relevant_set: | |
| return 1.0 / rank | |
| return 0.0 | |
| def hit_rate(retrieved_ids: list, relevant_ids: list, k: int) -> float: | |
| """Hit Rate@K: 1 if any relevant doc is in top-k, else 0.""" | |
| relevant_set = set(relevant_ids) | |
| return float(any(d in relevant_set for d in retrieved_ids[:k])) | |
| def compute_retrieval_metrics( | |
| retrieved_ids: list, | |
| relevant_ids: list, | |
| k_values: list[int] = (1, 3, 5, 10), | |
| ) -> dict: | |
| """Compute all retrieval metrics for one query.""" | |
| results = {} | |
| for k in k_values: | |
| results[f"recall@{k}"] = round(recall_at_k(retrieved_ids, relevant_ids, k), 4) | |
| results[f"precision@{k}"] = round(precision_at_k(retrieved_ids, relevant_ids, k), 4) | |
| results[f"hit_rate@{k}"] = round(hit_rate(retrieved_ids, relevant_ids, k), 4) | |
| results["mrr"] = round(mrr(retrieved_ids, relevant_ids), 4) | |
| return results | |
| def aggregate_retrieval_metrics(per_query_metrics: list[dict]) -> dict: | |
| """Average retrieval metrics across all queries.""" | |
| if not per_query_metrics: | |
| return {} | |
| keys = per_query_metrics[0].keys() | |
| return { | |
| k: round(sum(m[k] for m in per_query_metrics) / len(per_query_metrics), 4) | |
| for k in keys | |
| } | |
| # =========================================================================== | |
| # QA Metrics (token-level, following SQuAD convention) | |
| # =========================================================================== | |
| def _normalize(text: str) -> str: | |
| """Lowercase, strip punctuation, collapse whitespace.""" | |
| text = text.lower() | |
| text = re.sub(r"[^\w\s]", "", text) | |
| text = re.sub(r"\s+", " ", text).strip() | |
| return text | |
| def _tokenize(text: str) -> list[str]: | |
| return _normalize(text).split() | |
| def exact_match(prediction: str, ground_truth: str) -> float: | |
| return float(_normalize(prediction) == _normalize(ground_truth)) | |
| def token_f1(prediction: str, ground_truth: str) -> float: | |
| pred_tokens = _tokenize(prediction) | |
| truth_tokens = _tokenize(ground_truth) | |
| if not pred_tokens or not truth_tokens: | |
| return float(pred_tokens == truth_tokens) | |
| common = set(pred_tokens) & set(truth_tokens) | |
| if not common: | |
| return 0.0 | |
| precision = len(common) / len(pred_tokens) | |
| recall = len(common) / len(truth_tokens) | |
| return 2 * precision * recall / (precision + recall) | |
| def groundedness_score(answer: str, context_chunks: list[str]) -> float: | |
| """ | |
| Heuristic groundedness: fraction of answer bigrams that appear in context. | |
| A proper implementation would use an NLI model (e.g. cross-encoder/nli-deberta-v3). | |
| """ | |
| def bigrams(text: str) -> set: | |
| tokens = _tokenize(text) | |
| return set(zip(tokens[:-1], tokens[1:])) | |
| answer_bigrams = bigrams(answer) | |
| if not answer_bigrams: | |
| return 0.0 | |
| context = " ".join(context_chunks) | |
| context_bigrams = bigrams(context) | |
| overlap = answer_bigrams & context_bigrams | |
| return round(len(overlap) / len(answer_bigrams), 4) | |
| def citation_accuracy(answer: str, source_titles: list[str]) -> float: | |
| """ | |
| Fraction of cited paper titles (or substrings) that appear in the sources list. | |
| Looks for patterns like [Title, Year] or mentions of paper titles. | |
| """ | |
| if not source_titles: | |
| return 0.0 | |
| found = 0 | |
| for title in source_titles: | |
| # Check for partial title match (first 5 words) | |
| key = " ".join(title.lower().split()[:5]) | |
| if key in answer.lower(): | |
| found += 1 | |
| return round(found / len(source_titles), 4) | |
| # =========================================================================== | |
| # Hallucination Metrics | |
| # =========================================================================== | |
| REFUSAL_PHRASES = [ | |
| "i cannot answer this question from the available scientific corpus", | |
| "je ne sais pas a partir des documents fournis", | |
| "i don't have enough information", | |
| "not found in the corpus", | |
| "the context does not contain", | |
| ] | |
| def is_refusal(answer: str) -> bool: | |
| """Check if the model correctly refused an out-of-corpus question.""" | |
| norm = _normalize(answer) | |
| return any(phrase in norm for phrase in REFUSAL_PHRASES) | |
| def hallucination_rate( | |
| results: list[dict], | |
| context_chunks_key: str = "retrieved", | |
| answer_key: str = "answer", | |
| threshold: float = 0.2, | |
| ) -> float: | |
| """ | |
| Estimate hallucination rate as fraction of answers with groundedness < threshold. | |
| """ | |
| if not results: | |
| return 0.0 | |
| hallucinated = sum( | |
| 1 for r in results | |
| if groundedness_score(r[answer_key], r[context_chunks_key]) < threshold | |
| ) | |
| return round(hallucinated / len(results), 4) | |
| def refusal_accuracy( | |
| out_of_corpus_results: list[dict], | |
| answer_key: str = "answer", | |
| ) -> float: | |
| """ | |
| Fraction of out-of-corpus questions where the model correctly refused. | |
| """ | |
| if not out_of_corpus_results: | |
| return 0.0 | |
| correct = sum(1 for r in out_of_corpus_results if is_refusal(r[answer_key])) | |
| return round(correct / len(out_of_corpus_results), 4) | |
| # =========================================================================== | |
| # Full Evaluation Runner | |
| # =========================================================================== | |
| def evaluate_qa_result( | |
| prediction: str, | |
| ground_truth: str, | |
| context_chunks: list[str], | |
| source_titles: list[str] = None, | |
| ) -> dict: | |
| """Compute all QA metrics for one (prediction, ground_truth) pair.""" | |
| return { | |
| "exact_match": exact_match(prediction, ground_truth), | |
| "token_f1": round(token_f1(prediction, ground_truth), 4), | |
| "groundedness": groundedness_score(prediction, context_chunks), | |
| "citation_accuracy": citation_accuracy(prediction, source_titles or []), | |
| "is_refusal": is_refusal(prediction), | |
| } | |
| def print_retrieval_report(agg: dict, label: str = ""): | |
| print(f"\n{'='*50}") | |
| print(f"Retrieval Metrics {label}") | |
| print(f"{'='*50}") | |
| for k, v in sorted(agg.items()): | |
| print(f" {k:<20} {v:.4f}") | |
| def print_qa_report(results: list[dict], label: str = ""): | |
| print(f"\n{'='*50}") | |
| print(f"QA Metrics {label}") | |
| print(f"{'='*50}") | |
| if not results: | |
| print(" No results.") | |
| return | |
| keys = ["exact_match", "token_f1", "groundedness", "citation_accuracy"] | |
| for k in keys: | |
| vals = [r.get(k, 0) for r in results] | |
| avg = sum(vals) / len(vals) | |
| print(f" {k:<25} {avg:.4f}") | |