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,56 @@
|
|
|
1
|
+
"""Embedding-based semantic similarity. Bring your own embedding function: no dependencies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any, Callable, Dict, List, Sequence
|
|
6
|
+
|
|
7
|
+
from .base import Context, Similarity
|
|
8
|
+
|
|
9
|
+
EmbedFn = Callable[[List[str]], Sequence[Sequence[float]]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EmbeddingCosine(Similarity):
|
|
13
|
+
"""Cosine similarity of text embeddings.
|
|
14
|
+
|
|
15
|
+
``embed`` takes a list of strings and returns one vector each, which is the shape of
|
|
16
|
+
``SentenceTransformer.encode`` and of most embedding API clients, e.g.::
|
|
17
|
+
|
|
18
|
+
from sentence_transformers import SentenceTransformer
|
|
19
|
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
20
|
+
sim = EmbeddingCosine(lambda texts: model.encode(texts).tolist())
|
|
21
|
+
|
|
22
|
+
``rescale="clip"`` maps negative cosines to 0 (the usual convention for text
|
|
23
|
+
embeddings); ``"affine"`` maps [-1, 1] onto [0, 1]. Embeddings are cached per text
|
|
24
|
+
because the same item is compared against several others.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name = "embedding_cosine"
|
|
28
|
+
|
|
29
|
+
def __init__(self, embed: EmbedFn, *, rescale: str = "clip", max_cache: int = 20000) -> None:
|
|
30
|
+
if rescale not in ("clip", "affine"):
|
|
31
|
+
raise ValueError("rescale must be 'clip' or 'affine'")
|
|
32
|
+
self._embed, self._rescale, self._max_cache = embed, rescale, max_cache
|
|
33
|
+
self._cache: Dict[str, Sequence[float]] = {}
|
|
34
|
+
|
|
35
|
+
def _vectors(self, texts: List[str]) -> List[Sequence[float]]:
|
|
36
|
+
missing = [t for t in dict.fromkeys(texts) if t not in self._cache]
|
|
37
|
+
if missing:
|
|
38
|
+
if len(self._cache) + len(missing) > self._max_cache:
|
|
39
|
+
self._cache.clear()
|
|
40
|
+
vecs = self._embed(missing)
|
|
41
|
+
if len(vecs) != len(missing):
|
|
42
|
+
raise ValueError("embed() must return one vector per input text")
|
|
43
|
+
self._cache.update(zip(missing, vecs))
|
|
44
|
+
return [self._cache[t] for t in texts]
|
|
45
|
+
|
|
46
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
47
|
+
ta, tb = str(a), str(b)
|
|
48
|
+
if ta == tb:
|
|
49
|
+
return 1.0
|
|
50
|
+
va, vb = self._vectors([ta, tb])
|
|
51
|
+
na = math.sqrt(sum(x * x for x in va))
|
|
52
|
+
nb = math.sqrt(sum(x * x for x in vb))
|
|
53
|
+
if na == 0 or nb == 0:
|
|
54
|
+
return 0.0
|
|
55
|
+
cos = sum(x * y for x, y in zip(va, vb)) / (na * nb)
|
|
56
|
+
return max(0.0, cos) if self._rescale == "clip" else (cos + 1) / 2
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""LLM-as-judge semantic equivalence. Model-agnostic: you supply the completion function."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
from ..prompts import DEFAULT_DIMENSIONS, SEMANTIC_EQUIVALENCE_PROMPT, Dimension, build_rubric_prompt
|
|
9
|
+
from ..types import SimilarityResult
|
|
10
|
+
from .base import Context, Similarity
|
|
11
|
+
|
|
12
|
+
CompleteFn = Callable[[str], str]
|
|
13
|
+
ACompleteFn = Callable[[str], Awaitable[str]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JudgeParseError(ValueError):
|
|
17
|
+
"""The judge's reply contained no usable score."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_judge_output(text: str, scale: Tuple[float, float]) -> Tuple[float, Optional[str]]:
|
|
21
|
+
"""Extract (score, reason) from a judge reply: JSON first, then 'score: N', then last number."""
|
|
22
|
+
lo, hi = scale
|
|
23
|
+
match = re.search(r"\{.*\}", text, re.S)
|
|
24
|
+
if match:
|
|
25
|
+
try:
|
|
26
|
+
obj = json.loads(match.group(0))
|
|
27
|
+
if isinstance(obj, dict) and "score" in obj:
|
|
28
|
+
return float(obj["score"]), obj.get("reason")
|
|
29
|
+
except (ValueError, TypeError):
|
|
30
|
+
pass
|
|
31
|
+
m = re.search(r"(?i)\b(?:score|rating)\b\D{0,12}(-?\d+(?:\.\d+)?)", text)
|
|
32
|
+
if m:
|
|
33
|
+
return float(m.group(1)), None
|
|
34
|
+
nums = [float(x) for x in re.findall(r"(?<![\d.])\d+(?:\.\d+)?(?![\d.])", text)]
|
|
35
|
+
nums = [x for x in nums if lo <= x <= hi]
|
|
36
|
+
if nums:
|
|
37
|
+
return nums[-1], None
|
|
38
|
+
raise JudgeParseError(f"no score found in judge reply: {text[:200]!r}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LLMJudge(Similarity):
|
|
42
|
+
"""Semantic equivalence of two items, scored by an LLM on a 1-5 rubric.
|
|
43
|
+
|
|
44
|
+
``llm`` is any ``str -> str`` completion function (and/or ``allm``, its async twin), so
|
|
45
|
+
this works with any provider or local model. The score is normalised to [0, 1]; the
|
|
46
|
+
raw rating and the judge's reason are kept on the ``SimilarityResult``. Put the query
|
|
47
|
+
both items answer in ``context={"query": ...}`` and the prompt will include it.
|
|
48
|
+
|
|
49
|
+
``symmetric=True`` judges both (a, b) and (b, a) and averages them, removing position
|
|
50
|
+
bias at twice the cost.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name = "llm_judge"
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
llm: Optional[CompleteFn] = None,
|
|
58
|
+
allm: Optional[ACompleteFn] = None,
|
|
59
|
+
*,
|
|
60
|
+
prompt: str = SEMANTIC_EQUIVALENCE_PROMPT,
|
|
61
|
+
scale: Tuple[float, float] = (1.0, 5.0),
|
|
62
|
+
symmetric: bool = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
if llm is None and allm is None:
|
|
65
|
+
raise ValueError("provide llm and/or allm")
|
|
66
|
+
if scale[1] <= scale[0]:
|
|
67
|
+
raise ValueError("scale must be (low, high) with high > low")
|
|
68
|
+
self._llm, self._allm = llm, allm
|
|
69
|
+
self.prompt, self.scale, self.symmetric = prompt, scale, symmetric
|
|
70
|
+
|
|
71
|
+
def _render(self, a: Any, b: Any, context: Context) -> str:
|
|
72
|
+
query = str((context or {}).get("query", "") or "(not provided)")
|
|
73
|
+
return (
|
|
74
|
+
self.prompt.replace("{query}", query)
|
|
75
|
+
.replace("{item_a}", str(a))
|
|
76
|
+
.replace("{item_b}", str(b))
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _parse(self, reply: str) -> Tuple[float, Optional[str]]:
|
|
80
|
+
raw, reason = parse_judge_output(reply, self.scale)
|
|
81
|
+
return min(max(raw, self.scale[0]), self.scale[1]), reason
|
|
82
|
+
|
|
83
|
+
def _finish(self, parsed: list) -> SimilarityResult:
|
|
84
|
+
lo, hi = self.scale
|
|
85
|
+
raw = sum(p[0] for p in parsed) / len(parsed)
|
|
86
|
+
reason = next((p[1] for p in parsed if p[1]), None)
|
|
87
|
+
return SimilarityResult(score=(raw - lo) / (hi - lo), raw=raw, reason=reason)
|
|
88
|
+
|
|
89
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
|
|
90
|
+
if self._llm is None:
|
|
91
|
+
raise RuntimeError("this judge only has an async llm; use aevaluate()/ascore()")
|
|
92
|
+
orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
|
|
93
|
+
return self._finish([self._parse(self._llm(self._render(x, y, context))) for x, y in orders])
|
|
94
|
+
|
|
95
|
+
async def acompute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
|
|
96
|
+
if self._allm is None:
|
|
97
|
+
return await super().acompute(a, b, context=context) # type: ignore[return-value]
|
|
98
|
+
orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
|
|
99
|
+
parsed = [self._parse(await self._allm(self._render(x, y, context))) for x, y in orders]
|
|
100
|
+
return self._finish(parsed)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_rubric_output(
|
|
104
|
+
text: str, names: Sequence[str], scale: Tuple[float, float]
|
|
105
|
+
) -> Tuple[Dict[str, float], Optional[str]]:
|
|
106
|
+
"""Extract ({dimension: score}, reason) from a rubric judge reply (JSON with a "scores" object)."""
|
|
107
|
+
match = re.search(r"\{.*\}", text, re.S)
|
|
108
|
+
if not match:
|
|
109
|
+
raise JudgeParseError(f"no JSON found in judge reply: {text[:200]!r}")
|
|
110
|
+
try:
|
|
111
|
+
obj = json.loads(match.group(0))
|
|
112
|
+
scores = obj["scores"]
|
|
113
|
+
out = {n: min(max(float(scores[n]), scale[0]), scale[1]) for n in names}
|
|
114
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
115
|
+
raise JudgeParseError(f"missing or invalid dimension scores ({e!r}): {text[:200]!r}") from e
|
|
116
|
+
return out, obj.get("reason")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RubricJudge(Similarity):
|
|
120
|
+
"""Multi-dimension LLM judge: one call scores several aspects of agreement (1-5 each).
|
|
121
|
+
|
|
122
|
+
Default dimensions: ``core_facts``, ``specifics``, ``supporting_detail``, ``stance``,
|
|
123
|
+
``format`` (see ``repeatability_evals.prompts.DEFAULT_DIMENSIONS``). The similarity score is
|
|
124
|
+
their weighted mean, normalised to [0, 1]; the per-dimension scores are kept on the result
|
|
125
|
+
(``metadata["dimensions"]``) and averaged per dimension in ``PairwiseConsistency`` details, so
|
|
126
|
+
you can see *what* varies between runs (facts? format?) and not just *whether* they differ.
|
|
127
|
+
|
|
128
|
+
Pass your own ``dimensions`` (any number, any weights) to adapt it, e.g. for agent traces.
|
|
129
|
+
Weights are normalised to sum to 1. ``symmetric=True`` judges both orders and averages.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
name = "rubric_judge"
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
llm: Optional[CompleteFn] = None,
|
|
137
|
+
allm: Optional[ACompleteFn] = None,
|
|
138
|
+
*,
|
|
139
|
+
dimensions: Sequence[Dimension] = DEFAULT_DIMENSIONS,
|
|
140
|
+
prompt: Optional[str] = None,
|
|
141
|
+
scale: Tuple[float, float] = (1.0, 5.0),
|
|
142
|
+
symmetric: bool = False,
|
|
143
|
+
) -> None:
|
|
144
|
+
if llm is None and allm is None:
|
|
145
|
+
raise ValueError("provide llm and/or allm")
|
|
146
|
+
if scale[1] <= scale[0]:
|
|
147
|
+
raise ValueError("scale must be (low, high) with high > low")
|
|
148
|
+
if not dimensions or any(d.weight <= 0 for d in dimensions):
|
|
149
|
+
raise ValueError("need at least one dimension, each with weight > 0")
|
|
150
|
+
if len({d.name for d in dimensions}) != len(dimensions):
|
|
151
|
+
raise ValueError("dimension names must be unique")
|
|
152
|
+
self._llm, self._allm = llm, allm
|
|
153
|
+
self.dimensions = tuple(dimensions)
|
|
154
|
+
self.prompt = prompt or build_rubric_prompt(self.dimensions)
|
|
155
|
+
self.scale, self.symmetric = scale, symmetric
|
|
156
|
+
self._names = [d.name for d in self.dimensions]
|
|
157
|
+
total = sum(d.weight for d in self.dimensions)
|
|
158
|
+
self._weights = {d.name: d.weight / total for d in self.dimensions}
|
|
159
|
+
|
|
160
|
+
_render = LLMJudge._render
|
|
161
|
+
|
|
162
|
+
def _parse(self, reply: str) -> Tuple[Dict[str, float], Optional[str]]:
|
|
163
|
+
return parse_rubric_output(reply, self._names, self.scale)
|
|
164
|
+
|
|
165
|
+
def _finish(self, parsed: list) -> SimilarityResult:
|
|
166
|
+
lo, hi = self.scale
|
|
167
|
+
raw_dims = {n: sum(p[0][n] for p in parsed) / len(parsed) for n in self._names}
|
|
168
|
+
norm = {n: (v - lo) / (hi - lo) for n, v in raw_dims.items()}
|
|
169
|
+
score = sum(self._weights[n] * norm[n] for n in self._names)
|
|
170
|
+
raw = lo + score * (hi - lo)
|
|
171
|
+
reason = next((p[1] for p in parsed if p[1]), None)
|
|
172
|
+
return SimilarityResult(score, raw=raw, reason=reason, metadata={"dimensions": norm})
|
|
173
|
+
|
|
174
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
|
|
175
|
+
if self._llm is None:
|
|
176
|
+
raise RuntimeError("this judge only has an async llm; use aevaluate()/ascore()")
|
|
177
|
+
orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
|
|
178
|
+
return self._finish([self._parse(self._llm(self._render(x, y, context))) for x, y in orders])
|
|
179
|
+
|
|
180
|
+
async def acompute(self, a: Any, b: Any, *, context: Context = None) -> SimilarityResult:
|
|
181
|
+
if self._allm is None:
|
|
182
|
+
return await super().acompute(a, b, context=context) # type: ignore[return-value]
|
|
183
|
+
orders = [(a, b), (b, a)] if self.symmetric else [(a, b)]
|
|
184
|
+
parsed = [self._parse(await self._allm(self._render(x, y, context))) for x, y in orders]
|
|
185
|
+
return self._finish(parsed)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Similarity for *ordered* lists, where position matters (ranked retrieval, tool sequences)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Hashable, List, Sequence
|
|
5
|
+
|
|
6
|
+
from .base import Context, Similarity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _unique(seq: Sequence[Hashable]) -> List[Hashable]:
|
|
10
|
+
return list(dict.fromkeys(seq))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RankBiasedOverlap(Similarity):
|
|
14
|
+
"""Rank-biased overlap (Webber et al., 2010), extrapolated form on the common depth.
|
|
15
|
+
|
|
16
|
+
Top ranks count more than lower ones; ``p`` sets how top-heavy (0.9 means roughly the
|
|
17
|
+
top ~10 ranks carry most of the weight). Identical rankings score 1.0, disjoint 0.0.
|
|
18
|
+
Lists are truncated to the shorter length; duplicates keep their first position.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name = "rank_biased_overlap"
|
|
22
|
+
|
|
23
|
+
def __init__(self, p: float = 0.9) -> None:
|
|
24
|
+
if not 0.0 < p < 1.0:
|
|
25
|
+
raise ValueError("p must be in (0, 1)")
|
|
26
|
+
self.p = p
|
|
27
|
+
|
|
28
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
29
|
+
la, lb = _unique(list(a)), _unique(list(b))
|
|
30
|
+
if not la and not lb:
|
|
31
|
+
return 1.0
|
|
32
|
+
k = min(len(la), len(lb))
|
|
33
|
+
if k == 0:
|
|
34
|
+
return 0.0
|
|
35
|
+
p, seen_a, seen_b = self.p, set(), set()
|
|
36
|
+
overlap, weighted = 0, 0.0
|
|
37
|
+
for d in range(1, k + 1):
|
|
38
|
+
x, y = la[d - 1], lb[d - 1]
|
|
39
|
+
if x == y:
|
|
40
|
+
overlap += 1
|
|
41
|
+
else:
|
|
42
|
+
overlap += (x in seen_b) + (y in seen_a)
|
|
43
|
+
seen_a.add(x)
|
|
44
|
+
seen_b.add(y)
|
|
45
|
+
weighted += (overlap / d) * p**d
|
|
46
|
+
return (overlap / k) * p**k + ((1 - p) / p) * weighted
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Set-overlap similarities over collections of hashable items (IDs, labels, URLs...)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Hashable, Iterable, Set
|
|
5
|
+
|
|
6
|
+
from .base import Context, Similarity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_set(x: Iterable[Hashable]) -> Set[Hashable]:
|
|
10
|
+
if isinstance(x, (str, bytes)):
|
|
11
|
+
raise TypeError(
|
|
12
|
+
"set similarities compare collections; got a bare string. "
|
|
13
|
+
"Wrap it (e.g. [text]) or use a text similarity such as NgramJaccard."
|
|
14
|
+
)
|
|
15
|
+
return set(x)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JaccardSimilarity(Similarity):
|
|
19
|
+
"""|A & B| / |A | B|. Two empty collections count as identical (1.0)."""
|
|
20
|
+
|
|
21
|
+
name = "jaccard"
|
|
22
|
+
|
|
23
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
24
|
+
sa, sb = to_set(a), to_set(b)
|
|
25
|
+
if not sa and not sb:
|
|
26
|
+
return 1.0
|
|
27
|
+
return len(sa & sb) / len(sa | sb)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DiceSimilarity(Similarity):
|
|
31
|
+
"""2|A & B| / (|A| + |B|): like Jaccard but more forgiving of partial overlap."""
|
|
32
|
+
|
|
33
|
+
name = "dice"
|
|
34
|
+
|
|
35
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
36
|
+
sa, sb = to_set(a), to_set(b)
|
|
37
|
+
if not sa and not sb:
|
|
38
|
+
return 1.0
|
|
39
|
+
return 2 * len(sa & sb) / (len(sa) + len(sb))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OverlapCoefficient(Similarity):
|
|
43
|
+
"""|A & B| / min(|A|, |B|): 1.0 when one collection is contained in the other."""
|
|
44
|
+
|
|
45
|
+
name = "overlap_coefficient"
|
|
46
|
+
|
|
47
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
48
|
+
sa, sb = to_set(a), to_set(b)
|
|
49
|
+
if not sa and not sb:
|
|
50
|
+
return 1.0
|
|
51
|
+
if not sa or not sb:
|
|
52
|
+
return 0.0
|
|
53
|
+
return len(sa & sb) / min(len(sa), len(sb))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Lexical text similarities (no model calls)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import difflib
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Callable, List, Optional, Sequence, Set, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from .base import Context, Similarity
|
|
9
|
+
|
|
10
|
+
_TOKEN_RE = re.compile(r"\w+", re.UNICODE)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def tokenize(text: str) -> List[str]:
|
|
14
|
+
"""Lower-cased word tokens (Unicode-aware, punctuation dropped)."""
|
|
15
|
+
return _TOKEN_RE.findall(text.lower())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_normalize(text: str) -> str:
|
|
19
|
+
"""Case-fold and collapse whitespace."""
|
|
20
|
+
return " ".join(text.casefold().split())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ngram_set(text: str, n: int = 3) -> Set[Tuple[str, ...]]:
|
|
24
|
+
"""Word n-grams of ``text``. Text shorter than ``n`` yields one short gram."""
|
|
25
|
+
if n < 1:
|
|
26
|
+
raise ValueError("n must be >= 1")
|
|
27
|
+
tokens = tokenize(text)
|
|
28
|
+
if not tokens:
|
|
29
|
+
return set()
|
|
30
|
+
if len(tokens) < n:
|
|
31
|
+
return {tuple(tokens)}
|
|
32
|
+
return {tuple(tokens[k : k + n]) for k in range(len(tokens) - n + 1)}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExactMatch(Similarity):
|
|
36
|
+
"""1.0 if the normalised strings are equal, else 0.0."""
|
|
37
|
+
|
|
38
|
+
name = "exact_match"
|
|
39
|
+
|
|
40
|
+
def __init__(self, normalize: Optional[Callable[[str], str]] = default_normalize) -> None:
|
|
41
|
+
self._normalize = normalize or (lambda s: s)
|
|
42
|
+
|
|
43
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
44
|
+
return 1.0 if self._normalize(str(a)) == self._normalize(str(b)) else 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NgramJaccard(Similarity):
|
|
48
|
+
"""Jaccard over word n-grams.
|
|
49
|
+
|
|
50
|
+
Each item is a string or a sequence of strings (e.g. retrieved chunks); a sequence is
|
|
51
|
+
pooled into one n-gram set. That makes it robust to re-chunking: when chunk IDs
|
|
52
|
+
change but the underlying text does not, the score stays high.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
name = "ngram_jaccard"
|
|
56
|
+
|
|
57
|
+
def __init__(self, n: int = 3) -> None:
|
|
58
|
+
if n < 1:
|
|
59
|
+
raise ValueError("n must be >= 1")
|
|
60
|
+
self.n = n
|
|
61
|
+
|
|
62
|
+
def _grams(self, item: Union[str, Sequence[str]]) -> Set[Tuple[str, ...]]:
|
|
63
|
+
texts = [item] if isinstance(item, str) else list(item)
|
|
64
|
+
grams: Set[Tuple[str, ...]] = set()
|
|
65
|
+
for t in texts:
|
|
66
|
+
grams |= ngram_set(t, self.n)
|
|
67
|
+
return grams
|
|
68
|
+
|
|
69
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
70
|
+
ga, gb = self._grams(a), self._grams(b)
|
|
71
|
+
if not ga and not gb:
|
|
72
|
+
return 1.0
|
|
73
|
+
return len(ga & gb) / len(ga | gb)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RougeL(Similarity):
|
|
77
|
+
"""ROUGE-L F-measure: longest common token subsequence, so word order matters."""
|
|
78
|
+
|
|
79
|
+
name = "rouge_l"
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _lcs(x: List[str], y: List[str]) -> int:
|
|
83
|
+
if not x or not y:
|
|
84
|
+
return 0
|
|
85
|
+
prev = [0] * (len(y) + 1)
|
|
86
|
+
for xi in x:
|
|
87
|
+
cur = [0]
|
|
88
|
+
for j, yj in enumerate(y, 1):
|
|
89
|
+
cur.append(prev[j - 1] + 1 if xi == yj else max(prev[j], cur[j - 1]))
|
|
90
|
+
prev = cur
|
|
91
|
+
return prev[-1]
|
|
92
|
+
|
|
93
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
94
|
+
ta, tb = tokenize(str(a)), tokenize(str(b))
|
|
95
|
+
if not ta and not tb:
|
|
96
|
+
return 1.0
|
|
97
|
+
lcs = self._lcs(ta, tb)
|
|
98
|
+
if lcs == 0:
|
|
99
|
+
return 0.0
|
|
100
|
+
p, r = lcs / len(tb), lcs / len(ta)
|
|
101
|
+
return 2 * p * r / (p + r)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class EditSimilarity(Similarity):
|
|
105
|
+
"""Character-level similarity ratio (``difflib``); also works on lists of tokens."""
|
|
106
|
+
|
|
107
|
+
name = "edit_similarity"
|
|
108
|
+
|
|
109
|
+
def compute(self, a: Any, b: Any, *, context: Context = None) -> float:
|
|
110
|
+
if isinstance(a, str):
|
|
111
|
+
a, b = default_normalize(a), default_normalize(str(b))
|
|
112
|
+
if len(a) == 0 and len(b) == 0:
|
|
113
|
+
return 1.0
|
|
114
|
+
return difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Small statistics helpers (standard library only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import random
|
|
5
|
+
from typing import Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mean(values: Sequence[float]) -> float:
|
|
9
|
+
if not values:
|
|
10
|
+
raise ValueError("mean() of an empty sequence")
|
|
11
|
+
return sum(values) / len(values)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def bootstrap_ci(
|
|
15
|
+
values: Sequence[float], n_boot: int = 2000, alpha: float = 0.05, seed: int = 0
|
|
16
|
+
) -> Tuple[float, float]:
|
|
17
|
+
"""Percentile bootstrap confidence interval for the mean of ``values``."""
|
|
18
|
+
if not values:
|
|
19
|
+
raise ValueError("bootstrap_ci() of an empty sequence")
|
|
20
|
+
if len(values) == 1:
|
|
21
|
+
return (values[0], values[0])
|
|
22
|
+
rng = random.Random(seed)
|
|
23
|
+
n = len(values)
|
|
24
|
+
means = sorted(sum(values[rng.randrange(n)] for _ in range(n)) / n for _ in range(n_boot))
|
|
25
|
+
lo = means[int((alpha / 2) * n_boot)]
|
|
26
|
+
hi = means[min(n_boot - 1, int((1 - alpha / 2) * n_boot))]
|
|
27
|
+
return (lo, hi)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Result and data types shared across the package."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
Pair = Tuple[int, int]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class SimilarityResult:
|
|
12
|
+
"""Outcome of comparing two items. ``score`` is always in [0, 1] (1 = identical)."""
|
|
13
|
+
|
|
14
|
+
score: float
|
|
15
|
+
raw: Optional[float] = None # the un-normalised value, e.g. a 1-5 judge rating
|
|
16
|
+
reason: Optional[str] = None # free-text rationale, e.g. from an LLM judge
|
|
17
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PairScore:
|
|
22
|
+
i: int
|
|
23
|
+
j: int
|
|
24
|
+
score: float
|
|
25
|
+
raw: Optional[float] = None
|
|
26
|
+
reason: Optional[str] = None
|
|
27
|
+
dimensions: Optional[Dict[str, float]] = None # per-rubric-dimension scores in [0, 1]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ConsistencyResult:
|
|
32
|
+
"""Consistency of one group of N items (e.g. N runs of the same input)."""
|
|
33
|
+
|
|
34
|
+
metric: str
|
|
35
|
+
score: float # headline value in [0, 1]; 1 = fully consistent
|
|
36
|
+
min_score: float # the weakest compared pair (the "worst-case" view)
|
|
37
|
+
n_items: int
|
|
38
|
+
n_pairs: int # pairs actually compared (== number of similarity calls)
|
|
39
|
+
pairs: List[PairScore] = field(default_factory=list)
|
|
40
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
43
|
+
return asdict(self)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: repeatability-evals
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows.
|
|
5
|
+
Project-URL: Homepage, https://github.com/krishna-gogineni-765/repeatability-evals
|
|
6
|
+
Project-URL: Issues, https://github.com/krishna-gogineni-765/repeatability-evals/issues
|
|
7
|
+
Author: Krishna Gogineni
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,consistency,evaluation,llm,llm-as-judge,non-determinism,rag,reliability,repeatability,retrieval
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# repeatability-evals
|
|
21
|
+
|
|
22
|
+
**Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows.**
|
|
23
|
+
|
|
24
|
+
Composable tools for measuring **how consistent an LLM system is with itself**: the same
|
|
25
|
+
input run several times, or an input and its paraphrases, and how much the outputs agree.
|
|
26
|
+
|
|
27
|
+
Most eval libraries score one run for quality. A system can average 90% on quality and still
|
|
28
|
+
give a customer a different answer every time they ask. This package measures that spread.
|
|
29
|
+
|
|
30
|
+
It is deliberately **unaware of what you are comparing**. It has no notion of "answers",
|
|
31
|
+
"retrieval" or "agents": it compares *items*. You choose the items and the similarity:
|
|
32
|
+
|
|
33
|
+
| You have N of ... | Use a similarity such as |
|
|
34
|
+
|--------------------------------------|--------------------------------------------------|
|
|
35
|
+
| final answers (free text) | `LLMJudge`, `EmbeddingCosine`, `RougeL` |
|
|
36
|
+
| retrieved chunk IDs | `JaccardSimilarity` |
|
|
37
|
+
| retrieved chunk text | `NgramJaccard` (robust to re-chunking) |
|
|
38
|
+
| ranked result lists | `RankBiasedOverlap` |
|
|
39
|
+
| agent tool-call traces | `EditSimilarity` / `ExactMatch` on a trace string|
|
|
40
|
+
| anything else | subclass `Similarity`, or pass a plain function |
|
|
41
|
+
|
|
42
|
+
Consistency is **not** correctness: a system that is consistently wrong scores 1.0. Pair this
|
|
43
|
+
with an accuracy metric.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install repeatability-evals # pure standard library, no required dependencies
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from repeatability_evals import (
|
|
55
|
+
PairwiseConsistency, JaccardSimilarity, NgramJaccard, LLMJudge, evaluate_groups,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 1. Retrieval: did the 5 runs fetch the same chunks?
|
|
59
|
+
ids = PairwiseConsistency(JaccardSimilarity())
|
|
60
|
+
print(ids.evaluate([["a", "b", "c"], ["a", "b", "d"], ["a", "b", "c"]]).score)
|
|
61
|
+
|
|
62
|
+
# 2. Same evidence, different chunk boundaries?
|
|
63
|
+
text = PairwiseConsistency(NgramJaccard(n=3))
|
|
64
|
+
print(text.evaluate([["backup jobs run nightly"], ["backup jobs", "run nightly"]]).score)
|
|
65
|
+
|
|
66
|
+
# 3. Answers: an LLM judge scores semantic equivalence. Bring any `str -> str` function.
|
|
67
|
+
def complete(prompt: str) -> str: ... # your model call
|
|
68
|
+
answers = PairwiseConsistency(LLMJudge(complete), agreement_threshold=0.75)
|
|
69
|
+
r = answers.evaluate(["Retention is 30 days.", "30 days.", "Retention is 90 days."],
|
|
70
|
+
context={"query": "How long are backups retained?"})
|
|
71
|
+
print(r.score, r.details["agreement_rate"], [p.reason for p in r.pairs])
|
|
72
|
+
|
|
73
|
+
# 4. A whole dataset: one group per question, with a bootstrap confidence interval.
|
|
74
|
+
report = evaluate_groups({"q1": runs_q1, "q2": runs_q2}, ids)
|
|
75
|
+
print(report.score, report.ci, report.worst(3))
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Design
|
|
79
|
+
|
|
80
|
+
Three small pieces, each replaceable:
|
|
81
|
+
|
|
82
|
+
* **`Similarity`**: compare two items, return a score in `[0, 1]` (optionally with a raw value
|
|
83
|
+
and a rationale). Built in: set overlap (`Jaccard`, `Dice`, `OverlapCoefficient`), text
|
|
84
|
+
(`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`), ranking (`RankBiasedOverlap`),
|
|
85
|
+
embeddings (`EmbeddingCosine`, bring your own embed function), and `RubricJudge` and `LLMJudge`.
|
|
86
|
+
* **`PairSampler`**: which pairs of the N items to compare (see below).
|
|
87
|
+
* **`ConsistencyMetric`**: combines them. `PairwiseConsistency` averages similarity over
|
|
88
|
+
sampled pairs; `ClusterConsistency` groups items into "same answer" clusters and reports the
|
|
89
|
+
largest cluster's share plus the entropy of the partition; `TotalAgreement` is the
|
|
90
|
+
all-N-identical rate.
|
|
91
|
+
|
|
92
|
+
Everything has an async twin (`aevaluate`, `aevaluate_groups`) with bounded concurrency, which
|
|
93
|
+
matters when the similarity is an LLM call.
|
|
94
|
+
|
|
95
|
+
### Sampling instead of all pairs
|
|
96
|
+
|
|
97
|
+
All pairs cost N(N-1)/2 comparisons: 10 for 5 runs, 45 for 10. The default `CycleSampler`
|
|
98
|
+
compares a random cycle through the runs, so N pairs, and every run is compared at least twice.
|
|
99
|
+
It is seeded and reproducible. `AllPairs()` gives the exact value; `RandomPairs(k)` and
|
|
100
|
+
`AnchorPairs()` (every run against one reference) are also available. `benchmarks/sampling_error.py`
|
|
101
|
+
measures the error this introduces on synthetic data so you can see the trade-off for your N.
|
|
102
|
+
|
|
103
|
+
### The LLM judges
|
|
104
|
+
|
|
105
|
+
`RubricJudge` is the recommended judge for free-text outputs. One call scores five dimensions
|
|
106
|
+
of agreement (1-5 each): `core_facts`, `specifics` (names, numbers, dates), `supporting_detail`,
|
|
107
|
+
`stance` (answer vs hedge vs refuse) and `format`. The headline score is their weighted mean
|
|
108
|
+
(core facts count most), and `PairwiseConsistency` reports the mean per dimension in
|
|
109
|
+
`details["dimensions"]`, so you can see *what* varies between runs: a system whose facts are
|
|
110
|
+
stable but whose formatting drifts looks very different from one that changes its answer.
|
|
111
|
+
Pass your own `Dimension`s to add, remove or reweight (e.g. for agent traces).
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from repeatability_evals import RubricJudge, PairwiseConsistency
|
|
115
|
+
m = PairwiseConsistency(RubricJudge(complete))
|
|
116
|
+
r = m.evaluate(runs, context={"query": question})
|
|
117
|
+
print(r.score, r.details["dimensions"]) # {'core_facts': 0.95, 'format': 0.4, ...}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`LLMJudge` is the single-score version (one 1-5 semantic-equivalence rating). Both take any completion function, so it works with any provider or a local model. The
|
|
121
|
+
default rubrics (1-5, normalised to `[0, 1]`) are in `repeatability_evals.prompts`. `symmetric=True`
|
|
122
|
+
judges both orderings to cancel position bias at twice the cost. **Validate any judge against
|
|
123
|
+
human ratings on your own data before relying on it**; agreement depends on the domain and the
|
|
124
|
+
judge model.
|
|
125
|
+
|
|
126
|
+
## Writing your own similarity
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from repeatability_evals import Similarity, PairwiseConsistency
|
|
130
|
+
|
|
131
|
+
class SameFirstSentence(Similarity):
|
|
132
|
+
name = "same_first_sentence"
|
|
133
|
+
def compute(self, a, b, *, context=None):
|
|
134
|
+
return float(a.split(".")[0] == b.split(".")[0])
|
|
135
|
+
|
|
136
|
+
PairwiseConsistency(SameFirstSentence())
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
or just `PairwiseConsistency(lambda a, b: ...)`.
|
|
140
|
+
|
|
141
|
+
## Status
|
|
142
|
+
|
|
143
|
+
Early (0.1). The API may change before 1.0.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|