Spaces:
Running
Running
| """Scientific evaluation for calibrated entropy-weighted hybrid retrieval. | |
| The evaluator supports both the original toy corpus and BEIR SciFact. When the | |
| current index metadata says ``dataset=scifact`` it evaluates the 300 SciFact | |
| test claims with BEIR qrels from ``data/scifact/qrels/test.tsv``. | |
| Usage: | |
| python scripts/build_index.py | |
| python scripts/evaluate.py | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import math | |
| import statistics | |
| import sys | |
| import time | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from app.calibration import ( # noqa: E402 | |
| CALIBRATION_METHODS, | |
| bootstrap_ci, | |
| clarity_score, | |
| compute_entropy, | |
| nqc, | |
| pearson_correlation, | |
| wig, | |
| ) | |
| from app.datasets import SCIFACT_DIR, load_scifact_golden_set, scifact_available # noqa: E402 | |
| from app.retriever import INDEX_DIR, RETRIEVAL_MODES, DocumentStore # noqa: E402 | |
| EVAL_FILE = Path("eval/golden_set.json") | |
| RESULTS_FILE = Path("eval/results.json") | |
| INDEX_METADATA_FILE = INDEX_DIR / "index_metadata.json" | |
| PRIMARY_K = 10 | |
| METRIC_KS = (3, 5) | |
| RETRIEVAL_DEPTH = 20 | |
| BOOTSTRAP_RESAMPLES = 1000 | |
| CALIBRATION_STYLES = ("raw", "minmax", "zscore", "cdf") | |
| H2_COMPARISONS = ( | |
| ("hybrid_calibrated", "rrf"), | |
| ("hybrid_calibrated", "hybrid_fixed"), | |
| ("hybrid_calibrated_rerank", "hybrid_fixed_rerank"), | |
| ) | |
| def load_index_metadata() -> dict[str, Any]: | |
| if not INDEX_METADATA_FILE.exists(): | |
| return {"dataset": "scifact" if scifact_available(SCIFACT_DIR) else "sample"} | |
| with open(INDEX_METADATA_FILE, encoding="utf-8") as f: | |
| return json.load(f) | |
| def load_golden_set(dataset: str) -> list[dict[str, Any]]: | |
| if dataset == "scifact": | |
| return load_scifact_golden_set(SCIFACT_DIR) | |
| with open(EVAL_FILE, encoding="utf-8") as f: | |
| return json.load(f) | |
| def percentile(values: list[float], pct: float) -> float: | |
| if not values: | |
| return 0.0 | |
| ordered = sorted(values) | |
| index = min(len(ordered) - 1, max(0, math.ceil((pct / 100) * len(ordered)) - 1)) | |
| return float(ordered[index]) | |
| def clean_float(value: float, digits: int = 6) -> float: | |
| if not math.isfinite(float(value)): | |
| return 0.0 | |
| return round(float(value), digits) | |
| def is_relevant_source(source: str, item: dict[str, Any]) -> bool: | |
| return str(source) in {str(s) for s in item.get("relevant_sources", [])} | |
| def is_relevant(result: dict[str, Any], item: dict[str, Any]) -> bool: | |
| source = str(result.get("source", "")) | |
| if is_relevant_source(source, item): | |
| return True | |
| keywords = [kw.lower() for kw in item.get("relevant_keywords", [])] | |
| text = f"{source} {result.get('text', '')}".lower() | |
| return any(keyword in text for keyword in keywords) | |
| def total_relevant_documents(item: dict[str, Any]) -> int: | |
| return max(1, len({str(s) for s in item.get("relevant_sources", [])})) | |
| def dedupe_results_by_source(results: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| """Keep the highest-ranked chunk for each source document.""" | |
| seen: set[str] = set() | |
| unique: list[dict[str, Any]] = [] | |
| for result in results: | |
| source = str(result.get("source", "")) | |
| if source in seen: | |
| continue | |
| seen.add(source) | |
| unique.append(result) | |
| return unique | |
| def metric_bundle( | |
| results: list[dict[str, Any]], | |
| item: dict[str, Any], | |
| total_relevant: int, | |
| ) -> dict[str, float]: | |
| unique_results = dedupe_results_by_source(results) | |
| flags = [1 if is_relevant(result, item) else 0 for result in unique_results[:PRIMARY_K]] | |
| metrics: dict[str, float] = {} | |
| for k in METRIC_KS: | |
| relevant_at_k = sum(flags[:k]) | |
| metrics[f"precision@{k}"] = relevant_at_k / k | |
| metrics[f"recall@{k}"] = relevant_at_k / total_relevant | |
| first_rank = next((rank for rank, flag in enumerate(flags, start=1) if flag), None) | |
| metrics["mrr"] = 1.0 / first_rank if first_rank else 0.0 | |
| dcg = sum(flag / math.log2(rank + 1) for rank, flag in enumerate(flags, start=1)) | |
| ideal_count = min(PRIMARY_K, total_relevant) | |
| idcg = sum(1.0 / math.log2(rank + 1) for rank in range(1, ideal_count + 1)) | |
| metrics[f"ndcg@{PRIMARY_K}"] = dcg / idcg if idcg else 0.0 | |
| return metrics | |
| def aggregate_mode(rows: list[dict[str, Any]]) -> dict[str, float]: | |
| metric_names = [ | |
| "precision@3", | |
| "precision@5", | |
| "recall@3", | |
| "recall@5", | |
| f"ndcg@{PRIMARY_K}", | |
| "mrr", | |
| ] | |
| summary: dict[str, float] = {} | |
| for name in metric_names: | |
| values = [row["metrics"][name] for row in rows] | |
| summary[name] = clean_float(statistics.mean(values), 4) | |
| summary[f"{name}_std"] = ( | |
| clean_float(statistics.stdev(values), 4) if len(values) > 1 else 0.0 | |
| ) | |
| latencies = [row["latency_ms"] for row in rows] | |
| summary.update( | |
| { | |
| "latency_mean_ms": clean_float(statistics.mean(latencies), 2), | |
| "latency_p50_ms": clean_float(statistics.median(latencies), 2), | |
| "latency_p95_ms": clean_float(percentile(latencies, 95), 2), | |
| "queries": len(rows), | |
| } | |
| ) | |
| return summary | |
| def calibrate_for_entropy( | |
| scores: np.ndarray, | |
| method: str, | |
| corpus_cdf: np.ndarray | None, | |
| ) -> np.ndarray: | |
| fn = CALIBRATION_METHODS[method] | |
| if method == "cdf": | |
| if corpus_cdf is None: | |
| return CALIBRATION_METHODS["minmax"](scores) | |
| return fn(scores, corpus_cdf=corpus_cdf) | |
| return fn(scores) | |
| def entropy_debug_rows( | |
| store: DocumentStore, | |
| golden_set: list[dict[str, Any]], | |
| cdf_bm25: np.ndarray | None, | |
| cdf_dense: np.ndarray | None, | |
| limit: int = 3, | |
| ) -> list[dict[str, Any]]: | |
| rows: list[dict[str, Any]] = [] | |
| for item in golden_set[:limit]: | |
| query = item["query"] | |
| sparse_scores = store.bm25_index.score_all(query) | |
| dense_scores = store._dense_score_all(query) | |
| rows.append( | |
| { | |
| "query_id": item.get("query_id"), | |
| "query": query, | |
| "top_raw_bm25_scores": [ | |
| clean_float(value, 4) for value in np.sort(sparse_scores)[::-1][:10].tolist() | |
| ], | |
| "sparse_entropy_by_calibration": { | |
| method: clean_float( | |
| compute_entropy(calibrate_for_entropy(sparse_scores, method, cdf_bm25)) | |
| ) | |
| for method in CALIBRATION_STYLES | |
| }, | |
| "dense_entropy_by_calibration": { | |
| method: clean_float( | |
| compute_entropy(calibrate_for_entropy(dense_scores, method, cdf_dense)) | |
| ) | |
| for method in CALIBRATION_STYLES | |
| }, | |
| } | |
| ) | |
| return rows | |
| def h1_entropy_analysis( | |
| store: DocumentStore, | |
| golden_set: list[dict[str, Any]], | |
| per_query_by_mode: dict[str, list[dict[str, Any]]], | |
| ) -> dict[str, Any]: | |
| cdf_bm25: np.ndarray | None = None | |
| cdf_dense: np.ndarray | None = None | |
| if store.corpus_cdfs is not None: | |
| cdf_bm25, cdf_dense = store.corpus_cdfs | |
| entropy_values: dict[str, dict[str, list[float]]] = { | |
| "sparse": {method: [] for method in CALIBRATION_STYLES}, | |
| "dense": {method: [] for method in CALIBRATION_STYLES}, | |
| } | |
| qpp_values: dict[str, list[float]] = { | |
| "clarity_sparse_top10": [], | |
| "nqc_sparse": [], | |
| "nqc_dense": [], | |
| "wig_sparse": [], | |
| "wig_dense": [], | |
| } | |
| for item in golden_set: | |
| query = item["query"] | |
| sparse_scores = store.bm25_index.score_all(query) | |
| dense_scores = store._dense_score_all(query) | |
| for method in CALIBRATION_STYLES: | |
| sparse_cal = calibrate_for_entropy(sparse_scores, method, cdf_bm25) | |
| dense_cal = calibrate_for_entropy(dense_scores, method, cdf_dense) | |
| entropy_values["sparse"][method].append(compute_entropy(sparse_cal)) | |
| entropy_values["dense"][method].append(compute_entropy(dense_cal)) | |
| sparse_top = store._search_sparse(query, top_k=PRIMARY_K) | |
| dense_top = store._search_dense(query, top_k=PRIMARY_K) | |
| sparse_top_scores = np.array([r["score"] for r in sparse_top], dtype=float) | |
| dense_top_scores = np.array([r["score"] for r in dense_top], dtype=float) | |
| sparse_mean = store.bm25_index.corpus_mean_score(query) | |
| dense_mean = float(np.mean(dense_scores)) if len(dense_scores) else 0.0 | |
| query_len = max(1, len(query.split())) | |
| if store.corpus_lm is not None: | |
| term_freqs, total_terms = store.corpus_lm | |
| qpp_values["clarity_sparse_top10"].append( | |
| clarity_score([r["text"] for r in sparse_top], term_freqs, total_terms) | |
| ) | |
| else: | |
| qpp_values["clarity_sparse_top10"].append(0.0) | |
| qpp_values["nqc_sparse"].append(nqc(sparse_top_scores, sparse_mean)) | |
| qpp_values["nqc_dense"].append(nqc(dense_top_scores, dense_mean)) | |
| qpp_values["wig_sparse"].append(wig(sparse_top_scores, sparse_mean, query_len)) | |
| qpp_values["wig_dense"].append(wig(dense_top_scores, dense_mean, query_len)) | |
| quality = { | |
| "sparse": { | |
| "mrr": [row["metrics"]["mrr"] for row in per_query_by_mode["sparse"]], | |
| f"ndcg@{PRIMARY_K}": [ | |
| row["metrics"][f"ndcg@{PRIMARY_K}"] for row in per_query_by_mode["sparse"] | |
| ], | |
| }, | |
| "dense": { | |
| "mrr": [row["metrics"]["mrr"] for row in per_query_by_mode["dense"]], | |
| f"ndcg@{PRIMARY_K}": [ | |
| row["metrics"][f"ndcg@{PRIMARY_K}"] for row in per_query_by_mode["dense"] | |
| ], | |
| }, | |
| } | |
| correlations: dict[str, Any] = {} | |
| for retriever in ("sparse", "dense"): | |
| correlations[retriever] = {} | |
| for method in CALIBRATION_STYLES: | |
| correlations[retriever][method] = {} | |
| for metric_name, quality_values in quality[retriever].items(): | |
| corr = pearson_correlation(entropy_values[retriever][method], quality_values) | |
| correlations[retriever][method][metric_name] = { | |
| "r": clean_float(corr["r"]), | |
| "p_value": clean_float(corr["p_value"]), | |
| "n": corr["n"], | |
| } | |
| qpp_correlations = { | |
| predictor: { | |
| f"hybrid_calibrated_{metric_name}": { | |
| "r": clean_float(corr["r"]), | |
| "p_value": clean_float(corr["p_value"]), | |
| "n": corr["n"], | |
| } | |
| for metric_name, values in { | |
| "mrr": [row["metrics"]["mrr"] for row in per_query_by_mode["hybrid_calibrated"]], | |
| f"ndcg@{PRIMARY_K}": [ | |
| row["metrics"][f"ndcg@{PRIMARY_K}"] | |
| for row in per_query_by_mode["hybrid_calibrated"] | |
| ], | |
| }.items() | |
| for corr in [pearson_correlation(predictor_values, values)] | |
| } | |
| for predictor, predictor_values in qpp_values.items() | |
| } | |
| verdict: dict[str, Any] = {} | |
| for retriever in ("sparse", "dense"): | |
| verdict[retriever] = {} | |
| for metric_name in ("mrr", f"ndcg@{PRIMARY_K}"): | |
| ranked = sorted( | |
| CALIBRATION_STYLES, | |
| key=lambda method: correlations[retriever][method][metric_name]["r"], | |
| ) | |
| verdict[retriever][metric_name] = { | |
| "best_negative_method": ranked[0], | |
| "cdf_is_best_negative": ranked[0] == "cdf", | |
| } | |
| return { | |
| "hypothesis": ( | |
| "CDF-calibrated entropy has stronger negative Pearson correlation " | |
| "with per-query retrieval quality than raw, min-max, or z-score entropy." | |
| ), | |
| "entropy_correlations": correlations, | |
| "entropy_debug": entropy_debug_rows(store, golden_set, cdf_bm25, cdf_dense), | |
| "qpp_baseline_correlations": qpp_correlations, | |
| "verdict": verdict, | |
| } | |
| def bootstrap_with_pvalue(values_a: list[float], values_b: list[float]) -> dict[str, float | bool]: | |
| ci = bootstrap_ci(values_a, values_b, n_resamples=BOOTSTRAP_RESAMPLES) | |
| diffs = np.array(values_a, dtype=float) - np.array(values_b, dtype=float) | |
| if len(diffs) == 0: | |
| p_value = 1.0 | |
| else: | |
| rng = np.random.default_rng(seed=42) | |
| samples = [] | |
| for _ in range(BOOTSTRAP_RESAMPLES): | |
| indices = rng.integers(0, len(diffs), size=len(diffs)) | |
| samples.append(float(np.mean(diffs[indices]))) | |
| samples_arr = np.array(samples) | |
| p_less_or_equal_zero = float(np.mean(samples_arr <= 0)) | |
| p_greater_or_equal_zero = float(np.mean(samples_arr >= 0)) | |
| p_value = min(1.0, 2 * min(p_less_or_equal_zero, p_greater_or_equal_zero)) | |
| return { | |
| "mean_diff": clean_float(ci["mean_diff"]), | |
| "ci_lower": clean_float(ci["ci_lower"]), | |
| "ci_upper": clean_float(ci["ci_upper"]), | |
| "p_value": clean_float(p_value), | |
| "significant_95": bool(ci["significant"]), | |
| } | |
| def h2_significance(per_query_by_mode: dict[str, list[dict[str, Any]]]) -> dict[str, Any]: | |
| metrics = (f"ndcg@{PRIMARY_K}", "precision@3", "precision@5", "mrr") | |
| results: dict[str, Any] = {} | |
| for mode_a, mode_b in H2_COMPARISONS: | |
| comparison_key = f"{mode_a}_vs_{mode_b}" | |
| results[comparison_key] = {} | |
| for metric_name in metrics: | |
| values_a = [row["metrics"][metric_name] for row in per_query_by_mode[mode_a]] | |
| values_b = [row["metrics"][metric_name] for row in per_query_by_mode[mode_b]] | |
| results[comparison_key][metric_name] = bootstrap_with_pvalue(values_a, values_b) | |
| return { | |
| "hypothesis": ( | |
| "CDF-calibrated entropy fusion improves aggregate metrics over " | |
| "RRF and fixed-alpha fusion baselines." | |
| ), | |
| "paired_bootstrap": results, | |
| "resamples": BOOTSTRAP_RESAMPLES, | |
| "confidence": 0.95, | |
| } | |
| def evaluate( | |
| store: DocumentStore, | |
| golden_set: list[dict[str, Any]], | |
| metadata: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| modes = list(RETRIEVAL_MODES) | |
| per_query_by_mode: dict[str, list[dict[str, Any]]] = {mode: [] for mode in modes} | |
| relevant_totals = {item["query"]: total_relevant_documents(item) for item in golden_set} | |
| for mode in modes: | |
| print(f" Evaluating mode: {mode}") | |
| for item in golden_set: | |
| query = item["query"] | |
| results, latency_ms, telemetry = store.search(query, top_k=RETRIEVAL_DEPTH, mode=mode) | |
| unique_results = dedupe_results_by_source(results) | |
| total_relevant = relevant_totals[query] | |
| metrics = metric_bundle(unique_results, item, total_relevant) | |
| per_query_by_mode[mode].append( | |
| { | |
| "query_id": item.get("query_id"), | |
| "query": query, | |
| "query_type": item.get("query_type", "unknown"), | |
| "metrics": {key: clean_float(value, 4) for key, value in metrics.items()}, | |
| "latency_ms": clean_float(latency_ms, 2), | |
| "retrieved": [ | |
| { | |
| "rank": rank, | |
| "source": result.get("source"), | |
| "chunk": result.get("chunk"), | |
| "score": result.get("score"), | |
| "relevant": is_relevant(result, item), | |
| } | |
| for rank, result in enumerate(unique_results[:PRIMARY_K], start=1) | |
| ], | |
| "expected_sources": item.get("relevant_sources", []), | |
| "total_relevant_documents": total_relevant, | |
| "telemetry": telemetry, | |
| } | |
| ) | |
| mode_summaries = {mode: aggregate_mode(rows) for mode, rows in per_query_by_mode.items()} | |
| return { | |
| "metadata": { | |
| **metadata, | |
| "generated_at_unix": int(time.time()), | |
| "golden_queries": len(golden_set), | |
| "retrieval_modes": modes, | |
| "primary_metric": f"ndcg@{PRIMARY_K}", | |
| "candidate_depth": RETRIEVAL_DEPTH, | |
| "relevance_rule": "document-level source match; keywords only for toy fallback labels", | |
| }, | |
| "aggregate_metrics": mode_summaries, | |
| "h1_distributional": h1_entropy_analysis(store, golden_set, per_query_by_mode), | |
| "h2_downstream": h2_significance(per_query_by_mode), | |
| "per_query": per_query_by_mode, | |
| } | |
| def assert_store_ready(store: DocumentStore) -> None: | |
| missing = [] | |
| if store.vector_store is None: | |
| missing.append("FAISS index") | |
| if store.bm25_index.bm25 is None: | |
| missing.append("BM25 index") | |
| if store.corpus_cdfs is None: | |
| missing.append("corpus CDFs") | |
| if missing: | |
| joined = ", ".join(missing) | |
| raise RuntimeError(f"Missing {joined}. Run 'python scripts/build_index.py' first.") | |
| def print_summary(results: dict[str, Any]) -> None: | |
| print("\n" + "=" * 88) | |
| print("RETRIEVAL ABLATION SUMMARY") | |
| print("=" * 88) | |
| header = ( | |
| f"{'mode':30s} {'NDCG@10':>8s} {'std':>7s} {'MRR':>7s} " | |
| f"{'P@3':>7s} {'P@5':>7s} {'p95 ms':>8s}" | |
| ) | |
| print(header) | |
| print("-" * len(header)) | |
| for mode, metrics in results["aggregate_metrics"].items(): | |
| print( | |
| f"{mode:30s} " | |
| f"{metrics[f'ndcg@{PRIMARY_K}']:8.4f} " | |
| f"{metrics[f'ndcg@{PRIMARY_K}_std']:7.4f} " | |
| f"{metrics['mrr']:7.4f} " | |
| f"{metrics['precision@3']:7.4f} " | |
| f"{metrics['precision@5']:7.4f} " | |
| f"{metrics['latency_p95_ms']:8.2f}" | |
| ) | |
| print("\nH1 sparse entropy debug (first 3 queries):") | |
| for row in results["h1_distributional"]["entropy_debug"]: | |
| print(f" {row.get('query_id') or '-'}: {row['query'][:90]}") | |
| print(f" top BM25: {row['top_raw_bm25_scores'][:5]}") | |
| print(f" sparse H: {row['sparse_entropy_by_calibration']}") | |
| print("\nH1 best negative entropy correlations:") | |
| verdict = results["h1_distributional"]["verdict"] | |
| for retriever, metrics in verdict.items(): | |
| for metric_name, data in metrics.items(): | |
| print( | |
| f" {retriever:6s} {metric_name:7s}: " | |
| f"{data['best_negative_method']} " | |
| f"(cdf_best={data['cdf_is_best_negative']})" | |
| ) | |
| print("\nH2 paired bootstrap NDCG@10:") | |
| comparisons = results["h2_downstream"]["paired_bootstrap"] | |
| for name, metrics in comparisons.items(): | |
| ndcg = metrics[f"ndcg@{PRIMARY_K}"] | |
| print( | |
| f" {name}: diff={ndcg['mean_diff']:.4f}, " | |
| f"95% CI [{ndcg['ci_lower']:.4f}, {ndcg['ci_upper']:.4f}], " | |
| f"p={ndcg['p_value']:.4f}" | |
| ) | |
| def main() -> None: | |
| print("Loading index metadata...") | |
| metadata = load_index_metadata() | |
| dataset = metadata.get("dataset", "sample") | |
| print(f" Dataset: {dataset}") | |
| print("\nLoading evaluation set...") | |
| golden_set = load_golden_set(dataset) | |
| print(f" {len(golden_set)} queries loaded") | |
| print("\nLoading retrieval artifacts...") | |
| store = DocumentStore() | |
| store.load() | |
| try: | |
| assert_store_ready(store) | |
| except RuntimeError as exc: | |
| print(f"ERROR: {exc}") | |
| sys.exit(1) | |
| print(" FAISS, BM25, CDF, corpus LM, and lookup artifacts loaded") | |
| print("\nRunning evaluation...") | |
| results = evaluate(store, golden_set, metadata) | |
| print_summary(results) | |
| RESULTS_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| with open(RESULTS_FILE, "w", encoding="utf-8") as f: | |
| json.dump(results, f, indent=2, ensure_ascii=False) | |
| print(f"\nFull results saved to {RESULTS_FILE}") | |
| if __name__ == "__main__": | |
| main() | |