semantic-retrieval-api / app /calibration.py
Kind07's picture
Initial clean commit for web deployment
ed65693
Raw
History Blame Contribute Delete
12.7 kB
"""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,
}