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