repeatability-evals 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +17 -0
- repeatability_evals-0.1.0/.github/workflows/publish.yml +16 -0
- repeatability_evals-0.1.0/.gitignore +6 -0
- repeatability_evals-0.1.0/LICENSE +21 -0
- repeatability_evals-0.1.0/PKG-INFO +147 -0
- repeatability_evals-0.1.0/README.md +128 -0
- repeatability_evals-0.1.0/benchmarks/sampling_error.py +56 -0
- repeatability_evals-0.1.0/pyproject.toml +40 -0
- repeatability_evals-0.1.0/src/repeatability_evals/__init__.py +38 -0
- repeatability_evals-0.1.0/src/repeatability_evals/dataset.py +80 -0
- repeatability_evals-0.1.0/src/repeatability_evals/metrics.py +223 -0
- repeatability_evals-0.1.0/src/repeatability_evals/prompts.py +120 -0
- repeatability_evals-0.1.0/src/repeatability_evals/py.typed +0 -0
- repeatability_evals-0.1.0/src/repeatability_evals/sampling.py +102 -0
- repeatability_evals-0.1.0/src/repeatability_evals/similarity/__init__.py +14 -0
- repeatability_evals-0.1.0/src/repeatability_evals/similarity/base.py +85 -0
- repeatability_evals-0.1.0/src/repeatability_evals/similarity/embedding.py +56 -0
- repeatability_evals-0.1.0/src/repeatability_evals/similarity/judge.py +185 -0
- repeatability_evals-0.1.0/src/repeatability_evals/similarity/ranking.py +46 -0
- repeatability_evals-0.1.0/src/repeatability_evals/similarity/sets.py +53 -0
- repeatability_evals-0.1.0/src/repeatability_evals/similarity/text.py +114 -0
- repeatability_evals-0.1.0/src/repeatability_evals/stats.py +27 -0
- repeatability_evals-0.1.0/src/repeatability_evals/types.py +43 -0
- repeatability_evals-0.1.0/tests/test_dataset.py +67 -0
- repeatability_evals-0.1.0/tests/test_judge.py +177 -0
- repeatability_evals-0.1.0/tests/test_metrics.py +121 -0
- repeatability_evals-0.1.0/tests/test_sampling.py +66 -0
- repeatability_evals-0.1.0/tests/test_similarity.py +166 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push: { branches: [main] }
|
|
4
|
+
pull_request:
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
strategy:
|
|
9
|
+
matrix:
|
|
10
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with: { python-version: "${{ matrix.python-version }}" }
|
|
15
|
+
- run: pip install -e ".[dev]"
|
|
16
|
+
- run: ruff check .
|
|
17
|
+
- run: pytest -q
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
release:
|
|
4
|
+
types: [published]
|
|
5
|
+
jobs:
|
|
6
|
+
publish:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
environment: pypi
|
|
9
|
+
permissions:
|
|
10
|
+
id-token: write # PyPI trusted publishing; no API token needed
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with: { python-version: "3.12" }
|
|
15
|
+
- run: pip install build && python -m build
|
|
16
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Krishna Gogineni
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -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
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# repeatability-evals
|
|
2
|
+
|
|
3
|
+
**Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows.**
|
|
4
|
+
|
|
5
|
+
Composable tools for measuring **how consistent an LLM system is with itself**: the same
|
|
6
|
+
input run several times, or an input and its paraphrases, and how much the outputs agree.
|
|
7
|
+
|
|
8
|
+
Most eval libraries score one run for quality. A system can average 90% on quality and still
|
|
9
|
+
give a customer a different answer every time they ask. This package measures that spread.
|
|
10
|
+
|
|
11
|
+
It is deliberately **unaware of what you are comparing**. It has no notion of "answers",
|
|
12
|
+
"retrieval" or "agents": it compares *items*. You choose the items and the similarity:
|
|
13
|
+
|
|
14
|
+
| You have N of ... | Use a similarity such as |
|
|
15
|
+
|--------------------------------------|--------------------------------------------------|
|
|
16
|
+
| final answers (free text) | `LLMJudge`, `EmbeddingCosine`, `RougeL` |
|
|
17
|
+
| retrieved chunk IDs | `JaccardSimilarity` |
|
|
18
|
+
| retrieved chunk text | `NgramJaccard` (robust to re-chunking) |
|
|
19
|
+
| ranked result lists | `RankBiasedOverlap` |
|
|
20
|
+
| agent tool-call traces | `EditSimilarity` / `ExactMatch` on a trace string|
|
|
21
|
+
| anything else | subclass `Similarity`, or pass a plain function |
|
|
22
|
+
|
|
23
|
+
Consistency is **not** correctness: a system that is consistently wrong scores 1.0. Pair this
|
|
24
|
+
with an accuracy metric.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install repeatability-evals # pure standard library, no required dependencies
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from repeatability_evals import (
|
|
36
|
+
PairwiseConsistency, JaccardSimilarity, NgramJaccard, LLMJudge, evaluate_groups,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# 1. Retrieval: did the 5 runs fetch the same chunks?
|
|
40
|
+
ids = PairwiseConsistency(JaccardSimilarity())
|
|
41
|
+
print(ids.evaluate([["a", "b", "c"], ["a", "b", "d"], ["a", "b", "c"]]).score)
|
|
42
|
+
|
|
43
|
+
# 2. Same evidence, different chunk boundaries?
|
|
44
|
+
text = PairwiseConsistency(NgramJaccard(n=3))
|
|
45
|
+
print(text.evaluate([["backup jobs run nightly"], ["backup jobs", "run nightly"]]).score)
|
|
46
|
+
|
|
47
|
+
# 3. Answers: an LLM judge scores semantic equivalence. Bring any `str -> str` function.
|
|
48
|
+
def complete(prompt: str) -> str: ... # your model call
|
|
49
|
+
answers = PairwiseConsistency(LLMJudge(complete), agreement_threshold=0.75)
|
|
50
|
+
r = answers.evaluate(["Retention is 30 days.", "30 days.", "Retention is 90 days."],
|
|
51
|
+
context={"query": "How long are backups retained?"})
|
|
52
|
+
print(r.score, r.details["agreement_rate"], [p.reason for p in r.pairs])
|
|
53
|
+
|
|
54
|
+
# 4. A whole dataset: one group per question, with a bootstrap confidence interval.
|
|
55
|
+
report = evaluate_groups({"q1": runs_q1, "q2": runs_q2}, ids)
|
|
56
|
+
print(report.score, report.ci, report.worst(3))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Design
|
|
60
|
+
|
|
61
|
+
Three small pieces, each replaceable:
|
|
62
|
+
|
|
63
|
+
* **`Similarity`**: compare two items, return a score in `[0, 1]` (optionally with a raw value
|
|
64
|
+
and a rationale). Built in: set overlap (`Jaccard`, `Dice`, `OverlapCoefficient`), text
|
|
65
|
+
(`ExactMatch`, `NgramJaccard`, `RougeL`, `EditSimilarity`), ranking (`RankBiasedOverlap`),
|
|
66
|
+
embeddings (`EmbeddingCosine`, bring your own embed function), and `RubricJudge` and `LLMJudge`.
|
|
67
|
+
* **`PairSampler`**: which pairs of the N items to compare (see below).
|
|
68
|
+
* **`ConsistencyMetric`**: combines them. `PairwiseConsistency` averages similarity over
|
|
69
|
+
sampled pairs; `ClusterConsistency` groups items into "same answer" clusters and reports the
|
|
70
|
+
largest cluster's share plus the entropy of the partition; `TotalAgreement` is the
|
|
71
|
+
all-N-identical rate.
|
|
72
|
+
|
|
73
|
+
Everything has an async twin (`aevaluate`, `aevaluate_groups`) with bounded concurrency, which
|
|
74
|
+
matters when the similarity is an LLM call.
|
|
75
|
+
|
|
76
|
+
### Sampling instead of all pairs
|
|
77
|
+
|
|
78
|
+
All pairs cost N(N-1)/2 comparisons: 10 for 5 runs, 45 for 10. The default `CycleSampler`
|
|
79
|
+
compares a random cycle through the runs, so N pairs, and every run is compared at least twice.
|
|
80
|
+
It is seeded and reproducible. `AllPairs()` gives the exact value; `RandomPairs(k)` and
|
|
81
|
+
`AnchorPairs()` (every run against one reference) are also available. `benchmarks/sampling_error.py`
|
|
82
|
+
measures the error this introduces on synthetic data so you can see the trade-off for your N.
|
|
83
|
+
|
|
84
|
+
### The LLM judges
|
|
85
|
+
|
|
86
|
+
`RubricJudge` is the recommended judge for free-text outputs. One call scores five dimensions
|
|
87
|
+
of agreement (1-5 each): `core_facts`, `specifics` (names, numbers, dates), `supporting_detail`,
|
|
88
|
+
`stance` (answer vs hedge vs refuse) and `format`. The headline score is their weighted mean
|
|
89
|
+
(core facts count most), and `PairwiseConsistency` reports the mean per dimension in
|
|
90
|
+
`details["dimensions"]`, so you can see *what* varies between runs: a system whose facts are
|
|
91
|
+
stable but whose formatting drifts looks very different from one that changes its answer.
|
|
92
|
+
Pass your own `Dimension`s to add, remove or reweight (e.g. for agent traces).
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from repeatability_evals import RubricJudge, PairwiseConsistency
|
|
96
|
+
m = PairwiseConsistency(RubricJudge(complete))
|
|
97
|
+
r = m.evaluate(runs, context={"query": question})
|
|
98
|
+
print(r.score, r.details["dimensions"]) # {'core_facts': 0.95, 'format': 0.4, ...}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`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
|
|
102
|
+
default rubrics (1-5, normalised to `[0, 1]`) are in `repeatability_evals.prompts`. `symmetric=True`
|
|
103
|
+
judges both orderings to cancel position bias at twice the cost. **Validate any judge against
|
|
104
|
+
human ratings on your own data before relying on it**; agreement depends on the domain and the
|
|
105
|
+
judge model.
|
|
106
|
+
|
|
107
|
+
## Writing your own similarity
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from repeatability_evals import Similarity, PairwiseConsistency
|
|
111
|
+
|
|
112
|
+
class SameFirstSentence(Similarity):
|
|
113
|
+
name = "same_first_sentence"
|
|
114
|
+
def compute(self, a, b, *, context=None):
|
|
115
|
+
return float(a.split(".")[0] == b.split(".")[0])
|
|
116
|
+
|
|
117
|
+
PairwiseConsistency(SameFirstSentence())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
or just `PairwiseConsistency(lambda a, b: ...)`.
|
|
121
|
+
|
|
122
|
+
## Status
|
|
123
|
+
|
|
124
|
+
Early (0.1). The API may change before 1.0.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Estimate how much error pair subsampling adds versus scoring all pairs.
|
|
2
|
+
|
|
3
|
+
Synthetic by default; pass --runs-file (JSON: {question: [answer, ...]}) to use real runs
|
|
4
|
+
with ExactMatch-style token overlap (NgramJaccard) as a cheap stand-in for a judge.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import itertools
|
|
8
|
+
import json
|
|
9
|
+
import random
|
|
10
|
+
import statistics
|
|
11
|
+
|
|
12
|
+
from repeatability_evals import CycleSampler, NgramJaccard
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def synthetic(n_q, n_runs, rng):
|
|
16
|
+
out = []
|
|
17
|
+
for _ in range(n_q):
|
|
18
|
+
p = rng.choice([0.0, 0.1, 0.3, 0.6]) # per-question divergence
|
|
19
|
+
base = [rng.random() for _ in range(n_runs)]
|
|
20
|
+
out.append([0.0 if rng.random() > p else b for b in base])
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pair_score_synth(a, b):
|
|
25
|
+
return 1.0 - min(1.0, abs(a - b) * 3)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
ap = argparse.ArgumentParser()
|
|
30
|
+
ap.add_argument("--runs", type=int, default=5)
|
|
31
|
+
ap.add_argument("--questions", type=int, default=300)
|
|
32
|
+
ap.add_argument("--runs-file")
|
|
33
|
+
ap.add_argument("--seed", type=int, default=0)
|
|
34
|
+
a = ap.parse_args()
|
|
35
|
+
rng = random.Random(a.seed)
|
|
36
|
+
if a.runs_file:
|
|
37
|
+
groups = list(json.load(open(a.runs_file)).values())
|
|
38
|
+
sim = NgramJaccard(2)
|
|
39
|
+
score = lambda x, y: sim(x, y) # noqa: E731
|
|
40
|
+
else:
|
|
41
|
+
groups = synthetic(a.questions, a.runs, rng)
|
|
42
|
+
score = pair_score_synth
|
|
43
|
+
for budget in (None, 7, 8):
|
|
44
|
+
errs = []
|
|
45
|
+
for g in groups:
|
|
46
|
+
n = len(g)
|
|
47
|
+
full = statistics.mean(score(g[i], g[j]) for i, j in itertools.combinations(range(n), 2))
|
|
48
|
+
ps = CycleSampler(budget=budget, seed=rng.randrange(10**6)).sample(n)
|
|
49
|
+
sub = statistics.mean(score(g[i], g[j]) for i, j in ps)
|
|
50
|
+
errs.append(sub - full)
|
|
51
|
+
print(f"budget={budget or 'N'}: mean_abs_err={statistics.mean(map(abs, errs)):.3f} "
|
|
52
|
+
f"bias={statistics.mean(errs):+.4f} max_abs_err={max(map(abs, errs)):.3f}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "repeatability-evals"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Consistency evals for non-deterministic systems: LLM generation, agentic retrieval, agent workflows."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Krishna Gogineni" }]
|
|
13
|
+
keywords = ["llm", "evaluation", "consistency", "repeatability", "non-determinism", "rag", "retrieval", "agents", "reliability", "llm-as-judge"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [] # core is pure standard library
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=7", "ruff>=0.5"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/repeatability_evals"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 110
|
|
32
|
+
src = ["src", "tests"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
36
|
+
ignore = ["UP006", "UP007", "UP035", "UP045"] # keep typing.Optional/List for py3.9 readability
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/krishna-gogineni-765/repeatability-evals"
|
|
40
|
+
Issues = "https://github.com/krishna-gogineni-765/repeatability-evals/issues"
|
|
@@ -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)
|