repeatability-evals 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repeatability_evals/__init__.py +38 -0
- repeatability_evals/dataset.py +80 -0
- repeatability_evals/metrics.py +223 -0
- repeatability_evals/prompts.py +120 -0
- repeatability_evals/py.typed +0 -0
- repeatability_evals/sampling.py +102 -0
- repeatability_evals/similarity/__init__.py +14 -0
- repeatability_evals/similarity/base.py +85 -0
- repeatability_evals/similarity/embedding.py +56 -0
- repeatability_evals/similarity/judge.py +185 -0
- repeatability_evals/similarity/ranking.py +46 -0
- repeatability_evals/similarity/sets.py +53 -0
- repeatability_evals/similarity/text.py +114 -0
- repeatability_evals/stats.py +27 -0
- repeatability_evals/types.py +43 -0
- repeatability_evals-0.1.0.dist-info/METADATA +147 -0
- repeatability_evals-0.1.0.dist-info/RECORD +19 -0
- repeatability_evals-0.1.0.dist-info/WHEEL +4 -0
- repeatability_evals-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""repeatability-evals: consistency evals for non-deterministic LLM systems."""
|
|
2
|
+
from .dataset import DatasetResult, aevaluate_groups, evaluate_groups
|
|
3
|
+
from .metrics import ClusterConsistency, ConsistencyMetric, PairwiseConsistency, TotalAgreement
|
|
4
|
+
from .prompts import DEFAULT_DIMENSIONS, SEMANTIC_EQUIVALENCE_PROMPT, Dimension, build_rubric_prompt
|
|
5
|
+
from .sampling import AllPairs, AnchorPairs, CycleSampler, PairSampler, RandomPairs, cycle_pairs
|
|
6
|
+
from .similarity import (
|
|
7
|
+
DiceSimilarity,
|
|
8
|
+
EditSimilarity,
|
|
9
|
+
EmbeddingCosine,
|
|
10
|
+
ExactMatch,
|
|
11
|
+
FunctionSimilarity,
|
|
12
|
+
JaccardSimilarity,
|
|
13
|
+
JudgeParseError,
|
|
14
|
+
LLMJudge,
|
|
15
|
+
NgramJaccard,
|
|
16
|
+
OverlapCoefficient,
|
|
17
|
+
RankBiasedOverlap,
|
|
18
|
+
RougeL,
|
|
19
|
+
RubricJudge,
|
|
20
|
+
Similarity,
|
|
21
|
+
as_similarity,
|
|
22
|
+
)
|
|
23
|
+
from .stats import bootstrap_ci
|
|
24
|
+
from .types import ConsistencyResult, PairScore, SimilarityResult
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ConsistencyMetric", "PairwiseConsistency", "ClusterConsistency", "TotalAgreement",
|
|
30
|
+
"evaluate_groups", "aevaluate_groups", "DatasetResult", "bootstrap_ci",
|
|
31
|
+
"Similarity", "FunctionSimilarity", "as_similarity",
|
|
32
|
+
"JaccardSimilarity", "DiceSimilarity", "OverlapCoefficient",
|
|
33
|
+
"ExactMatch", "NgramJaccard", "RougeL", "EditSimilarity",
|
|
34
|
+
"RankBiasedOverlap", "EmbeddingCosine", "LLMJudge", "RubricJudge", "JudgeParseError",
|
|
35
|
+
"Dimension", "DEFAULT_DIMENSIONS", "build_rubric_prompt",
|
|
36
|
+
"PairSampler", "AllPairs", "CycleSampler", "RandomPairs", "AnchorPairs", "cycle_pairs",
|
|
37
|
+
"ConsistencyResult", "PairScore", "SimilarityResult", "SEMANTIC_EQUIVALENCE_PROMPT",
|
|
38
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Run a metric over many groups (one group = the N runs of one input) and summarise."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
from .metrics import ConsistencyMetric
|
|
9
|
+
from .stats import bootstrap_ci, mean
|
|
10
|
+
from .types import ConsistencyResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DatasetResult:
|
|
15
|
+
metric: str
|
|
16
|
+
score: float # mean of per-group scores
|
|
17
|
+
n_groups: int
|
|
18
|
+
ci: Optional[Tuple[float, float]] = None # bootstrap CI of ``score``
|
|
19
|
+
per_group: Dict[Hashable, ConsistencyResult] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
def worst(self, k: int = 5) -> List[Tuple[Hashable, ConsistencyResult]]:
|
|
22
|
+
"""The ``k`` least consistent groups: where to look first when debugging."""
|
|
23
|
+
return sorted(self.per_group.items(), key=lambda kv: kv[1].score)[:k]
|
|
24
|
+
|
|
25
|
+
def to_records(self) -> List[Dict[str, Any]]:
|
|
26
|
+
return [
|
|
27
|
+
{"group": key, "score": r.score, "min_score": r.min_score,
|
|
28
|
+
"n_items": r.n_items, "n_pairs": r.n_pairs, **{f"d_{k}": v for k, v in r.details.items()
|
|
29
|
+
if isinstance(v, (int, float, str, bool))}}
|
|
30
|
+
for key, r in self.per_group.items()
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _summarise(
|
|
35
|
+
metric: ConsistencyMetric, results: Dict[Hashable, ConsistencyResult],
|
|
36
|
+
ci: bool, n_boot: int, seed: int,
|
|
37
|
+
) -> DatasetResult:
|
|
38
|
+
if not results:
|
|
39
|
+
raise ValueError("no groups to evaluate")
|
|
40
|
+
scores = [r.score for r in results.values()]
|
|
41
|
+
interval = bootstrap_ci(scores, n_boot=n_boot, seed=seed) if ci else None
|
|
42
|
+
return DatasetResult(metric.name, mean(scores), len(results), interval, results)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def evaluate_groups(
|
|
46
|
+
groups: Mapping[Hashable, Sequence[Any]],
|
|
47
|
+
metric: ConsistencyMetric,
|
|
48
|
+
*,
|
|
49
|
+
contexts: Optional[Mapping[Hashable, Mapping[str, Any]]] = None,
|
|
50
|
+
ci: bool = True,
|
|
51
|
+
n_boot: int = 2000,
|
|
52
|
+
seed: int = 0,
|
|
53
|
+
) -> DatasetResult:
|
|
54
|
+
"""``groups`` maps a group key (e.g. a question ID) to its N items; ``contexts`` optionally
|
|
55
|
+
maps the same keys to per-group context such as ``{"query": "..."}`` for an LLM judge."""
|
|
56
|
+
results = {
|
|
57
|
+
key: metric.evaluate(items, context=(contexts or {}).get(key))
|
|
58
|
+
for key, items in groups.items()
|
|
59
|
+
}
|
|
60
|
+
return _summarise(metric, results, ci, n_boot, seed)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def aevaluate_groups(
|
|
64
|
+
groups: Mapping[Hashable, Sequence[Any]],
|
|
65
|
+
metric: ConsistencyMetric,
|
|
66
|
+
*,
|
|
67
|
+
contexts: Optional[Mapping[Hashable, Mapping[str, Any]]] = None,
|
|
68
|
+
concurrency: int = 4,
|
|
69
|
+
ci: bool = True,
|
|
70
|
+
n_boot: int = 2000,
|
|
71
|
+
seed: int = 0,
|
|
72
|
+
) -> DatasetResult:
|
|
73
|
+
sem = asyncio.Semaphore(concurrency)
|
|
74
|
+
|
|
75
|
+
async def one(key: Hashable, items: Sequence[Any]) -> Tuple[Hashable, ConsistencyResult]:
|
|
76
|
+
async with sem:
|
|
77
|
+
return key, await metric.aevaluate(items, context=(contexts or {}).get(key))
|
|
78
|
+
|
|
79
|
+
pairs = await asyncio.gather(*(one(k, v) for k, v in groups.items()))
|
|
80
|
+
return _summarise(metric, dict(pairs), ci, n_boot, seed)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Consistency metrics: turn a Similarity (or an equality test) into a score for N items.
|
|
2
|
+
|
|
3
|
+
A metric knows nothing about *what* the items are. Feed it the N final answers of N runs,
|
|
4
|
+
N sets of retrieved chunk IDs, N tool-call traces, N paraphrase-conditioned outputs... and
|
|
5
|
+
pick the Similarity that suits them.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import math
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any, Callable, List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from .sampling import CycleSampler, PairSampler
|
|
15
|
+
from .similarity.base import Context, Similarity, as_similarity
|
|
16
|
+
from .similarity.text import default_normalize
|
|
17
|
+
from .stats import mean
|
|
18
|
+
from .types import ConsistencyResult, PairScore, SimilarityResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConsistencyMetric(ABC):
|
|
22
|
+
"""Score how consistent a group of N items is. Higher is more consistent (1.0 = fully)."""
|
|
23
|
+
|
|
24
|
+
name: str = "consistency"
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult: ...
|
|
28
|
+
|
|
29
|
+
async def aevaluate(
|
|
30
|
+
self, items: Sequence[Any], *, context: Context = None
|
|
31
|
+
) -> ConsistencyResult:
|
|
32
|
+
return await asyncio.to_thread(self.evaluate, items, context=context)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _degenerate(name: str, n_items: int) -> ConsistencyResult:
|
|
36
|
+
# Nothing to disagree with: zero or one item is trivially consistent.
|
|
37
|
+
return ConsistencyResult(name, 1.0, 1.0, n_items, 0, [], {"degenerate": True})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _pair_score(i: int, j: int, r: SimilarityResult) -> PairScore:
|
|
41
|
+
return PairScore(i, j, r.score, r.raw, r.reason, r.metadata.get("dimensions"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PairwiseConsistency(ConsistencyMetric):
|
|
45
|
+
"""Average similarity over a sampled set of item pairs.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
similarity: a ``Similarity`` (or any ``fn(a, b) -> float``).
|
|
49
|
+
sampler: which pairs to compare. Default ``CycleSampler()``: N pairs, every item
|
|
50
|
+
covered, deterministic. Use ``AllPairs()`` for the exact all-pairs value.
|
|
51
|
+
preprocess: applied to each item before comparing, e.g. ``lambda r: r["chunk_ids"]``.
|
|
52
|
+
agreement_threshold: if set, ``details["agreement_rate"]`` reports the share of
|
|
53
|
+
compared pairs scoring at least this (on the normalised [0, 1] scale).
|
|
54
|
+
aggregate: how to combine pair scores into ``score`` (default: mean).
|
|
55
|
+
concurrency: max simultaneous similarity calls in ``aevaluate``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
similarity: Any,
|
|
61
|
+
*,
|
|
62
|
+
sampler: Optional[PairSampler] = None,
|
|
63
|
+
preprocess: Optional[Callable[[Any], Any]] = None,
|
|
64
|
+
agreement_threshold: Optional[float] = None,
|
|
65
|
+
aggregate: Optional[Callable[[Sequence[float]], float]] = None,
|
|
66
|
+
name: Optional[str] = None,
|
|
67
|
+
concurrency: int = 8,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.similarity: Similarity = as_similarity(similarity)
|
|
70
|
+
self.sampler = sampler or CycleSampler()
|
|
71
|
+
self.preprocess = preprocess
|
|
72
|
+
self.agreement_threshold = agreement_threshold
|
|
73
|
+
self.aggregate = aggregate or mean
|
|
74
|
+
self.name = name or f"pairwise_{self.similarity.name}"
|
|
75
|
+
self.concurrency = concurrency
|
|
76
|
+
|
|
77
|
+
def _prepare(self, items: Sequence[Any]) -> List[Any]:
|
|
78
|
+
return [self.preprocess(x) for x in items] if self.preprocess else list(items)
|
|
79
|
+
|
|
80
|
+
def _finish(self, n_items: int, pairs: List[PairScore]) -> ConsistencyResult:
|
|
81
|
+
scores = [p.score for p in pairs]
|
|
82
|
+
details = {"similarity": self.similarity.name, "sampler": type(self.sampler).__name__}
|
|
83
|
+
if self.agreement_threshold is not None:
|
|
84
|
+
details["agreement_rate"] = sum(s >= self.agreement_threshold for s in scores) / len(scores)
|
|
85
|
+
dims = [p.dimensions for p in pairs if p.dimensions]
|
|
86
|
+
if dims: # rubric judges: mean score per dimension, so you can see *what* varies
|
|
87
|
+
details["dimensions"] = {k: mean([d[k] for d in dims]) for k in dims[0]}
|
|
88
|
+
return ConsistencyResult(
|
|
89
|
+
self.name, self.aggregate(scores), min(scores), n_items, len(pairs), pairs, details
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult:
|
|
93
|
+
prepared = self._prepare(items)
|
|
94
|
+
pairs = self.sampler.sample(len(prepared))
|
|
95
|
+
if not pairs:
|
|
96
|
+
return _degenerate(self.name, len(prepared))
|
|
97
|
+
scored = [
|
|
98
|
+
_pair_score(i, j, self.similarity.score(prepared[i], prepared[j], context=context))
|
|
99
|
+
for i, j in pairs
|
|
100
|
+
]
|
|
101
|
+
return self._finish(len(prepared), scored)
|
|
102
|
+
|
|
103
|
+
async def aevaluate(
|
|
104
|
+
self, items: Sequence[Any], *, context: Context = None
|
|
105
|
+
) -> ConsistencyResult:
|
|
106
|
+
prepared = self._prepare(items)
|
|
107
|
+
pairs = self.sampler.sample(len(prepared))
|
|
108
|
+
if not pairs:
|
|
109
|
+
return _degenerate(self.name, len(prepared))
|
|
110
|
+
sem = asyncio.Semaphore(self.concurrency)
|
|
111
|
+
|
|
112
|
+
async def one(i: int, j: int) -> PairScore:
|
|
113
|
+
async with sem:
|
|
114
|
+
r = await self.similarity.ascore(prepared[i], prepared[j], context=context)
|
|
115
|
+
return _pair_score(i, j, r)
|
|
116
|
+
|
|
117
|
+
scored = await asyncio.gather(*(one(i, j) for i, j in pairs))
|
|
118
|
+
return self._finish(len(prepared), list(scored))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ClusterConsistency(ConsistencyMetric):
|
|
122
|
+
"""Group items into 'same answer' clusters; the score is the largest cluster's share.
|
|
123
|
+
|
|
124
|
+
Greedy: each item joins the first cluster whose first member it matches
|
|
125
|
+
(``similarity >= threshold``), else starts a new one. Order-dependent by construction;
|
|
126
|
+
it never compares more than items x clusters pairs. ``details`` reports the cluster
|
|
127
|
+
sizes and the normalised entropy of the partition (the discrete form of semantic
|
|
128
|
+
entropy: 0 = one cluster, 1 = every item alone).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self, similarity: Any, *, threshold: float = 0.75, name: Optional[str] = None,
|
|
133
|
+
preprocess: Optional[Callable[[Any], Any]] = None, concurrency: int = 8,
|
|
134
|
+
) -> None:
|
|
135
|
+
if not 0.0 <= threshold <= 1.0:
|
|
136
|
+
raise ValueError("threshold must be in [0, 1]")
|
|
137
|
+
self.similarity: Similarity = as_similarity(similarity)
|
|
138
|
+
self.threshold, self.preprocess, self.concurrency = threshold, preprocess, concurrency
|
|
139
|
+
self.name = name or f"cluster_{self.similarity.name}"
|
|
140
|
+
|
|
141
|
+
def _prepare(self, items: Sequence[Any]) -> List[Any]:
|
|
142
|
+
return [self.preprocess(x) for x in items] if self.preprocess else list(items)
|
|
143
|
+
|
|
144
|
+
def _result(self, n: int, members: List[List[int]], pairs: List[PairScore]) -> ConsistencyResult:
|
|
145
|
+
sizes = sorted((len(m) for m in members), reverse=True)
|
|
146
|
+
probs = [s / n for s in sizes]
|
|
147
|
+
entropy = -sum(p * math.log(p) for p in probs)
|
|
148
|
+
norm = entropy / math.log(n) if n > 1 else 0.0
|
|
149
|
+
details = {
|
|
150
|
+
"similarity": self.similarity.name, "threshold": self.threshold,
|
|
151
|
+
"cluster_sizes": sizes, "n_clusters": len(sizes), "normalized_entropy": norm,
|
|
152
|
+
"clusters": members,
|
|
153
|
+
}
|
|
154
|
+
scores = [p.score for p in pairs]
|
|
155
|
+
return ConsistencyResult(
|
|
156
|
+
self.name, sizes[0] / n, min(scores) if scores else 1.0, n, len(pairs), pairs, details
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult:
|
|
160
|
+
prepared = self._prepare(items)
|
|
161
|
+
if len(prepared) < 2:
|
|
162
|
+
return _degenerate(self.name, len(prepared))
|
|
163
|
+
members: List[List[int]] = []
|
|
164
|
+
pairs: List[PairScore] = []
|
|
165
|
+
for idx, item in enumerate(prepared):
|
|
166
|
+
for cluster in members:
|
|
167
|
+
r = self.similarity.score(prepared[cluster[0]], item, context=context)
|
|
168
|
+
pairs.append(_pair_score(cluster[0], idx, r))
|
|
169
|
+
if r.score >= self.threshold:
|
|
170
|
+
cluster.append(idx)
|
|
171
|
+
break
|
|
172
|
+
else:
|
|
173
|
+
members.append([idx])
|
|
174
|
+
return self._result(len(prepared), members, pairs)
|
|
175
|
+
|
|
176
|
+
async def aevaluate(
|
|
177
|
+
self, items: Sequence[Any], *, context: Context = None
|
|
178
|
+
) -> ConsistencyResult:
|
|
179
|
+
prepared = self._prepare(items)
|
|
180
|
+
if len(prepared) < 2:
|
|
181
|
+
return _degenerate(self.name, len(prepared))
|
|
182
|
+
sem = asyncio.Semaphore(self.concurrency)
|
|
183
|
+
members: List[List[int]] = []
|
|
184
|
+
pairs: List[PairScore] = []
|
|
185
|
+
|
|
186
|
+
async def cmp(rep: int, idx: int) -> PairScore:
|
|
187
|
+
async with sem:
|
|
188
|
+
r = await self.similarity.ascore(prepared[rep], prepared[idx], context=context)
|
|
189
|
+
return _pair_score(rep, idx, r)
|
|
190
|
+
|
|
191
|
+
for idx in range(len(prepared)):
|
|
192
|
+
# compare against every current cluster at once, then take the first match
|
|
193
|
+
results = await asyncio.gather(*(cmp(c[0], idx) for c in members))
|
|
194
|
+
pairs.extend(results)
|
|
195
|
+
hit = next((k for k, p in enumerate(results) if p.score >= self.threshold), None)
|
|
196
|
+
if hit is None:
|
|
197
|
+
members.append([idx])
|
|
198
|
+
else:
|
|
199
|
+
members[hit].append(idx)
|
|
200
|
+
return self._result(len(prepared), members, pairs)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TotalAgreement(ConsistencyMetric):
|
|
204
|
+
"""1.0 if *all* items are identical after normalisation, else 0.0.
|
|
205
|
+
|
|
206
|
+
The mean over a dataset is the total agreement rate (TAR@N of Atil et al., 2024).
|
|
207
|
+
Pass ``normalize=str`` for raw-output agreement, or an answer extractor for parsed
|
|
208
|
+
agreement.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
name = "total_agreement"
|
|
212
|
+
|
|
213
|
+
def __init__(self, normalize: Callable[[Any], Any] = default_normalize) -> None:
|
|
214
|
+
self.normalize = normalize
|
|
215
|
+
|
|
216
|
+
def evaluate(self, items: Sequence[Any], *, context: Context = None) -> ConsistencyResult:
|
|
217
|
+
if len(items) < 2:
|
|
218
|
+
return _degenerate(self.name, len(items))
|
|
219
|
+
distinct = {self.normalize(x) for x in items}
|
|
220
|
+
score = 1.0 if len(distinct) == 1 else 0.0
|
|
221
|
+
return ConsistencyResult(
|
|
222
|
+
self.name, score, score, len(items), 0, [], {"n_distinct": len(distinct)}
|
|
223
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Default prompt for the LLM judge. Placeholders: {query}, {item_a}, {item_b}."""
|
|
2
|
+
|
|
3
|
+
SEMANTIC_EQUIVALENCE_PROMPT = """\
|
|
4
|
+
You are comparing two responses that were produced independently for the same input. \
|
|
5
|
+
Decide how closely they agree in meaning.
|
|
6
|
+
|
|
7
|
+
Input (may be empty):
|
|
8
|
+
{query}
|
|
9
|
+
|
|
10
|
+
Response A:
|
|
11
|
+
{item_a}
|
|
12
|
+
|
|
13
|
+
Response B:
|
|
14
|
+
{item_b}
|
|
15
|
+
|
|
16
|
+
Rate their semantic equivalence on a 1-5 scale:
|
|
17
|
+
5 = Identical meaning: same core facts, no meaningful differences
|
|
18
|
+
4 = Very similar: only minor phrasing or presentational differences
|
|
19
|
+
3 = Partially equivalent: some differences in detail or completeness
|
|
20
|
+
2 = Mostly different: notable factual or informational differences
|
|
21
|
+
1 = Completely different information: little to no overlap
|
|
22
|
+
|
|
23
|
+
Guidelines:
|
|
24
|
+
- Judge meaning, not wording, length, formatting, or the order of points.
|
|
25
|
+
- Do not judge which response is better or more correct. Only judge whether they agree; \
|
|
26
|
+
two equally wrong responses that say the same thing score 5.
|
|
27
|
+
- Extra detail in one response lowers the score only when it adds or changes substantive \
|
|
28
|
+
information that the other lacks or contradicts.
|
|
29
|
+
|
|
30
|
+
Reply with JSON only: {"reason": "<one or two sentences>", "score": <integer 1-5>}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------------------
|
|
35
|
+
# Multi-dimension rubric. One LLM call scores each dimension 1-5; ``RubricJudge`` combines
|
|
36
|
+
# them with the weights below. Edit, drop or add dimensions by passing your own ``Dimension``s.
|
|
37
|
+
# --------------------------------------------------------------------------------------
|
|
38
|
+
from dataclasses import dataclass # noqa: E402
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Dimension:
|
|
43
|
+
name: str
|
|
44
|
+
description: str # what 5 vs 1 means; shown to the judge verbatim
|
|
45
|
+
weight: float = 1.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
DEFAULT_DIMENSIONS = (
|
|
49
|
+
Dimension(
|
|
50
|
+
"core_facts",
|
|
51
|
+
"Do both responses make the same central claims, conclusions or answer? "
|
|
52
|
+
"5 = same core answer; 3 = same topic but the main conclusion is partly different or "
|
|
53
|
+
"one omits part of it; 1 = different or contradictory core answers.",
|
|
54
|
+
0.40,
|
|
55
|
+
),
|
|
56
|
+
Dimension(
|
|
57
|
+
"specifics",
|
|
58
|
+
"Do the concrete specifics agree: names, numbers, dates, versions, identifiers, "
|
|
59
|
+
"quantities, units, cited sources? "
|
|
60
|
+
"5 = all shared specifics match; 3 = some differ or are missing; "
|
|
61
|
+
"1 = specifics conflict. If neither response contains specifics, score 5.",
|
|
62
|
+
0.25,
|
|
63
|
+
),
|
|
64
|
+
Dimension(
|
|
65
|
+
"supporting_detail",
|
|
66
|
+
"Do the non-core details agree: explanations, examples, caveats, background, extra "
|
|
67
|
+
"context? 5 = same supporting information; 3 = noticeable additions or omissions; "
|
|
68
|
+
"1 = largely different supporting material. Differences here matter less than core facts.",
|
|
69
|
+
0.15,
|
|
70
|
+
),
|
|
71
|
+
Dimension(
|
|
72
|
+
"stance",
|
|
73
|
+
"Do the responses take the same stance and certainty: direct answer vs hedge vs refusal "
|
|
74
|
+
"vs 'I don't know', the same recommendation or next step, similar confidence? "
|
|
75
|
+
"5 = same stance; 3 = same direction but different confidence or caveats; "
|
|
76
|
+
"1 = opposite stance (e.g. one answers, one declines).",
|
|
77
|
+
0.10,
|
|
78
|
+
),
|
|
79
|
+
Dimension(
|
|
80
|
+
"format",
|
|
81
|
+
"Do the responses have the same form: structure (list vs prose vs table), length, "
|
|
82
|
+
"ordering, level of verbosity? 5 = essentially the same form; 3 = noticeably different "
|
|
83
|
+
"structure or length; 1 = completely different form. Ignore wording that doesn't change "
|
|
84
|
+
"structure.",
|
|
85
|
+
0.10,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
RUBRIC_PROMPT_TEMPLATE = """\
|
|
90
|
+
You are comparing two responses that were produced independently for the same input. \
|
|
91
|
+
Judge how closely they agree on each dimension below.
|
|
92
|
+
|
|
93
|
+
Input (may be empty):
|
|
94
|
+
{query}
|
|
95
|
+
|
|
96
|
+
Response A:
|
|
97
|
+
{item_a}
|
|
98
|
+
|
|
99
|
+
Response B:
|
|
100
|
+
{item_b}
|
|
101
|
+
|
|
102
|
+
Score each dimension from 1 (completely different) to 5 (identical):
|
|
103
|
+
{rubric}
|
|
104
|
+
|
|
105
|
+
Guidelines:
|
|
106
|
+
- Judge agreement between the two responses only. Do not judge which one is better or more \
|
|
107
|
+
correct; two equally wrong responses that say the same thing agree.
|
|
108
|
+
- Score each dimension independently. A formatting difference must not lower core_facts, and \
|
|
109
|
+
vice versa.
|
|
110
|
+
- Judge meaning, not surface wording.
|
|
111
|
+
|
|
112
|
+
Reply with JSON only: \
|
|
113
|
+
{"reason": "<one or two sentences on the main differences>", "scores": {%s}}
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_rubric_prompt(dimensions=DEFAULT_DIMENSIONS) -> str:
|
|
118
|
+
rubric = "\n".join(f"- {d.name}: {d.description}" for d in dimensions)
|
|
119
|
+
keys = ", ".join(f'"{d.name}": <integer 1-5>' for d in dimensions)
|
|
120
|
+
return RUBRIC_PROMPT_TEMPLATE.replace("{rubric}", rubric).replace("%s", keys)
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Choosing which pairs of items to compare.
|
|
2
|
+
|
|
3
|
+
Comparing all N-choose-2 pairs is quadratic; with an LLM judge that gets expensive. The
|
|
4
|
+
samplers here pick a subset deterministically (seeded) so results are reproducible.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import itertools
|
|
9
|
+
import random
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Optional, Protocol
|
|
12
|
+
|
|
13
|
+
from .types import Pair
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PairSampler(Protocol):
|
|
17
|
+
def sample(self, n_items: int) -> List[Pair]:
|
|
18
|
+
"""Return distinct (i, j) pairs with i < j, drawn from range(n_items)."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _norm(a: int, b: int) -> Pair:
|
|
23
|
+
return (a, b) if a < b else (b, a)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cycle_pairs(n_items: int, budget: Optional[int] = None, seed: int = 0) -> List[Pair]:
|
|
27
|
+
"""A random Hamiltonian cycle over the items, topped up with random extra pairs.
|
|
28
|
+
|
|
29
|
+
Every item appears in at least two sampled pairs when ``budget >= n_items``, so no
|
|
30
|
+
item can hide from the comparison. ``budget`` defaults to ``n_items``. When the
|
|
31
|
+
budget is smaller than the cycle, alternate cycle edges (a matching) are used first
|
|
32
|
+
so as many items as possible are still covered. When it reaches N-choose-2 you get
|
|
33
|
+
every pair.
|
|
34
|
+
"""
|
|
35
|
+
if n_items < 2:
|
|
36
|
+
return []
|
|
37
|
+
all_pairs = list(itertools.combinations(range(n_items), 2))
|
|
38
|
+
budget = n_items if budget is None else budget
|
|
39
|
+
if budget < 1:
|
|
40
|
+
raise ValueError("budget must be >= 1")
|
|
41
|
+
if budget >= len(all_pairs):
|
|
42
|
+
return all_pairs
|
|
43
|
+
|
|
44
|
+
rng = random.Random(seed)
|
|
45
|
+
order = list(range(n_items))
|
|
46
|
+
rng.shuffle(order) # random cycle: no positional bias
|
|
47
|
+
ring = list(dict.fromkeys(_norm(order[k], order[(k + 1) % n_items]) for k in range(n_items)))
|
|
48
|
+
ring = ring[0::2] + ring[1::2] # even edges form a matching -> best coverage if truncated
|
|
49
|
+
chosen = ring[:budget]
|
|
50
|
+
if len(chosen) < budget:
|
|
51
|
+
taken = set(chosen)
|
|
52
|
+
rest = [p for p in all_pairs if p not in taken]
|
|
53
|
+
rng.shuffle(rest)
|
|
54
|
+
chosen += rest[: budget - len(chosen)]
|
|
55
|
+
return sorted(chosen)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class AllPairs:
|
|
60
|
+
"""Exact: every pair. Cost grows quadratically."""
|
|
61
|
+
|
|
62
|
+
def sample(self, n_items: int) -> List[Pair]:
|
|
63
|
+
return list(itertools.combinations(range(n_items), 2))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class CycleSampler:
|
|
68
|
+
"""Default. ``budget`` pairs (default: N) with coverage guaranteed; see ``cycle_pairs``."""
|
|
69
|
+
|
|
70
|
+
budget: Optional[int] = None
|
|
71
|
+
seed: int = 0
|
|
72
|
+
|
|
73
|
+
def sample(self, n_items: int) -> List[Pair]:
|
|
74
|
+
return cycle_pairs(n_items, self.budget, self.seed)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class RandomPairs:
|
|
79
|
+
"""``budget`` distinct pairs drawn uniformly, with no coverage guarantee."""
|
|
80
|
+
|
|
81
|
+
budget: int
|
|
82
|
+
seed: int = 0
|
|
83
|
+
|
|
84
|
+
def sample(self, n_items: int) -> List[Pair]:
|
|
85
|
+
all_pairs = list(itertools.combinations(range(n_items), 2))
|
|
86
|
+
if self.budget >= len(all_pairs):
|
|
87
|
+
return all_pairs
|
|
88
|
+
return sorted(random.Random(self.seed).sample(all_pairs, self.budget))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class AnchorPairs:
|
|
93
|
+
"""Compare every item to one anchor (e.g. a reference or baseline run): N-1 pairs."""
|
|
94
|
+
|
|
95
|
+
anchor: int = 0
|
|
96
|
+
|
|
97
|
+
def sample(self, n_items: int) -> List[Pair]:
|
|
98
|
+
if n_items < 2:
|
|
99
|
+
return []
|
|
100
|
+
if not 0 <= self.anchor < n_items:
|
|
101
|
+
raise IndexError(f"anchor {self.anchor} out of range for {n_items} items")
|
|
102
|
+
return [_norm(self.anchor, k) for k in range(n_items) if k != self.anchor]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .base import FunctionSimilarity, Similarity, as_similarity
|
|
2
|
+
from .embedding import EmbeddingCosine
|
|
3
|
+
from .judge import JudgeParseError, LLMJudge, RubricJudge, parse_judge_output, parse_rubric_output
|
|
4
|
+
from .ranking import RankBiasedOverlap
|
|
5
|
+
from .sets import DiceSimilarity, JaccardSimilarity, OverlapCoefficient
|
|
6
|
+
from .text import EditSimilarity, ExactMatch, NgramJaccard, RougeL, ngram_set, tokenize
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Similarity", "FunctionSimilarity", "as_similarity",
|
|
10
|
+
"JaccardSimilarity", "DiceSimilarity", "OverlapCoefficient",
|
|
11
|
+
"ExactMatch", "NgramJaccard", "RougeL", "EditSimilarity", "ngram_set", "tokenize",
|
|
12
|
+
"RankBiasedOverlap", "EmbeddingCosine",
|
|
13
|
+
"LLMJudge", "RubricJudge", "JudgeParseError", "parse_judge_output", "parse_rubric_output",
|
|
14
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The one abstraction everything else is built on: comparing two items."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import math
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Awaitable, Callable, Mapping, Optional, Union
|
|
8
|
+
|
|
9
|
+
from ..types import SimilarityResult
|
|
10
|
+
|
|
11
|
+
Context = Optional[Mapping[str, Any]]
|
|
12
|
+
_EPS = 1e-9
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def coerce_result(value: Union[float, int, SimilarityResult], source: str) -> SimilarityResult:
|
|
16
|
+
"""Accept a bare number or a SimilarityResult; enforce the [0, 1] contract."""
|
|
17
|
+
result = value if isinstance(value, SimilarityResult) else SimilarityResult(score=float(value))
|
|
18
|
+
s = result.score
|
|
19
|
+
if math.isnan(s) or s < -_EPS or s > 1 + _EPS:
|
|
20
|
+
raise ValueError(f"{source} returned {s!r}; similarity scores must be in [0, 1]")
|
|
21
|
+
if s != min(max(s, 0.0), 1.0):
|
|
22
|
+
result = SimilarityResult(min(max(s, 0.0), 1.0), result.raw, result.reason, result.metadata)
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Similarity(ABC):
|
|
27
|
+
"""Compare two items and return how alike they are, in [0, 1] (1 = identical).
|
|
28
|
+
|
|
29
|
+
Subclass and implement ``compute``. Return a float, or a ``SimilarityResult`` to
|
|
30
|
+
attach a raw value or a rationale. Items can be anything (strings, sets of IDs,
|
|
31
|
+
lists of chunks, tool-call traces); ``context`` carries side information such as the
|
|
32
|
+
query both items answer. Override ``acompute`` if your similarity does async I/O.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name: str = "similarity"
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def compute(
|
|
39
|
+
self, a: Any, b: Any, *, context: Context = None
|
|
40
|
+
) -> Union[float, SimilarityResult]: ...
|
|
41
|
+
|
|
42
|
+
async def acompute(
|
|
43
|
+
self, a: Any, b: Any, *, context: Context = None
|
|
44
|
+
) -> Union[float, SimilarityResult]:
|
|
45
|
+
return await asyncio.to_thread(self.compute, a, b, context=context)
|
|
46
|
+
|
|
47
|
+
# public entry points -------------------------------------------------
|
|
48
|
+
def score(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
|
|
49
|
+
return coerce_result(self.compute(a, b, context=context), self.name)
|
|
50
|
+
|
|
51
|
+
async def ascore(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
|
|
52
|
+
return coerce_result(await self.acompute(a, b, context=context), self.name)
|
|
53
|
+
|
|
54
|
+
def __call__(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
55
|
+
return self.score(a, b, context=context).score
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FunctionSimilarity(Similarity):
|
|
59
|
+
"""Wrap a plain ``fn(a, b) -> float`` (and optionally an async twin) as a Similarity."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
fn: Callable[..., Union[float, SimilarityResult]],
|
|
64
|
+
*,
|
|
65
|
+
afn: Optional[Callable[..., Awaitable[Union[float, SimilarityResult]]]] = None,
|
|
66
|
+
name: Optional[str] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._fn, self._afn = fn, afn
|
|
69
|
+
self.name = name or getattr(fn, "__name__", "function_similarity")
|
|
70
|
+
|
|
71
|
+
def compute(self, a: Any, b: Any, *, context: Context = None):
|
|
72
|
+
return self._fn(a, b)
|
|
73
|
+
|
|
74
|
+
async def acompute(self, a: Any, b: Any, *, context: Context = None):
|
|
75
|
+
if self._afn is not None:
|
|
76
|
+
return await self._afn(a, b)
|
|
77
|
+
return await super().acompute(a, b, context=context)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def as_similarity(obj: Any) -> Similarity:
|
|
81
|
+
if isinstance(obj, Similarity):
|
|
82
|
+
return obj
|
|
83
|
+
if callable(obj):
|
|
84
|
+
return FunctionSimilarity(obj)
|
|
85
|
+
raise TypeError(f"expected a Similarity or a callable, got {type(obj).__name__}")
|