"""Score calibration, entropy computation, and QPP predictors. This is the research module. It addresses the cross-retriever score calibration problem: BM25 produces unbounded term-frequency scores while dense retrievers produce bounded similarity scores. Comparing entropy across these distributions is meaningless without calibration to a common probability space. Corpus-level CDFs are built offline during indexing (not per-query top-k, which would normalize away the distributional signal by construction). QPP baselines (Clarity Score, NQC, WIG) are included for head-to-head comparison against calibrated entropy as a retrieval quality predictor. """ import math import re from collections import Counter from pathlib import Path from typing import Any import numpy as np from scipy import stats # --------------------------------------------------------------------------- # Score Calibration Methods # --------------------------------------------------------------------------- def calibrate_raw(scores: np.ndarray, **_: Any) -> np.ndarray: """Identity calibration (baseline). No transformation.""" return np.asarray(scores, dtype=float) def calibrate_minmax(scores: np.ndarray, **_: Any) -> np.ndarray: """Min-max normalization to [0, 1].""" scores = np.asarray(scores, dtype=float) if len(scores) == 0: return scores min_s, max_s = scores.min(), scores.max() if max_s - min_s < 1e-10: return np.full_like(scores, 0.5) return (scores - min_s) / (max_s - min_s) def calibrate_zscore(scores: np.ndarray, **_: Any) -> np.ndarray: """Z-score normalization. Returns standardized scores (mean=0, std=1).""" scores = np.asarray(scores, dtype=float) if len(scores) == 0: return scores std_s = scores.std() if std_s < 1e-10: return np.zeros_like(scores) return (scores - scores.mean()) / std_s def calibrate_cdf(scores: np.ndarray, corpus_cdf: np.ndarray) -> np.ndarray: """CDF quantile normalization using a corpus-level CDF. For each score, returns its percentile rank in the corpus distribution. The corpus_cdf is a sorted array of scores built offline during indexing. This maps any distribution to approximately uniform [0,1], but crucially the mapping is FIXED (corpus-level), so per-query variation is preserved. """ scores = np.asarray(scores, dtype=float) if len(scores) == 0 or len(corpus_cdf) == 0: return scores calibrated = np.searchsorted(corpus_cdf, scores) / len(corpus_cdf) return calibrated CALIBRATION_METHODS = { "raw": calibrate_raw, "minmax": calibrate_minmax, "zscore": calibrate_zscore, "cdf": calibrate_cdf, } # --------------------------------------------------------------------------- # Entropy Computation # --------------------------------------------------------------------------- def compute_entropy(scores: np.ndarray, epsilon: float = 1e-10) -> float: """Shannon entropy of a score distribution. Normalizes scores to a probability distribution, then computes H = -sum(p * log2(p)). Edge cases: - Empty or single-element: returns 0.0 - All equal: returns log2(n) (maximum entropy) - Negative values: uses softmax, which preserves z-score shape without subtracting away the affine transform being tested. """ scores = np.asarray(scores, dtype=float) if len(scores) <= 1: return 0.0 if np.allclose(scores, scores[0]): return math.log2(len(scores)) if np.any(scores < 0): shifted = scores - np.max(scores) exp_scores = np.exp(np.clip(shifted, -745, 0)) total = exp_scores.sum() if total < epsilon: return 0.0 probs = exp_scores / total else: adjusted = scores + epsilon total = adjusted.sum() if total < epsilon: return math.log2(len(scores)) probs = adjusted / total # Shannon entropy (base 2) entropy = -float(np.sum(probs * np.log2(probs + epsilon))) return entropy def compute_alpha(h_dense: float, h_sparse: float, epsilon: float = 1e-8) -> float: """Compute entropy-weighted fusion weight. α = H_dense / (H_dense + H_sparse + ε) Bayesian precision-weighted mixing interpretation: If each retriever produces a likelihood estimate P(rel|d,q), the entropy of its calibrated score distribution approximates the inverse precision of that estimate. Weighting by inverse entropy is proportional to weighting by the precision (inverse variance) of each retriever's likelihood — a standard precision-weighted Bayesian combination. When dense has high entropy (low confidence), α is large → more weight on BM25. When sparse has high entropy (low confidence), α is small → more weight on dense. """ return h_dense / (h_dense + h_sparse + epsilon) # --------------------------------------------------------------------------- # Corpus-Level CDF Construction (Offline) # --------------------------------------------------------------------------- def build_corpus_cdfs( bm25_score_fn: Any, dense_score_fn: Any, sample_queries: list[str], ) -> tuple[np.ndarray, np.ndarray]: """Build corpus-level CDFs for both retrievers. For each sample query, scores ALL documents with both retrievers. Pools all scores into sorted arrays (one per retriever). Args: bm25_score_fn: callable(query) → np.ndarray of BM25 scores for all docs dense_score_fn: callable(query) → np.ndarray of dense scores for all docs sample_queries: list of representative queries (golden set + pseudo-queries) Returns: (cdf_bm25, cdf_dense): sorted score arrays for CDF lookup """ all_bm25_scores: list[float] = [] all_dense_scores: list[float] = [] for query in sample_queries: bm25_scores = bm25_score_fn(query) dense_scores = dense_score_fn(query) if len(bm25_scores) > 0: all_bm25_scores.extend(bm25_scores.tolist()) if len(dense_scores) > 0: all_dense_scores.extend(dense_scores.tolist()) cdf_bm25 = np.sort(np.array(all_bm25_scores)) cdf_dense = np.sort(np.array(all_dense_scores)) return cdf_bm25, cdf_dense def save_corpus_cdfs(cdf_bm25: np.ndarray, cdf_dense: np.ndarray, directory: Path) -> None: """Save corpus-level CDFs to disk as .npy files.""" directory.mkdir(parents=True, exist_ok=True) np.save(directory / "corpus_cdf_bm25.npy", cdf_bm25) np.save(directory / "corpus_cdf_dense.npy", cdf_dense) def load_corpus_cdfs(directory: Path) -> tuple[np.ndarray, np.ndarray] | None: """Load corpus-level CDFs from disk. Returns None if not found.""" bm25_path = directory / "corpus_cdf_bm25.npy" dense_path = directory / "corpus_cdf_dense.npy" if not bm25_path.exists() or not dense_path.exists(): return None return np.load(bm25_path), np.load(dense_path) # --------------------------------------------------------------------------- # QPP Baseline Predictors # --------------------------------------------------------------------------- def clarity_score( top_k_texts: list[str], corpus_term_freqs: Counter, corpus_total_terms: int, ) -> float: """Clarity Score: KL divergence of top-k document LM vs corpus LM. Cronen-Townsend, Zhou, Croft (2002). "Predicting Query Performance." KL(P(w|θ_D@k) || P(w|θ_C)) = Σ P(w|θ_D@k) * log2(P(w|θ_D@k) / P(w|θ_C)) """ # Build top-k language model top_k_freqs: Counter = Counter() top_k_total = 0 for text in top_k_texts: tokens = re.sub(r"[^\w\s]", " ", text.lower()).split() top_k_freqs.update(tokens) top_k_total += len(tokens) if top_k_total == 0 or corpus_total_terms == 0: return 0.0 # KL divergence (with Laplace smoothing for corpus model) kl = 0.0 for word, count in top_k_freqs.items(): p_w_topk = count / top_k_total # +1 Laplace smoothing to avoid division by zero p_w_corpus = (corpus_term_freqs.get(word, 0) + 1) / ( corpus_total_terms + len(corpus_term_freqs) ) if p_w_topk > 0 and p_w_corpus > 0: kl += p_w_topk * math.log2(p_w_topk / p_w_corpus) return kl def nqc(top_k_scores: np.ndarray, corpus_mean_score: float) -> float: """Normalized Query Commitment. Shtok, Kurland, Carmel, Raiber, Markovits (2012). "Using Statistical Decision Theory and Relevance Models for Query-Performance Prediction." NQC = std(top-k scores) / |s(q, C)| The denominator s(q, C) is the retrieval score of a virtual corpus-level document. We use the mean retrieval score across all documents as a standard practical approximation. """ scores = np.asarray(top_k_scores, dtype=float) if len(scores) == 0 or abs(corpus_mean_score) < 1e-10: return 0.0 return float(np.std(scores) / abs(corpus_mean_score)) def wig(top_k_scores: np.ndarray, corpus_mean_score: float, query_len: int) -> float: """Weighted Information Gain. Zhou and Croft (2007). "Query Performance Prediction in Web Search Environments." WIG = mean(top-k scores - corpus_mean) / sqrt(|q|) """ scores = np.asarray(top_k_scores, dtype=float) if len(scores) == 0 or query_len <= 0: return 0.0 return float(np.mean(scores - corpus_mean_score) / math.sqrt(query_len)) # --------------------------------------------------------------------------- # Corpus Language Model (for Clarity Score) # --------------------------------------------------------------------------- def build_corpus_language_model( all_texts: list[str], ) -> tuple[Counter, int]: """Build corpus-level language model (unigram term frequencies). Returns (term_freqs, total_terms). """ freqs: Counter = Counter() total = 0 for text in all_texts: tokens = re.sub(r"[^\w\s]", " ", text.lower()).split() freqs.update(tokens) total += len(tokens) return freqs, total def save_corpus_lm(term_freqs: Counter, total_terms: int, path: Path) -> None: """Save corpus language model to disk.""" import pickle path.parent.mkdir(parents=True, exist_ok=True) with open(path, "wb") as f: pickle.dump({"term_freqs": term_freqs, "total_terms": total_terms}, f) def load_corpus_lm(path: Path) -> tuple[Counter, int] | None: """Load corpus language model from disk.""" import pickle if not path.exists(): return None with open(path, "rb") as f: data = pickle.load(f) # noqa: S301 return data["term_freqs"], data["total_terms"] # --------------------------------------------------------------------------- # Statistical Analysis # --------------------------------------------------------------------------- def pearson_correlation( predictor_values: list[float], quality_values: list[float] ) -> dict[str, float]: """Compute Pearson correlation with p-value. Returns dict with 'r', 'p_value', 'n'. """ n = len(predictor_values) if n < 3: return {"r": 0.0, "p_value": 1.0, "n": n} if np.allclose(predictor_values, predictor_values[0]) or np.allclose( quality_values, quality_values[0] ): return {"r": 0.0, "p_value": 1.0, "n": n} r, p = stats.pearsonr(predictor_values, quality_values) if np.isnan(r) or np.isnan(p): return {"r": 0.0, "p_value": 1.0, "n": n} return {"r": float(r), "p_value": float(p), "n": n} def bootstrap_ci( values_a: list[float], values_b: list[float], n_resamples: int = 1000, confidence: float = 0.95, ) -> dict[str, float]: """Bootstrap 95% confidence interval for the difference in means. Used for paired significance testing in H2 ablation comparisons. """ values_a = np.array(values_a) values_b = np.array(values_b) n = len(values_a) if n == 0: return {"mean_diff": 0.0, "ci_lower": 0.0, "ci_upper": 0.0, "significant": False} diffs = [] rng = np.random.default_rng(seed=42) for _ in range(n_resamples): indices = rng.integers(0, n, size=n) diff = np.mean(values_a[indices]) - np.mean(values_b[indices]) diffs.append(diff) diffs = np.sort(diffs) alpha = 1 - confidence ci_lower = float(np.percentile(diffs, 100 * alpha / 2)) ci_upper = float(np.percentile(diffs, 100 * (1 - alpha / 2))) mean_diff = float(np.mean(values_a) - np.mean(values_b)) return { "mean_diff": mean_diff, "ci_lower": ci_lower, "ci_upper": ci_upper, "significant": ci_lower > 0 or ci_upper < 0, }