evalaudit 0.0.1__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.
- evalaudit-0.0.1/.github/workflows/tests.yml +20 -0
- evalaudit-0.0.1/.gitignore +18 -0
- evalaudit-0.0.1/CLAUDE.md +39 -0
- evalaudit-0.0.1/LICENSE +21 -0
- evalaudit-0.0.1/PKG-INFO +134 -0
- evalaudit-0.0.1/README.md +110 -0
- evalaudit-0.0.1/evalaudit/__init__.py +7 -0
- evalaudit-0.0.1/evalaudit/_types.py +66 -0
- evalaudit-0.0.1/evalaudit/scores.py +136 -0
- evalaudit-0.0.1/examples/.gitkeep +0 -0
- evalaudit-0.0.1/pyproject.toml +38 -0
- evalaudit-0.0.1/tests/__init__.py +0 -0
- evalaudit-0.0.1/tests/test_scores.py +181 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.9", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[dev]"
|
|
20
|
+
- run: pytest -q
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# CLAUDE.md — evalaudit project rules
|
|
2
|
+
|
|
3
|
+
## Design invariants
|
|
4
|
+
|
|
5
|
+
- Every public function returns a frozen dataclass from `_types.py`.
|
|
6
|
+
- Every return type has a `summary()` that produces a plain-English verdict.
|
|
7
|
+
- Never return a bare p-value. Always report effect size plus confidence interval.
|
|
8
|
+
- Dependencies are numpy, scipy, and pandas only.
|
|
9
|
+
|
|
10
|
+
## Testing
|
|
11
|
+
|
|
12
|
+
- Tests are written before implementations and must not be edited to make code pass.
|
|
13
|
+
- Coverage tests (simulation-based) verify that intervals cover at the claimed rate.
|
|
14
|
+
|
|
15
|
+
## Implementation style
|
|
16
|
+
|
|
17
|
+
- Vectorise bootstraps in numpy. Draw index matrices in one call and compute row statistics — no Python loops over resamples.
|
|
18
|
+
- Use `rng.integers(0, n, size=(n_boot, n))` for bootstrap index matrices.
|
|
19
|
+
|
|
20
|
+
## Build order
|
|
21
|
+
|
|
22
|
+
scores → compare → agreement → pairwise → judge → power → audit
|
|
23
|
+
|
|
24
|
+
The core of the package is **agreement**, **judge**, and **pairwise** — these are the point, not extras. scores and compare are the foundation they stand on.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
- Install dev: `pip install -e ".[dev]"`
|
|
29
|
+
- Run tests: `pytest -q`
|
|
30
|
+
|
|
31
|
+
## Git
|
|
32
|
+
Never run `git commit`, `git push`, or any history-rewriting command.
|
|
33
|
+
Make changes and show the diff. The user reviews and commits.
|
|
34
|
+
|
|
35
|
+
## Writing style
|
|
36
|
+
Write plainly, the way a knowledgeable person would talk. No em-dashes.
|
|
37
|
+
No "not X, but Y" constructions. No colon-then-reveal sentences.
|
|
38
|
+
Short, direct sentences over clever phrasing. This applies to docstrings,
|
|
39
|
+
README text, commit messages, and the summary() strings.
|
evalaudit-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hezron Mokaya
|
|
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.
|
evalaudit-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: evalaudit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Statistical validity checks for human-graded AI evaluations. Rater agreement, judge validation, and whether your result holds up.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ondibahezron-glitch/evalaudit
|
|
6
|
+
Project-URL: Issues, https://github.com/ondibahezron-glitch/evalaudit/issues
|
|
7
|
+
Author-email: Hezron Mokaya <ondibahezron@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: annotation-quality,bradley-terry,evaluation,inter-rater-reliability,krippendorff,llm,llm-as-judge,statistics
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: numpy>=1.22
|
|
18
|
+
Requires-Dist: pandas>=1.5
|
|
19
|
+
Requires-Dist: scipy>=1.9
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# evalaudit
|
|
26
|
+
|
|
27
|
+
[](https://github.com/ondibahezron-glitch/evalaudit/actions/workflows/tests.yml)
|
|
28
|
+
|
|
29
|
+
**Statistical validity checks for human-graded AI evaluations.**
|
|
30
|
+
|
|
31
|
+
Three experts graded 200 items. One of them disagreed with the other two on
|
|
32
|
+
almost everything. Nobody noticed, because nobody computed agreement — and
|
|
33
|
+
half the items were only graded once, so it could not have been computed.
|
|
34
|
+
|
|
35
|
+
That evaluation produced a number. The number is now in a slide deck.
|
|
36
|
+
|
|
37
|
+
`evalaudit` is the set of checks that should have run first.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Start here
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from evalaudit import score_ci
|
|
45
|
+
|
|
46
|
+
wins = [1] * 58 + [0] * 42
|
|
47
|
+
print(score_ci(wins).summary())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
58.0% pass rate (95% CI: 48.2%-67.2%, n=100). The interval spans
|
|
52
|
+
19 points; treat differences smaller than that as unresolved.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The interval crosses 50%. On this data the new model might be worse than the
|
|
56
|
+
old one. That evaluation cannot support the decision being made with it.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Why this exists
|
|
61
|
+
|
|
62
|
+
`scipy` computes McNemar's test. `sklearn` computes Cohen's kappa. Several
|
|
63
|
+
libraries now put bootstrapped intervals on automated eval scores.
|
|
64
|
+
|
|
65
|
+
What is missing is anything built for the **human** side of evaluation: the
|
|
66
|
+
graders, the rubric, the LLM judge standing in for both. That is where the
|
|
67
|
+
expensive failures happen, and it is where almost no tooling exists.
|
|
68
|
+
|
|
69
|
+
Every function returns an object with a `.summary()`. The numbers are the
|
|
70
|
+
input to the audit. The sentence is the output.
|
|
71
|
+
|
|
72
|
+
Two rules the library follows throughout:
|
|
73
|
+
|
|
74
|
+
- **Never report a bare p-value.** Always an effect size and an interval.
|
|
75
|
+
- **State what the data cannot support**, not only what it can.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## What it checks
|
|
80
|
+
|
|
81
|
+
| Module | Question |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `agreement` | Do your graders agree? Which grader is the outlier? Which items are ambiguous rather than hard? |
|
|
84
|
+
| `judge` | Does your LLM judge track your humans — and on which slices does it stop? Is it biased by position or length? |
|
|
85
|
+
| `pairwise` | In a blind pairwise comparison, which models are actually separable? |
|
|
86
|
+
| `power` | Could this many comparisons ever have detected the effect you care about? |
|
|
87
|
+
| `scores` | Is there an interval around your headline number, and how wide? |
|
|
88
|
+
| `compare` | Does the margin between two systems survive a paired test? |
|
|
89
|
+
| `audit` | All of the above, as a report ranked by what changes the conclusion. |
|
|
90
|
+
|
|
91
|
+
The first four are the point. The last three are the foundation they stand on.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Status
|
|
96
|
+
|
|
97
|
+
Early. `scores` is implemented. `agreement`, `judge` and `pairwise` are
|
|
98
|
+
specified and landing over the coming weeks.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Coverage
|
|
103
|
+
|
|
104
|
+
A statistics package earns trust by showing its intervals cover at the rate
|
|
105
|
+
they claim. Every interval here is tested by simulation: generate 1,000
|
|
106
|
+
datasets with a known truth, confirm the 95% interval contains it about 95% of
|
|
107
|
+
the time. Those tests run in CI and you can read them in `tests/`.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Related
|
|
112
|
+
|
|
113
|
+
[`promptstats`](https://pypi.org/project/promptstats/) covers the automated
|
|
114
|
+
side well — prompt sensitivity, model comparison across prompt variations,
|
|
115
|
+
bootstrapped intervals on benchmark scores. If your evaluation has no human
|
|
116
|
+
graders in it, start there.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Install
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
git clone https://github.com/ondibahezron-glitch/evalaudit.git
|
|
124
|
+
cd evalaudit
|
|
125
|
+
pip install -e ".[dev]"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Python 3.9+, numpy, scipy, pandas. Nothing else. PyPI package coming soon.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Licence
|
|
133
|
+
|
|
134
|
+
MIT.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# evalaudit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ondibahezron-glitch/evalaudit/actions/workflows/tests.yml)
|
|
4
|
+
|
|
5
|
+
**Statistical validity checks for human-graded AI evaluations.**
|
|
6
|
+
|
|
7
|
+
Three experts graded 200 items. One of them disagreed with the other two on
|
|
8
|
+
almost everything. Nobody noticed, because nobody computed agreement — and
|
|
9
|
+
half the items were only graded once, so it could not have been computed.
|
|
10
|
+
|
|
11
|
+
That evaluation produced a number. The number is now in a slide deck.
|
|
12
|
+
|
|
13
|
+
`evalaudit` is the set of checks that should have run first.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Start here
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from evalaudit import score_ci
|
|
21
|
+
|
|
22
|
+
wins = [1] * 58 + [0] * 42
|
|
23
|
+
print(score_ci(wins).summary())
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
58.0% pass rate (95% CI: 48.2%-67.2%, n=100). The interval spans
|
|
28
|
+
19 points; treat differences smaller than that as unresolved.
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The interval crosses 50%. On this data the new model might be worse than the
|
|
32
|
+
old one. That evaluation cannot support the decision being made with it.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Why this exists
|
|
37
|
+
|
|
38
|
+
`scipy` computes McNemar's test. `sklearn` computes Cohen's kappa. Several
|
|
39
|
+
libraries now put bootstrapped intervals on automated eval scores.
|
|
40
|
+
|
|
41
|
+
What is missing is anything built for the **human** side of evaluation: the
|
|
42
|
+
graders, the rubric, the LLM judge standing in for both. That is where the
|
|
43
|
+
expensive failures happen, and it is where almost no tooling exists.
|
|
44
|
+
|
|
45
|
+
Every function returns an object with a `.summary()`. The numbers are the
|
|
46
|
+
input to the audit. The sentence is the output.
|
|
47
|
+
|
|
48
|
+
Two rules the library follows throughout:
|
|
49
|
+
|
|
50
|
+
- **Never report a bare p-value.** Always an effect size and an interval.
|
|
51
|
+
- **State what the data cannot support**, not only what it can.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## What it checks
|
|
56
|
+
|
|
57
|
+
| Module | Question |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `agreement` | Do your graders agree? Which grader is the outlier? Which items are ambiguous rather than hard? |
|
|
60
|
+
| `judge` | Does your LLM judge track your humans — and on which slices does it stop? Is it biased by position or length? |
|
|
61
|
+
| `pairwise` | In a blind pairwise comparison, which models are actually separable? |
|
|
62
|
+
| `power` | Could this many comparisons ever have detected the effect you care about? |
|
|
63
|
+
| `scores` | Is there an interval around your headline number, and how wide? |
|
|
64
|
+
| `compare` | Does the margin between two systems survive a paired test? |
|
|
65
|
+
| `audit` | All of the above, as a report ranked by what changes the conclusion. |
|
|
66
|
+
|
|
67
|
+
The first four are the point. The last three are the foundation they stand on.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Status
|
|
72
|
+
|
|
73
|
+
Early. `scores` is implemented. `agreement`, `judge` and `pairwise` are
|
|
74
|
+
specified and landing over the coming weeks.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Coverage
|
|
79
|
+
|
|
80
|
+
A statistics package earns trust by showing its intervals cover at the rate
|
|
81
|
+
they claim. Every interval here is tested by simulation: generate 1,000
|
|
82
|
+
datasets with a known truth, confirm the 95% interval contains it about 95% of
|
|
83
|
+
the time. Those tests run in CI and you can read them in `tests/`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Related
|
|
88
|
+
|
|
89
|
+
[`promptstats`](https://pypi.org/project/promptstats/) covers the automated
|
|
90
|
+
side well — prompt sensitivity, model comparison across prompt variations,
|
|
91
|
+
bootstrapped intervals on benchmark scores. If your evaluation has no human
|
|
92
|
+
graders in it, start there.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Install
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
git clone https://github.com/ondibahezron-glitch/evalaudit.git
|
|
100
|
+
cd evalaudit
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Python 3.9+, numpy, scipy, pandas. Nothing else. PyPI package coming soon.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Licence
|
|
109
|
+
|
|
110
|
+
MIT.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Return types for evalaudit.
|
|
2
|
+
|
|
3
|
+
Every public function returns one of these. Each carries the numbers and a
|
|
4
|
+
``summary()`` that states, in plain English, what the numbers do and do not
|
|
5
|
+
support. The summary is the point of the library: scipy already computes
|
|
6
|
+
McNemar, but nothing tells a product manager their result does not hold.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _pct(x: float) -> str:
|
|
15
|
+
return f"{x * 100:.1f}%"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ScoreCI:
|
|
20
|
+
"""Confidence interval on a single eval score."""
|
|
21
|
+
|
|
22
|
+
estimate: float
|
|
23
|
+
ci_low: float
|
|
24
|
+
ci_high: float
|
|
25
|
+
n: int
|
|
26
|
+
method: str
|
|
27
|
+
binary: bool
|
|
28
|
+
confidence: float = 0.95
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def margin(self) -> float:
|
|
32
|
+
"""Half-width of the interval."""
|
|
33
|
+
return (self.ci_high - self.ci_low) / 2
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def width(self) -> float:
|
|
37
|
+
return self.ci_high - self.ci_low
|
|
38
|
+
|
|
39
|
+
def summary(self) -> str:
|
|
40
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
41
|
+
if self.binary:
|
|
42
|
+
head = (
|
|
43
|
+
f"{_pct(self.estimate)} pass rate "
|
|
44
|
+
f"({conf} CI: {_pct(self.ci_low)}-{_pct(self.ci_high)}, n={self.n})."
|
|
45
|
+
)
|
|
46
|
+
span = f"{self.width * 100:.0f} points"
|
|
47
|
+
else:
|
|
48
|
+
head = (
|
|
49
|
+
f"Mean score {self.estimate:.3f} "
|
|
50
|
+
f"({conf} CI: {self.ci_low:.3f}-{self.ci_high:.3f}, n={self.n})."
|
|
51
|
+
)
|
|
52
|
+
span = f"{self.width:.3f}"
|
|
53
|
+
|
|
54
|
+
tail = (
|
|
55
|
+
f" The interval spans {span}; treat differences smaller than that "
|
|
56
|
+
f"as unresolved."
|
|
57
|
+
)
|
|
58
|
+
if self.n < 30:
|
|
59
|
+
tail += (
|
|
60
|
+
f" With only {self.n} observations this estimate is weak "
|
|
61
|
+
f"regardless of the point value."
|
|
62
|
+
)
|
|
63
|
+
return head + tail
|
|
64
|
+
|
|
65
|
+
def __str__(self) -> str: # pragma: no cover
|
|
66
|
+
return self.summary()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Confidence intervals on a single eval score.
|
|
2
|
+
|
|
3
|
+
The most common missing piece in published eval results: a pass rate with no
|
|
4
|
+
interval around it. At n=50 the interval is roughly +/- 10 points, which is
|
|
5
|
+
usually wider than the difference being claimed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional, Sequence
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy import stats as _stats
|
|
14
|
+
|
|
15
|
+
from ._types import ScoreCI
|
|
16
|
+
|
|
17
|
+
__all__ = ["score_ci"]
|
|
18
|
+
|
|
19
|
+
_VALID_METHODS = {"auto", "wilson", "bootstrap", "t"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def score_ci(
|
|
23
|
+
scores: Sequence[float],
|
|
24
|
+
method: str = "auto",
|
|
25
|
+
confidence: float = 0.95,
|
|
26
|
+
n_boot: int = 10000,
|
|
27
|
+
seed: Optional[int] = None,
|
|
28
|
+
) -> ScoreCI:
|
|
29
|
+
"""Confidence interval on an eval score.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
scores
|
|
34
|
+
Binary (0/1) or continuous values, one per evaluated item.
|
|
35
|
+
method
|
|
36
|
+
``"auto"`` picks Wilson for binary data and bootstrap for continuous.
|
|
37
|
+
``"wilson"`` is the score interval for a proportion. Use it rather than
|
|
38
|
+
the normal approximation, which is visibly wrong at small n and near
|
|
39
|
+
the boundaries, and can return bounds outside [0, 1].
|
|
40
|
+
``"bootstrap"`` is the percentile bootstrap of the mean.
|
|
41
|
+
``"t"`` is the Student-t interval on the mean.
|
|
42
|
+
confidence
|
|
43
|
+
Nominal coverage, default 0.95.
|
|
44
|
+
n_boot
|
|
45
|
+
Bootstrap resamples. 1000 is enough for a consulting report;
|
|
46
|
+
the default is generous.
|
|
47
|
+
seed
|
|
48
|
+
Seeds the bootstrap so results are reproducible. Always set this in
|
|
49
|
+
anything you publish.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
ScoreCI
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
>>> score_ci([1] * 42 + [0] * 8).summary()
|
|
58
|
+
'84.0% pass rate (95% CI: 71.5%-91.7%, n=50). ...'
|
|
59
|
+
"""
|
|
60
|
+
x = np.asarray(scores, dtype=float)
|
|
61
|
+
n = len(x)
|
|
62
|
+
if n == 0:
|
|
63
|
+
raise ValueError("scores must not be empty")
|
|
64
|
+
if method not in _VALID_METHODS:
|
|
65
|
+
raise ValueError(f"method must be one of {_VALID_METHODS}, got {method!r}")
|
|
66
|
+
|
|
67
|
+
binary = _is_binary(x)
|
|
68
|
+
|
|
69
|
+
if method == "auto":
|
|
70
|
+
method = "wilson" if binary else "bootstrap"
|
|
71
|
+
|
|
72
|
+
estimate = float(np.mean(x))
|
|
73
|
+
|
|
74
|
+
if method == "wilson":
|
|
75
|
+
k = int(np.sum(x))
|
|
76
|
+
ci_low, ci_high = _wilson(k, n, confidence)
|
|
77
|
+
elif method == "bootstrap":
|
|
78
|
+
ci_low, ci_high = _bootstrap_mean(x, confidence, n_boot, seed)
|
|
79
|
+
else: # "t"
|
|
80
|
+
alpha = 1 - confidence
|
|
81
|
+
sem = float(np.std(x, ddof=1) / np.sqrt(n))
|
|
82
|
+
t_crit = _stats.t.ppf(1 - alpha / 2, df=n - 1)
|
|
83
|
+
ci_low = estimate - t_crit * sem
|
|
84
|
+
ci_high = estimate + t_crit * sem
|
|
85
|
+
|
|
86
|
+
return ScoreCI(
|
|
87
|
+
estimate=estimate,
|
|
88
|
+
ci_low=ci_low,
|
|
89
|
+
ci_high=ci_high,
|
|
90
|
+
n=n,
|
|
91
|
+
method=method,
|
|
92
|
+
binary=binary,
|
|
93
|
+
confidence=confidence,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_binary(x: np.ndarray) -> bool:
|
|
98
|
+
"""True when every value is 0 or 1."""
|
|
99
|
+
return bool(np.all((x == 0) | (x == 1)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _wilson(k: int, n: int, confidence: float) -> tuple[float, float]:
|
|
103
|
+
"""Wilson score interval for a proportion.
|
|
104
|
+
|
|
105
|
+
(p + z^2/2n +/- z * sqrt( p(1-p)/n + z^2/4n^2 )) / (1 + z^2/n)
|
|
106
|
+
|
|
107
|
+
Clip to [0, 1] at the end.
|
|
108
|
+
"""
|
|
109
|
+
p = k / n
|
|
110
|
+
z = _stats.norm.ppf(1 - (1 - confidence) / 2)
|
|
111
|
+
z2 = z * z
|
|
112
|
+
denom = 1 + z2 / n
|
|
113
|
+
centre = (p + z2 / (2 * n)) / denom
|
|
114
|
+
margin = (z / denom) * np.sqrt(p * (1 - p) / n + z2 / (4 * n * n))
|
|
115
|
+
lo = float(np.clip(centre - margin, 0.0, 1.0))
|
|
116
|
+
hi = float(np.clip(centre + margin, 0.0, 1.0))
|
|
117
|
+
return lo, hi
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _bootstrap_mean(
|
|
121
|
+
x: np.ndarray, confidence: float, n_boot: int, seed: Optional[int]
|
|
122
|
+
) -> tuple[float, float]:
|
|
123
|
+
"""Percentile bootstrap of the mean.
|
|
124
|
+
|
|
125
|
+
Vectorise this. Draw an (n_boot, n) index matrix with
|
|
126
|
+
``rng.integers(0, n, size=(n_boot, n))`` and take row means in one pass.
|
|
127
|
+
A Python loop here is the difference between instant and slow on a laptop.
|
|
128
|
+
"""
|
|
129
|
+
rng = np.random.default_rng(seed)
|
|
130
|
+
n = len(x)
|
|
131
|
+
idx = rng.integers(0, n, size=(n_boot, n))
|
|
132
|
+
means = x[idx].mean(axis=1)
|
|
133
|
+
alpha = 1 - confidence
|
|
134
|
+
lo = float(np.percentile(means, 100 * alpha / 2))
|
|
135
|
+
hi = float(np.percentile(means, 100 * (1 - alpha / 2)))
|
|
136
|
+
return lo, hi
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "evalaudit"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Statistical validity checks for human-graded AI evaluations. Rater agreement, judge validation, and whether your result holds up."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Hezron Mokaya", email = "ondibahezron@gmail.com" }]
|
|
13
|
+
keywords = ["llm", "evaluation", "statistics", "inter-rater-reliability", "krippendorff", "bradley-terry", "llm-as-judge", "annotation-quality"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"numpy>=1.22",
|
|
23
|
+
"scipy>=1.9",
|
|
24
|
+
"pandas>=1.5",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=7.0", "pytest-cov"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/ondibahezron-glitch/evalaudit"
|
|
32
|
+
Issues = "https://github.com/ondibahezron-glitch/evalaudit/issues"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["evalaudit"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Tests for evalaudit.scores.
|
|
2
|
+
|
|
3
|
+
Written before the implementation. Make these pass.
|
|
4
|
+
|
|
5
|
+
The fixtures in test_wilson_known_values are exact Wilson score intervals
|
|
6
|
+
computed independently; if your implementation returns these, it is correct.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from evalaudit.scores import score_ci
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# --------------------------------------------------------------------------
|
|
16
|
+
# Wilson interval, binary data
|
|
17
|
+
# --------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
@pytest.mark.parametrize(
|
|
20
|
+
"k, n, expected_low, expected_high",
|
|
21
|
+
[
|
|
22
|
+
(42, 50, 0.714858, 0.916626),
|
|
23
|
+
(9, 10, 0.595850, 0.982124),
|
|
24
|
+
(0, 20, 0.000000, 0.161125),
|
|
25
|
+
(20, 20, 0.838875, 1.000000),
|
|
26
|
+
(58, 100, 0.482065, 0.672016),
|
|
27
|
+
],
|
|
28
|
+
)
|
|
29
|
+
def test_wilson_known_values(k, n, expected_low, expected_high):
|
|
30
|
+
scores = [1] * k + [0] * (n - k)
|
|
31
|
+
r = score_ci(scores, method="wilson")
|
|
32
|
+
assert r.ci_low == pytest.approx(expected_low, abs=1e-5)
|
|
33
|
+
assert r.ci_high == pytest.approx(expected_high, abs=1e-5)
|
|
34
|
+
assert r.estimate == pytest.approx(k / n)
|
|
35
|
+
assert r.n == n
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_wilson_never_leaves_unit_interval():
|
|
39
|
+
"""The normal approximation goes below 0 here. Wilson must not."""
|
|
40
|
+
r = score_ci([0] * 30 + [1], method="wilson")
|
|
41
|
+
assert r.ci_low >= 0.0
|
|
42
|
+
assert r.ci_high <= 1.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_auto_picks_wilson_for_binary():
|
|
46
|
+
r = score_ci([1, 0, 1, 1, 0, 1, 1, 1, 0, 1])
|
|
47
|
+
assert r.method == "wilson"
|
|
48
|
+
assert r.binary is True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_auto_picks_bootstrap_for_continuous():
|
|
52
|
+
r = score_ci([0.2, 0.5, 0.9, 0.4, 0.7, 0.3], seed=0)
|
|
53
|
+
assert r.method == "bootstrap"
|
|
54
|
+
assert r.binary is False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
# Bootstrap, continuous data
|
|
59
|
+
# --------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def test_bootstrap_is_deterministic_with_seed():
|
|
62
|
+
xs = np.random.default_rng(1).normal(3.0, 1.0, 60)
|
|
63
|
+
a = score_ci(xs, method="bootstrap", seed=42, n_boot=2000)
|
|
64
|
+
b = score_ci(xs, method="bootstrap", seed=42, n_boot=2000)
|
|
65
|
+
assert a.ci_low == b.ci_low
|
|
66
|
+
assert a.ci_high == b.ci_high
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_bootstrap_brackets_the_mean():
|
|
70
|
+
xs = np.random.default_rng(2).normal(4.2, 0.8, 200)
|
|
71
|
+
r = score_ci(xs, method="bootstrap", seed=7)
|
|
72
|
+
assert r.ci_low < r.estimate < r.ci_high
|
|
73
|
+
assert r.estimate == pytest.approx(float(np.mean(xs)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_interval_narrows_with_more_data():
|
|
77
|
+
rng = np.random.default_rng(3)
|
|
78
|
+
small = score_ci(rng.normal(0, 1, 40), seed=1)
|
|
79
|
+
large = score_ci(rng.normal(0, 1, 4000), seed=1)
|
|
80
|
+
assert large.width < small.width
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --------------------------------------------------------------------------
|
|
84
|
+
# Coverage. This is the credibility argument for the whole package.
|
|
85
|
+
# --------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("true_p, n", [(0.5, 50), (0.9, 50), (0.3, 200)])
|
|
88
|
+
def test_wilson_coverage(true_p, n):
|
|
89
|
+
"""Across 1000 simulated datasets the 95% interval should cover the
|
|
90
|
+
true rate about 95% of the time. Wilson under-covers slightly at the
|
|
91
|
+
extremes, so the band is deliberately generous."""
|
|
92
|
+
rng = np.random.default_rng(11)
|
|
93
|
+
covered = 0
|
|
94
|
+
trials = 1000
|
|
95
|
+
for _ in range(trials):
|
|
96
|
+
sample = rng.binomial(1, true_p, n)
|
|
97
|
+
r = score_ci(sample, method="wilson")
|
|
98
|
+
if r.ci_low <= true_p <= r.ci_high:
|
|
99
|
+
covered += 1
|
|
100
|
+
rate = covered / trials
|
|
101
|
+
assert 0.92 <= rate <= 0.98, f"coverage was {rate:.3f}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_bootstrap_coverage():
|
|
105
|
+
rng = np.random.default_rng(12)
|
|
106
|
+
true_mean = 3.0
|
|
107
|
+
covered = 0
|
|
108
|
+
trials = 400
|
|
109
|
+
for _ in range(trials):
|
|
110
|
+
sample = rng.normal(true_mean, 1.0, 80)
|
|
111
|
+
r = score_ci(sample, method="bootstrap", n_boot=1000, seed=int(rng.integers(1e6)))
|
|
112
|
+
if r.ci_low <= true_mean <= r.ci_high:
|
|
113
|
+
covered += 1
|
|
114
|
+
rate = covered / trials
|
|
115
|
+
assert 0.91 <= rate <= 0.99, f"coverage was {rate:.3f}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------
|
|
119
|
+
# The summary is the product
|
|
120
|
+
# --------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def test_summary_mentions_estimate_and_bounds():
|
|
123
|
+
r = score_ci([1] * 42 + [0] * 8, method="wilson")
|
|
124
|
+
s = r.summary()
|
|
125
|
+
assert "84.0%" in s
|
|
126
|
+
assert "71.5%" in s or "71.4%" in s
|
|
127
|
+
assert "n=50" in s
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_summary_warns_on_small_n():
|
|
131
|
+
r = score_ci([1, 1, 0, 1, 1])
|
|
132
|
+
assert "5 observations" in r.summary()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------
|
|
136
|
+
# Input handling
|
|
137
|
+
# --------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_rejects_empty():
|
|
140
|
+
with pytest.raises(ValueError):
|
|
141
|
+
score_ci([])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_rejects_unknown_method():
|
|
145
|
+
with pytest.raises(ValueError):
|
|
146
|
+
score_ci([1, 0, 1], method="nonsense")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_accepts_numpy_list_and_series():
|
|
150
|
+
import pandas as pd
|
|
151
|
+
base = [1, 0, 1, 1, 0]
|
|
152
|
+
a = score_ci(base, method="wilson")
|
|
153
|
+
b = score_ci(np.array(base), method="wilson")
|
|
154
|
+
c = score_ci(pd.Series(base), method="wilson")
|
|
155
|
+
assert a.ci_low == pytest.approx(b.ci_low) == pytest.approx(c.ci_low)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --------------------------------------------------------------------------
|
|
159
|
+
# Non-default confidence levels
|
|
160
|
+
# --------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def test_confidence_level_affects_width():
|
|
163
|
+
"""A hardcoded z=1.96 would pass every 0.95 test. This catches that."""
|
|
164
|
+
data = [1] * 42 + [0] * 8
|
|
165
|
+
|
|
166
|
+
r90 = score_ci(data, method="wilson", confidence=0.90)
|
|
167
|
+
r95 = score_ci(data, method="wilson", confidence=0.95)
|
|
168
|
+
r99 = score_ci(data, method="wilson", confidence=0.99)
|
|
169
|
+
|
|
170
|
+
assert r90.width < r95.width < r99.width
|
|
171
|
+
|
|
172
|
+
assert "90%" in r90.summary()
|
|
173
|
+
assert "95%" in r95.summary()
|
|
174
|
+
assert "99%" in r99.summary()
|
|
175
|
+
|
|
176
|
+
xs = np.random.default_rng(0).normal(5.0, 1.0, 80)
|
|
177
|
+
b90 = score_ci(xs, method="bootstrap", confidence=0.90, seed=1)
|
|
178
|
+
b95 = score_ci(xs, method="bootstrap", confidence=0.95, seed=1)
|
|
179
|
+
b99 = score_ci(xs, method="bootstrap", confidence=0.99, seed=1)
|
|
180
|
+
|
|
181
|
+
assert b90.width < b95.width < b99.width
|