from __future__ import annotations import argparse import json import os import re import string import sys import warnings from pathlib import Path from statistics import mean os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") os.environ.setdefault("OMP_NUM_THREADS", "1") os.environ.setdefault("MKL_NUM_THREADS", "1") warnings.filterwarnings( "ignore", message=r"The CrossEncoder\.predict `num_workers` argument is deprecated.*", ) PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) import torch from src.real_qa.pipeline import RealQAPipeline from src.real_qa.settings import BuildConfig def normalize_answer(text: str) -> str: text = str(text).lower().strip() text = re.sub(r"\b(a|an|the)\b", " ", text) text = "".join(ch for ch in text if ch not in string.punctuation) text = " ".join(text.split()) return text def token_f1(prediction: str, ground_truth: str) -> float: pred_tokens = normalize_answer(prediction).split() gold_tokens = normalize_answer(ground_truth).split() if not pred_tokens and not gold_tokens: return 1.0 if not pred_tokens or not gold_tokens: return 0.0 common = {} for token in pred_tokens: common[token] = min(pred_tokens.count(token), gold_tokens.count(token)) overlap = sum(common.values()) if overlap == 0: return 0.0 precision = overlap / len(pred_tokens) recall = overlap / len(gold_tokens) return 2 * precision * recall / (precision + recall) def best_exact_match(prediction: str, answers: list[str]) -> float: norm_pred = normalize_answer(prediction) return float(any(norm_pred == normalize_answer(answer) for answer in answers)) def best_f1(prediction: str, answers: list[str]) -> float: return max((token_f1(prediction, answer) for answer in answers), default=0.0) def required_term_coverage(prediction: str, required_terms: list[str]) -> float: if not required_terms: return 0.0 normalized_prediction = normalize_answer(prediction) covered = sum(1 for term in required_terms if normalize_answer(term) in normalized_prediction) return covered / len(required_terms) def load_eval_set(path: Path) -> list[dict]: return json.loads(path.read_text(encoding="utf-8")) def evaluate_example(pipeline: RealQAPipeline, item: dict, threshold: float) -> dict: question_type = item.get("question_type", "extractive") answer_style = item.get("answer_style", "auto") result = pipeline.answer(item["question"], threshold=threshold, style=answer_style) predicted_ids = [entry["chunk_id"] for entry in result.get("evidence", [])] predicted_urls = [entry.get("source_url", "") for entry in result.get("evidence", [])] gold_ids = set(item.get("gold_chunk_ids", [])) gold_urls = set(item.get("gold_source_urls", [])) predicted_answer = result.get("final_answer", "") answerable = bool(item.get("answerable", True)) example = { "id": item["id"], "question": item["question"], "answerable": answerable, "question_type": question_type, "prediction": predicted_answer, "answer_type": result.get("answer_type"), "confidence": float(result.get("confidence", 0.0)), "evidence": result.get("evidence", []), "support_sentences": result.get("support_sentences", []), "exact_match": 0.0, "f1": 0.0, "required_term_coverage": 0.0, "hit_at_1": 0.0, "hit_at_3": 0.0, "mrr": 0.0, "no_answer_correct": 0.0, } if gold_ids or gold_urls: def is_gold(rank_index: int) -> bool: chunk_match = rank_index < len(predicted_ids) and predicted_ids[rank_index] in gold_ids url_match = rank_index < len(predicted_urls) and predicted_urls[rank_index] in gold_urls return chunk_match or url_match example["hit_at_1"] = float(any(is_gold(rank_index) for rank_index in range(min(1, len(predicted_ids))))) example["hit_at_3"] = float(any(is_gold(rank_index) for rank_index in range(min(3, len(predicted_ids))))) reciprocal_ranks = [ 1.0 / rank for rank, (chunk_id, source_url) in enumerate(zip(predicted_ids, predicted_urls), start=1) if chunk_id in gold_ids or source_url in gold_urls ] example["mrr"] = reciprocal_ranks[0] if reciprocal_ranks else 0.0 else: example["hit_at_1"] = 1.0 if not predicted_ids else 0.0 example["hit_at_3"] = 1.0 if not predicted_ids else 0.0 example["mrr"] = 1.0 if not predicted_ids else 0.0 if answerable: answers = item.get("answers", []) example["exact_match"] = best_exact_match(predicted_answer, answers) example["f1"] = best_f1(predicted_answer, answers) example["required_term_coverage"] = required_term_coverage(predicted_answer, item.get("required_terms", [])) else: example["no_answer_correct"] = float(result.get("answer_type") == "no_answer") example["exact_match"] = example["no_answer_correct"] example["f1"] = example["no_answer_correct"] return example def summarize_examples(examples: list[dict], threshold: float) -> dict: answerable_examples = [item for item in examples if item["answerable"]] explanatory_examples = [item for item in answerable_examples if item.get("question_type") == "explanatory"] negative_examples = [item for item in examples if not item["answerable"]] summary = { "threshold": threshold, "example_count": len(examples), "answerable_count": len(answerable_examples), "explanatory_count": len(explanatory_examples), "negative_count": len(negative_examples), "qa_exact_match": mean(item["exact_match"] for item in answerable_examples) if answerable_examples else 0.0, "qa_f1": mean(item["f1"] for item in answerable_examples) if answerable_examples else 0.0, "explanatory_f1": mean(item["f1"] for item in explanatory_examples) if explanatory_examples else 0.0, "explanatory_term_coverage": mean(item["required_term_coverage"] for item in explanatory_examples) if explanatory_examples else 0.0, "retrieval_hit_at_1": mean(item["hit_at_1"] for item in answerable_examples) if answerable_examples else 0.0, "retrieval_hit_at_3": mean(item["hit_at_3"] for item in answerable_examples) if answerable_examples else 0.0, "retrieval_mrr": mean(item["mrr"] for item in answerable_examples) if answerable_examples else 0.0, "no_answer_accuracy": mean(item["no_answer_correct"] for item in negative_examples) if negative_examples else 0.0, "overall_score": mean(item["f1"] for item in examples) if examples else 0.0, } return summary def build_markdown_report(output: dict) -> str: summary = output["best_summary"] lines = [ "# Real QA Evaluation Report", "", "## Summary", "", f"- Selected threshold: `{output['selected_threshold']}`", f"- Best threshold by QA F1: `{output['best_threshold_by_qa_f1']}`", f"- Example count: `{summary['example_count']}`", f"- Answerable questions: `{summary['answerable_count']}`", f"- Explanatory questions: `{summary['explanatory_count']}`", f"- Negative questions: `{summary['negative_count']}`", f"- QA Exact Match: `{summary['qa_exact_match']:.4f}`", f"- QA F1: `{summary['qa_f1']:.4f}`", f"- Explanatory F1: `{summary['explanatory_f1']:.4f}`", f"- Explanatory term coverage: `{summary['explanatory_term_coverage']:.4f}`", f"- Retrieval Hit@1: `{summary['retrieval_hit_at_1']:.4f}`", f"- Retrieval Hit@3: `{summary['retrieval_hit_at_3']:.4f}`", f"- Retrieval MRR: `{summary['retrieval_mrr']:.4f}`", f"- No-answer accuracy: `{summary['no_answer_accuracy']:.4f}`", f"- Overall score: `{summary['overall_score']:.4f}`", "", "## Threshold Sweep", "", "| Threshold | QA EM | QA F1 | Expl. F1 | Expl. Coverage | Hit@1 | Hit@3 | MRR | No-answer |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", ] for item in output["all_summaries"]: lines.append( f"| `{item['threshold']}` | `{item['qa_exact_match']:.4f}` | `{item['qa_f1']:.4f}` | " f"`{item['explanatory_f1']:.4f}` | `{item['explanatory_term_coverage']:.4f}` | " f"`{item['retrieval_hit_at_1']:.4f}` | `{item['retrieval_hit_at_3']:.4f}` | " f"`{item['retrieval_mrr']:.4f}` | `{item['no_answer_accuracy']:.4f}` |" ) failed = [ item for item in output["examples"] if item["answerable"] and (item["f1"] < 1.0 or item["hit_at_1"] < 1.0) ] lines.extend(["", "## Representative Gaps", ""]) if not failed: lines.append("- No failed answerable examples in the current benchmark.") else: for item in failed[:5]: top_evidence = item["evidence"][0] if item["evidence"] else {} lines.extend([ f"### {item['id']}", f"- Question: {item['question']}", f"- Prediction: `{item['prediction']}`", f"- Question type: `{item['question_type']}`", f"- Exact Match: `{item['exact_match']:.4f}`", f"- F1: `{item['f1']:.4f}`", f"- Required term coverage: `{item['required_term_coverage']:.4f}`", f"- Hit@1: `{item['hit_at_1']:.4f}`", f"- Top evidence chunk: `{top_evidence.get('chunk_id', '')}`", f"- Top source: `{top_evidence.get('title', '')}`", f"- Top URL: {top_evidence.get('source_url', '')}", "", ]) return "\n".join(lines).strip() + "\n" def main() -> None: torch.set_num_threads(1) parser = argparse.ArgumentParser(description="Evaluate the real QA pipeline with QA and retrieval metrics.") parser.add_argument( "--eval-set", default=str(PROJECT_ROOT / "data" / "eval" / "real_qa_eval.json"), help="Path to the evaluation JSON file.", ) parser.add_argument( "--threshold", type=float, default=0.01, help="Reader confidence threshold used to decide answer vs no_answer.", ) parser.add_argument( "--sweep", action="store_true", help="Evaluate several thresholds and report the best threshold by QA F1.", ) args = parser.parse_args() cfg = BuildConfig(project_root=PROJECT_ROOT) pipeline = RealQAPipeline(cfg) eval_set = load_eval_set(Path(args.eval_set)) thresholds = [args.threshold] if args.sweep: thresholds = [0.0, 0.01, 0.02, 0.03, 0.05, 0.08, 0.1, 0.15, 0.2, 0.25] reports = [] for threshold in thresholds: examples = [evaluate_example(pipeline, item, threshold) for item in eval_set] summary = summarize_examples(examples, threshold) reports.append({"summary": summary, "examples": examples}) best_report = max(reports, key=lambda item: item["summary"]["qa_f1"]) output = { "selected_threshold": args.threshold, "best_threshold_by_qa_f1": best_report["summary"]["threshold"], "best_summary": best_report["summary"], "all_summaries": [report["summary"] for report in reports], "examples": best_report["examples"], } reports_dir = cfg.reports_dir reports_dir.mkdir(parents=True, exist_ok=True) report_path = reports_dir / "evaluation_report.json" markdown_report_path = reports_dir / "evaluation_report.md" report_path.write_text(json.dumps(output, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") markdown_report_path.write_text(build_markdown_report(output), encoding="utf-8") print(json.dumps(output["best_summary"], indent=2, ensure_ascii=False)) print(f"\nSaved evaluation report to: {report_path}") print(f"Saved markdown report to: {markdown_report_path}") if __name__ == "__main__": main()