Spaces:
Sleeping
Sleeping
File size: 6,490 Bytes
c745c50 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | """Embedding quality metrics: cosine drift, rank agreement, and IR retrieval.
Used by scripts/eval_quality.py and unit-tested in tests/test_quality_metrics.py.
No model or dataset dependency so the math can run in CI.
"""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
import numpy as np
Qrels = Mapping[str, Mapping[str, int | float]]
def l2_normalize(vectors: np.ndarray, eps: float = 1e-12) -> np.ndarray:
"""Row-wise L2 normalize. Zero rows stay zero (divided by eps)."""
arr = np.asarray(vectors, dtype=np.float64)
if arr.ndim == 1:
arr = arr.reshape(1, -1)
norms = np.linalg.norm(arr, axis=1, keepdims=True)
return arr / np.maximum(norms, eps)
def pairwise_cosine(left: np.ndarray, right: np.ndarray) -> np.ndarray:
"""Cosine similarity of matching rows. left[i] vs right[i]."""
a = l2_normalize(left)
b = l2_normalize(right)
if a.shape != b.shape:
raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}")
return np.sum(a * b, axis=1)
def cosine_matrix(queries: np.ndarray, documents: np.ndarray) -> np.ndarray:
"""Dense query-document cosine matrix, shape (n_queries, n_docs)."""
return l2_normalize(queries) @ l2_normalize(documents).T
def angular_error_deg(cosine: np.ndarray) -> np.ndarray:
"""Angle in degrees between two unit vectors given their cosine."""
return np.degrees(np.arccos(np.clip(np.asarray(cosine, dtype=np.float64), -1.0, 1.0)))
def summarize_values(values: np.ndarray | Sequence[float]) -> dict[str, float]:
arr = np.asarray(values, dtype=np.float64).reshape(-1)
if arr.size == 0:
return {"count": 0.0, "mean": 0.0, "p50": 0.0, "p05": 0.0, "min": 0.0, "max": 0.0}
return {
"count": float(arr.size),
"mean": float(arr.mean()),
"p50": float(np.quantile(arr, 0.50)),
"p05": float(np.quantile(arr, 0.05)),
"min": float(arr.min()),
"max": float(arr.max()),
}
def cosine_drift_report(reference: np.ndarray, candidate: np.ndarray) -> dict[str, float]:
"""Per-row cosine and angular error of candidate embeddings vs a reference."""
cosine = pairwise_cosine(reference, candidate)
angle = angular_error_deg(cosine)
out = {f"cosine_{k}": v for k, v in summarize_values(cosine).items()}
out.update({f"angle_deg_{k}": v for k, v in summarize_values(angle).items()})
return out
def ranked_doc_ids(scores: np.ndarray, doc_ids: Sequence[str]) -> list[list[str]]:
"""For each query row, document ids sorted by descending score."""
if scores.ndim != 2:
raise ValueError(f"scores must be 2-d, got {scores.shape}")
if scores.shape[1] != len(doc_ids):
raise ValueError(f"doc count {len(doc_ids)} != scores columns {scores.shape[1]}")
order = np.argsort(-scores, axis=1, kind="stable")
ids = list(doc_ids)
return [[ids[j] for j in row] for row in order]
def dcg_at_k(gains: Sequence[float], k: int) -> float:
"""TREC/BEIR DCG: sum_i (2^{rel_i} - 1) / log2(i + 1), 1-based ranks."""
if k <= 0:
raise ValueError("k must be positive")
total = 0.0
for rank, rel in enumerate(list(gains)[:k], start=1):
total += (2.0 ** float(rel) - 1.0) / math.log2(rank + 1)
return total
def ndcg_at_k(ranked_ids: Sequence[str], qrel: Mapping[str, int | float], k: int) -> float:
"""nDCG@k for one query. Queries with no relevant docs score 0."""
gains = [float(qrel.get(doc_id, 0)) for doc_id in ranked_ids[:k]]
dcg = dcg_at_k(gains, k)
ideal = sorted((float(rel) for rel in qrel.values() if rel > 0), reverse=True)
idcg = dcg_at_k(ideal, k)
if idcg == 0.0:
return 0.0
return dcg / idcg
def recall_at_k(ranked_ids: Sequence[str], qrel: Mapping[str, int | float], k: int) -> float:
"""Fraction of relevant documents retrieved in the top k."""
relevant = {doc_id for doc_id, rel in qrel.items() if rel > 0}
if not relevant:
return 0.0
hit = sum(1 for doc_id in ranked_ids[:k] if doc_id in relevant)
return hit / len(relevant)
def mrr_at_k(ranked_ids: Sequence[str], qrel: Mapping[str, int | float], k: int) -> float:
"""Reciprocal rank of the first relevant document, 0 if none in the top k."""
relevant = {doc_id for doc_id, rel in qrel.items() if rel > 0}
for rank, doc_id in enumerate(ranked_ids[:k], start=1):
if doc_id in relevant:
return 1.0 / rank
return 0.0
def mean_retrieval_metrics(
rankings: Mapping[str, Sequence[str]],
qrels: Qrels,
ks: Sequence[int] = (10, 100),
) -> dict[str, float]:
"""Macro-average nDCG/recall/MRR over queries that have at least one qrel."""
scores: dict[str, list[float]] = {}
for k in ks:
scores[f"ndcg@{k}"] = []
scores[f"recall@{k}"] = []
scores[f"mrr@{k}"] = []
n_scored = 0
for query_id, qrel in qrels.items():
if not any(rel > 0 for rel in qrel.values()):
continue
ranked = rankings.get(query_id)
if ranked is None:
continue
n_scored += 1
for k in ks:
scores[f"ndcg@{k}"].append(ndcg_at_k(ranked, qrel, k))
scores[f"recall@{k}"].append(recall_at_k(ranked, qrel, k))
scores[f"mrr@{k}"].append(mrr_at_k(ranked, qrel, k))
out: dict[str, float] = {"n_queries": float(n_scored)}
for name, values in scores.items():
out[name] = float(sum(values) / len(values)) if values else 0.0
return out
def topk_overlap(left: Sequence[str], right: Sequence[str], k: int) -> float:
"""Jaccard-style overlap of two top-k lists: |A ∩ B| / k."""
if k <= 0:
raise ValueError("k must be positive")
return len(set(left[:k]) & set(right[:k])) / k
def mean_rank_agreement(
reference_rankings: Mapping[str, Sequence[str]],
candidate_rankings: Mapping[str, Sequence[str]],
ks: Sequence[int] = (1, 10),
) -> dict[str, float]:
"""How often the candidate ranking preserves the reference top-k."""
shared = [qid for qid in reference_rankings if qid in candidate_rankings]
out: dict[str, float] = {"n_queries": float(len(shared))}
for k in ks:
if not shared:
out[f"top{k}_overlap"] = 0.0
continue
overlaps = [
topk_overlap(reference_rankings[qid], candidate_rankings[qid], k) for qid in shared
]
out[f"top{k}_overlap"] = float(sum(overlaps) / len(overlaps))
return out
|