gonogo-eval 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.
- gonogo_eval-0.1.0/.gitignore +11 -0
- gonogo_eval-0.1.0/LICENSE +21 -0
- gonogo_eval-0.1.0/PKG-INFO +179 -0
- gonogo_eval-0.1.0/README.md +159 -0
- gonogo_eval-0.1.0/examples/banking77_routing.py +147 -0
- gonogo_eval-0.1.0/examples/invoice_extraction.py +81 -0
- gonogo_eval-0.1.0/gonogo/__init__.py +44 -0
- gonogo_eval-0.1.0/gonogo/cases.py +103 -0
- gonogo_eval-0.1.0/gonogo/decide.py +165 -0
- gonogo_eval-0.1.0/gonogo/evaluate.py +83 -0
- gonogo_eval-0.1.0/gonogo/report.py +368 -0
- gonogo_eval-0.1.0/gonogo/scoring.py +220 -0
- gonogo_eval-0.1.0/gonogo/stats.py +219 -0
- gonogo_eval-0.1.0/pyproject.toml +36 -0
- gonogo_eval-0.1.0/tests/test_decide.py +98 -0
- gonogo_eval-0.1.0/tests/test_evaluate.py +253 -0
- gonogo_eval-0.1.0/tests/test_stats.py +157 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Dominguez
|
|
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,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gonogo-eval
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Decide whether an agent is good enough to ship. Honest statistics for pilot-scale case sets.
|
|
5
|
+
Project-URL: Homepage, https://github.com/keppy/gonogo
|
|
6
|
+
Project-URL: Source, https://github.com/keppy/gonogo
|
|
7
|
+
Author: James Dominguez
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,calibration,evaluation,llm,selective-prediction
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# gonogo
|
|
22
|
+
|
|
23
|
+
**47 out of 50 is not 94%.** It lands somewhere between 84% and 98%, so if you were aiming at 90%, you can't yet say you got there.
|
|
24
|
+
|
|
25
|
+
Feed `gonogo` your agent and your real cases. Back comes a decision: ship it, ship it behind a human-review threshold, or walk away. When the honest answer is "you don't have enough cases to know," it says that instead of guessing.
|
|
26
|
+
|
|
27
|
+
This is deliberately **not** another eval framework — several good ones already exist. What none of them do is convert a score into a deployment decision you can defend at the sample sizes pilots actually run: forty to a hundred cases, not ten thousand.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install gonogo-eval
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Installs as `gonogo-eval` (the plain `gonogo` name on PyPI belongs to an unrelated project); imports as `gonogo`.
|
|
34
|
+
|
|
35
|
+
## The idea
|
|
36
|
+
|
|
37
|
+
Evaluate an agent on a small set of real cases and three things go wrong.
|
|
38
|
+
|
|
39
|
+
**The point estimate flatters you.** 47/50 reads as 94%. The 95% interval is [83.8%, 97.9%]. Most tools print the 94% and stop there.
|
|
40
|
+
|
|
41
|
+
**Accuracy isn't what anyone's asking.** The real question is how much work you can hand over at 98% precision, and how many cases end up on someone's desk. Answering it means a risk–coverage curve over an abstention threshold. Almost nothing computes one.
|
|
42
|
+
|
|
43
|
+
**A dashboard isn't a decision.** Somebody still has to say ship or don't ship, and that call ought to fall out of the numbers rather than out of a meeting.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from gonogo import Case, evaluate
|
|
49
|
+
from gonogo.scoring import fields
|
|
50
|
+
|
|
51
|
+
cases = Case.from_jsonl("invoices.jsonl") # your real cases
|
|
52
|
+
|
|
53
|
+
def agent(case):
|
|
54
|
+
result = my_pipeline(case.input)
|
|
55
|
+
return result.data, result.confidence # confidence is optional but unlocks a lot
|
|
56
|
+
|
|
57
|
+
report = evaluate(agent, cases, scorer=fields(["vendor", "total"]), target=0.95)
|
|
58
|
+
print(report.markdown())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Your agent is any callable. No base class, no decorator, no framework to adopt. Return a bare value, a `(value, confidence)` tuple, or a `Prediction` when you want to signal abstention.
|
|
62
|
+
|
|
63
|
+
## What it tells you
|
|
64
|
+
|
|
65
|
+
Five verdicts, and only two of them mean ship:
|
|
66
|
+
|
|
67
|
+
| Verdict | Meaning |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `AUTOMATE` | The pass rate's lower bound clears your target. Ship it. |
|
|
70
|
+
| `AUTOMATE WITH REVIEW` | Not good enough overall, but a confident subset is. Ship behind a threshold, route the rest to a person. |
|
|
71
|
+
| `ASSIST ONLY` | Useful as a draft generator, not as an unattended step. |
|
|
72
|
+
| `DO NOT AUTOMATE` | Not a fit for this workflow as scoped. |
|
|
73
|
+
| `INSUFFICIENT EVIDENCE` | The estimate looks good but your sample can't support the claim. Here's roughly how many cases you'd need. |
|
|
74
|
+
|
|
75
|
+
That last one is the whole reason this exists. It's the verdict an honest consultant gives and a dashboard never does.
|
|
76
|
+
|
|
77
|
+
## Why confidence matters
|
|
78
|
+
|
|
79
|
+
If your agent reports a per-case confidence, `gonogo` finds the abstention threshold that maximizes how much you can automate while keeping precision's *lower bound* above your target, and reports the whole curve so you can see the tradeoff:
|
|
80
|
+
|
|
81
|
+
| Confidence floor | Handled | Precision | To review |
|
|
82
|
+
| --- | --- | --- | --- |
|
|
83
|
+
| 0.36 | 100% | 88.3% [77.8%, 94.2%] | 0 |
|
|
84
|
+
| 0.70 | 87% | 94.2% [84.4%, 98.0%] | 8 |
|
|
85
|
+
| 0.85 | 82% | 98.0% [89.3%, 99.6%] | 11 |
|
|
86
|
+
| 0.89 | 57% | 100.0% [89.8%, 100.0%] | 26 |
|
|
87
|
+
| 0.99 | 2% | 100.0% [20.7%, 100.0%] | 59 |
|
|
88
|
+
|
|
89
|
+
That last row is why it uses the lower bound and not the point estimate. 100% precision on a single case is not an operating point, and the interval says so.
|
|
90
|
+
|
|
91
|
+
Note what the table above actually proves: against a 95% target, *no* threshold works here. The 0.85 row looks great at 98.0% until you read its lower bound of 89.3%. So the verdict is `ASSIST ONLY`, not a ship — which is the answer you want before you wire it into production, not after.
|
|
92
|
+
|
|
93
|
+
It also checks whether your confidence means anything. Expected calibration error above 0.15 and the harness refuses to recommend a threshold at all — thresholding on a number that doesn't track correctness is theater.
|
|
94
|
+
|
|
95
|
+
## A real measurement
|
|
96
|
+
|
|
97
|
+
`examples/banking77_routing.py` runs against [Banking77](https://github.com/PolyAI-LDN/task-specific-datasets) — 13,083 genuine retail-bank customer messages across 77 intents. The agent is an ordinary TF-IDF nearest-centroid baseline, stdlib only, no model API.
|
|
98
|
+
|
|
99
|
+
On a pilot-sized sample of 250 cases against a 95% target:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
ASSIST ONLY: Use it to draft, keep a human on every case.
|
|
103
|
+
Pass rate 77.2% [71.6%, 82.0%] (full 3,080-case split: 80.7% [79.3%, 82.1%])
|
|
104
|
+
Calibration error 0.42 (ranks cases, scale unreliable)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The calibration table is the interesting part:
|
|
108
|
+
|
|
109
|
+
| Stated confidence | Cases | Mean confidence | Actual accuracy |
|
|
110
|
+
| --- | --- | --- | --- |
|
|
111
|
+
| 0.0–0.2 | 83 | 0.11 | 61% |
|
|
112
|
+
| 0.2–0.4 | 74 | 0.30 | 78% |
|
|
113
|
+
| 0.4–0.6 | 50 | 0.51 | 84% |
|
|
114
|
+
| 0.6–0.8 | 30 | 0.70 | 97% |
|
|
115
|
+
| 0.8–1.0 | 13 | 0.87 | 100% |
|
|
116
|
+
|
|
117
|
+
That model is *badly* miscalibrated — it says 0.11 and is right 61% of the time — while still ranking cases almost perfectly. Those are two different properties, and conflating them is a common way to throw away a usable signal. `gonogo` reports the calibration error, says the number isn't a probability, and still measures precision at each cut point empirically, because that measurement doesn't depend on the scale being meaningful.
|
|
118
|
+
|
|
119
|
+
## On LLM-as-judge
|
|
120
|
+
|
|
121
|
+
`scoring.judge` takes any `complete(prompt) -> str` callable, so there's no provider SDK in the dependency tree.
|
|
122
|
+
|
|
123
|
+
An unvalidated judge is the most common silent failure in agent evaluation. Hand-label a subset and run `validate_judge(judge_labels, human_labels)`:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
judge NOT USABLE: 90% agreement, kappa 0.00 on 30 hand-labelled cases
|
|
127
|
+
(kappa 0.00 is below 0.60 and it passes cases you failed; fix the rubric)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
That's the trap. A judge that rubber-stamps everything scores 90% agreement on a set that's 90% passes, and carries no information whatsoever. Kappa is the gate, not agreement.
|
|
131
|
+
|
|
132
|
+
## Reports
|
|
133
|
+
|
|
134
|
+
`report.markdown()` for a terminal or a PR comment; `report.html()` for a self-contained styled page with no external assets, which you can hand to whoever signs off; `report.to_dict()` for JSON.
|
|
135
|
+
|
|
136
|
+
## Non-goals
|
|
137
|
+
|
|
138
|
+
This is a reference implementation, around 800 lines, readable in one sitting. It will not grow into:
|
|
139
|
+
|
|
140
|
+
- a hosted service or dashboard
|
|
141
|
+
- tracing / observability
|
|
142
|
+
- prompt management or versioning
|
|
143
|
+
- an agent framework
|
|
144
|
+
- a public leaderboard
|
|
145
|
+
|
|
146
|
+
If you want those, use one of the platforms. This does one thing.
|
|
147
|
+
|
|
148
|
+
## Run the example
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python examples/invoice_extraction.py
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Sixty simulated invoices, an agent that's good but not perfect, no API key required. Real output:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
# Score report: Extract fields from invoice
|
|
158
|
+
|
|
159
|
+
**ASSIST ONLY**: Use it to draft, keep a human on every case.
|
|
160
|
+
|
|
161
|
+
Pass rate 88.3% [77.8%, 94.2%] is well short of the 95% target and no
|
|
162
|
+
confident subset reaches it; useful as a draft-generator, not as an
|
|
163
|
+
unattended step.
|
|
164
|
+
|
|
165
|
+
| Cases evaluated | 60 |
|
|
166
|
+
| Passed | 53 |
|
|
167
|
+
| Pass rate | 88.3% [77.8%, 94.2%] |
|
|
168
|
+
| Target | 95% |
|
|
169
|
+
| Calibration error | 0.11 (usable) |
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The calibration table in the full report is worth a look too: this agent is
|
|
173
|
+
well calibrated above 0.8 (stated 0.92, actual 98%) and badly calibrated in
|
|
174
|
+
the 0.6–0.8 band (stated 0.68, actual 33%). That's the kind of thing you want
|
|
175
|
+
to know before you pick a threshold.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# gonogo
|
|
2
|
+
|
|
3
|
+
**47 out of 50 is not 94%.** It lands somewhere between 84% and 98%, so if you were aiming at 90%, you can't yet say you got there.
|
|
4
|
+
|
|
5
|
+
Feed `gonogo` your agent and your real cases. Back comes a decision: ship it, ship it behind a human-review threshold, or walk away. When the honest answer is "you don't have enough cases to know," it says that instead of guessing.
|
|
6
|
+
|
|
7
|
+
This is deliberately **not** another eval framework — several good ones already exist. What none of them do is convert a score into a deployment decision you can defend at the sample sizes pilots actually run: forty to a hundred cases, not ten thousand.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install gonogo-eval
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Installs as `gonogo-eval` (the plain `gonogo` name on PyPI belongs to an unrelated project); imports as `gonogo`.
|
|
14
|
+
|
|
15
|
+
## The idea
|
|
16
|
+
|
|
17
|
+
Evaluate an agent on a small set of real cases and three things go wrong.
|
|
18
|
+
|
|
19
|
+
**The point estimate flatters you.** 47/50 reads as 94%. The 95% interval is [83.8%, 97.9%]. Most tools print the 94% and stop there.
|
|
20
|
+
|
|
21
|
+
**Accuracy isn't what anyone's asking.** The real question is how much work you can hand over at 98% precision, and how many cases end up on someone's desk. Answering it means a risk–coverage curve over an abstention threshold. Almost nothing computes one.
|
|
22
|
+
|
|
23
|
+
**A dashboard isn't a decision.** Somebody still has to say ship or don't ship, and that call ought to fall out of the numbers rather than out of a meeting.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from gonogo import Case, evaluate
|
|
29
|
+
from gonogo.scoring import fields
|
|
30
|
+
|
|
31
|
+
cases = Case.from_jsonl("invoices.jsonl") # your real cases
|
|
32
|
+
|
|
33
|
+
def agent(case):
|
|
34
|
+
result = my_pipeline(case.input)
|
|
35
|
+
return result.data, result.confidence # confidence is optional but unlocks a lot
|
|
36
|
+
|
|
37
|
+
report = evaluate(agent, cases, scorer=fields(["vendor", "total"]), target=0.95)
|
|
38
|
+
print(report.markdown())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Your agent is any callable. No base class, no decorator, no framework to adopt. Return a bare value, a `(value, confidence)` tuple, or a `Prediction` when you want to signal abstention.
|
|
42
|
+
|
|
43
|
+
## What it tells you
|
|
44
|
+
|
|
45
|
+
Five verdicts, and only two of them mean ship:
|
|
46
|
+
|
|
47
|
+
| Verdict | Meaning |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `AUTOMATE` | The pass rate's lower bound clears your target. Ship it. |
|
|
50
|
+
| `AUTOMATE WITH REVIEW` | Not good enough overall, but a confident subset is. Ship behind a threshold, route the rest to a person. |
|
|
51
|
+
| `ASSIST ONLY` | Useful as a draft generator, not as an unattended step. |
|
|
52
|
+
| `DO NOT AUTOMATE` | Not a fit for this workflow as scoped. |
|
|
53
|
+
| `INSUFFICIENT EVIDENCE` | The estimate looks good but your sample can't support the claim. Here's roughly how many cases you'd need. |
|
|
54
|
+
|
|
55
|
+
That last one is the whole reason this exists. It's the verdict an honest consultant gives and a dashboard never does.
|
|
56
|
+
|
|
57
|
+
## Why confidence matters
|
|
58
|
+
|
|
59
|
+
If your agent reports a per-case confidence, `gonogo` finds the abstention threshold that maximizes how much you can automate while keeping precision's *lower bound* above your target, and reports the whole curve so you can see the tradeoff:
|
|
60
|
+
|
|
61
|
+
| Confidence floor | Handled | Precision | To review |
|
|
62
|
+
| --- | --- | --- | --- |
|
|
63
|
+
| 0.36 | 100% | 88.3% [77.8%, 94.2%] | 0 |
|
|
64
|
+
| 0.70 | 87% | 94.2% [84.4%, 98.0%] | 8 |
|
|
65
|
+
| 0.85 | 82% | 98.0% [89.3%, 99.6%] | 11 |
|
|
66
|
+
| 0.89 | 57% | 100.0% [89.8%, 100.0%] | 26 |
|
|
67
|
+
| 0.99 | 2% | 100.0% [20.7%, 100.0%] | 59 |
|
|
68
|
+
|
|
69
|
+
That last row is why it uses the lower bound and not the point estimate. 100% precision on a single case is not an operating point, and the interval says so.
|
|
70
|
+
|
|
71
|
+
Note what the table above actually proves: against a 95% target, *no* threshold works here. The 0.85 row looks great at 98.0% until you read its lower bound of 89.3%. So the verdict is `ASSIST ONLY`, not a ship — which is the answer you want before you wire it into production, not after.
|
|
72
|
+
|
|
73
|
+
It also checks whether your confidence means anything. Expected calibration error above 0.15 and the harness refuses to recommend a threshold at all — thresholding on a number that doesn't track correctness is theater.
|
|
74
|
+
|
|
75
|
+
## A real measurement
|
|
76
|
+
|
|
77
|
+
`examples/banking77_routing.py` runs against [Banking77](https://github.com/PolyAI-LDN/task-specific-datasets) — 13,083 genuine retail-bank customer messages across 77 intents. The agent is an ordinary TF-IDF nearest-centroid baseline, stdlib only, no model API.
|
|
78
|
+
|
|
79
|
+
On a pilot-sized sample of 250 cases against a 95% target:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
ASSIST ONLY: Use it to draft, keep a human on every case.
|
|
83
|
+
Pass rate 77.2% [71.6%, 82.0%] (full 3,080-case split: 80.7% [79.3%, 82.1%])
|
|
84
|
+
Calibration error 0.42 (ranks cases, scale unreliable)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The calibration table is the interesting part:
|
|
88
|
+
|
|
89
|
+
| Stated confidence | Cases | Mean confidence | Actual accuracy |
|
|
90
|
+
| --- | --- | --- | --- |
|
|
91
|
+
| 0.0–0.2 | 83 | 0.11 | 61% |
|
|
92
|
+
| 0.2–0.4 | 74 | 0.30 | 78% |
|
|
93
|
+
| 0.4–0.6 | 50 | 0.51 | 84% |
|
|
94
|
+
| 0.6–0.8 | 30 | 0.70 | 97% |
|
|
95
|
+
| 0.8–1.0 | 13 | 0.87 | 100% |
|
|
96
|
+
|
|
97
|
+
That model is *badly* miscalibrated — it says 0.11 and is right 61% of the time — while still ranking cases almost perfectly. Those are two different properties, and conflating them is a common way to throw away a usable signal. `gonogo` reports the calibration error, says the number isn't a probability, and still measures precision at each cut point empirically, because that measurement doesn't depend on the scale being meaningful.
|
|
98
|
+
|
|
99
|
+
## On LLM-as-judge
|
|
100
|
+
|
|
101
|
+
`scoring.judge` takes any `complete(prompt) -> str` callable, so there's no provider SDK in the dependency tree.
|
|
102
|
+
|
|
103
|
+
An unvalidated judge is the most common silent failure in agent evaluation. Hand-label a subset and run `validate_judge(judge_labels, human_labels)`:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
judge NOT USABLE: 90% agreement, kappa 0.00 on 30 hand-labelled cases
|
|
107
|
+
(kappa 0.00 is below 0.60 and it passes cases you failed; fix the rubric)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
That's the trap. A judge that rubber-stamps everything scores 90% agreement on a set that's 90% passes, and carries no information whatsoever. Kappa is the gate, not agreement.
|
|
111
|
+
|
|
112
|
+
## Reports
|
|
113
|
+
|
|
114
|
+
`report.markdown()` for a terminal or a PR comment; `report.html()` for a self-contained styled page with no external assets, which you can hand to whoever signs off; `report.to_dict()` for JSON.
|
|
115
|
+
|
|
116
|
+
## Non-goals
|
|
117
|
+
|
|
118
|
+
This is a reference implementation, around 800 lines, readable in one sitting. It will not grow into:
|
|
119
|
+
|
|
120
|
+
- a hosted service or dashboard
|
|
121
|
+
- tracing / observability
|
|
122
|
+
- prompt management or versioning
|
|
123
|
+
- an agent framework
|
|
124
|
+
- a public leaderboard
|
|
125
|
+
|
|
126
|
+
If you want those, use one of the platforms. This does one thing.
|
|
127
|
+
|
|
128
|
+
## Run the example
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python examples/invoice_extraction.py
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Sixty simulated invoices, an agent that's good but not perfect, no API key required. Real output:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
# Score report: Extract fields from invoice
|
|
138
|
+
|
|
139
|
+
**ASSIST ONLY**: Use it to draft, keep a human on every case.
|
|
140
|
+
|
|
141
|
+
Pass rate 88.3% [77.8%, 94.2%] is well short of the 95% target and no
|
|
142
|
+
confident subset reaches it; useful as a draft-generator, not as an
|
|
143
|
+
unattended step.
|
|
144
|
+
|
|
145
|
+
| Cases evaluated | 60 |
|
|
146
|
+
| Passed | 53 |
|
|
147
|
+
| Pass rate | 88.3% [77.8%, 94.2%] |
|
|
148
|
+
| Target | 95% |
|
|
149
|
+
| Calibration error | 0.11 (usable) |
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The calibration table in the full report is worth a look too: this agent is
|
|
153
|
+
well calibrated above 0.8 (stated 0.92, actual 98%) and badly calibrated in
|
|
154
|
+
the 0.6–0.8 band (stated 0.68, actual 33%). That's the kind of thing you want
|
|
155
|
+
to know before you pick a threshold.
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
MIT
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""A real measurement on a real public dataset.
|
|
2
|
+
|
|
3
|
+
Banking77 is 13,083 genuine customer-service messages from a retail bank,
|
|
4
|
+
labelled into 77 intents. Routing a message to the right queue is exactly the
|
|
5
|
+
kind of workflow a pilot gets scoped around, so it makes an honest stand-in for
|
|
6
|
+
"can we automate our support triage?"
|
|
7
|
+
|
|
8
|
+
The agent here is a deliberately ordinary baseline: TF-IDF nearest-centroid,
|
|
9
|
+
pure stdlib, no model API. That is the point. A plausible-looking baseline is
|
|
10
|
+
what most demos are built on, and the question is whether the harness tells you
|
|
11
|
+
the truth about it.
|
|
12
|
+
|
|
13
|
+
python examples/banking77_routing.py
|
|
14
|
+
|
|
15
|
+
Data is fetched once from the PolyAI repository and cached next to this file.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv
|
|
21
|
+
import math
|
|
22
|
+
import random
|
|
23
|
+
import re
|
|
24
|
+
import urllib.request
|
|
25
|
+
from collections import Counter, defaultdict
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from gonogo import Case, Prediction, evaluate
|
|
29
|
+
from gonogo.scoring import exact
|
|
30
|
+
|
|
31
|
+
BASE = "https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets/master/banking_data"
|
|
32
|
+
CACHE = Path(__file__).parent / "data"
|
|
33
|
+
TOKEN = re.compile(r"[a-z0-9']+")
|
|
34
|
+
|
|
35
|
+
# A pilot ships with a case set a human actually labelled, not a full benchmark
|
|
36
|
+
# split. 250 is a realistic size and it keeps the small-n lesson visible.
|
|
37
|
+
PILOT_SIZE = 250
|
|
38
|
+
SEED = 11
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fetch(name: str) -> list[tuple[str, str]]:
|
|
42
|
+
CACHE.mkdir(exist_ok=True)
|
|
43
|
+
path = CACHE / f"banking77_{name}.csv"
|
|
44
|
+
if not path.exists():
|
|
45
|
+
print(f"downloading banking77 {name} split...")
|
|
46
|
+
urllib.request.urlretrieve(f"{BASE}/{name}.csv", path)
|
|
47
|
+
with path.open(encoding="utf-8", newline="") as fh:
|
|
48
|
+
return [(row["text"], row["category"]) for row in csv.DictReader(fh)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tokenize(text: str) -> list[str]:
|
|
52
|
+
return TOKEN.findall(text.lower())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NearestCentroid:
|
|
56
|
+
"""TF-IDF nearest-centroid classifier with a softmax confidence.
|
|
57
|
+
|
|
58
|
+
Confidence is the softmax over centroid similarities. It is a real signal --
|
|
59
|
+
ambiguous messages do score lower -- but it was never calibrated against
|
|
60
|
+
anything, which is exactly the situation most agents are in.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, temperature: float = 12.0):
|
|
64
|
+
self.temperature = temperature
|
|
65
|
+
self.idf: dict[str, float] = {}
|
|
66
|
+
self.centroids: dict[str, dict[str, float]] = {}
|
|
67
|
+
|
|
68
|
+
def fit(self, rows: list[tuple[str, str]]) -> "NearestCentroid":
|
|
69
|
+
docs = [tokenize(t) for t, _ in rows]
|
|
70
|
+
df = Counter()
|
|
71
|
+
for tokens in docs:
|
|
72
|
+
df.update(set(tokens))
|
|
73
|
+
n = len(docs)
|
|
74
|
+
self.idf = {term: math.log(n / (1 + count)) + 1.0 for term, count in df.items()}
|
|
75
|
+
|
|
76
|
+
sums: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
|
|
77
|
+
counts: Counter = Counter()
|
|
78
|
+
for tokens, (_, label) in zip(docs, rows):
|
|
79
|
+
for term, weight in self._vector(tokens).items():
|
|
80
|
+
sums[label][term] += weight
|
|
81
|
+
counts[label] += 1
|
|
82
|
+
|
|
83
|
+
for label, vec in sums.items():
|
|
84
|
+
centroid = {t: w / counts[label] for t, w in vec.items()}
|
|
85
|
+
self.centroids[label] = self._normalize(centroid)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def _vector(self, tokens: list[str]) -> dict[str, float]:
|
|
89
|
+
tf = Counter(tokens)
|
|
90
|
+
vec = {t: (1 + math.log(c)) * self.idf.get(t, 1.0) for t, c in tf.items()}
|
|
91
|
+
return self._normalize(vec)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _normalize(vec: dict[str, float]) -> dict[str, float]:
|
|
95
|
+
norm = math.sqrt(sum(w * w for w in vec.values()))
|
|
96
|
+
return {t: w / norm for t, w in vec.items()} if norm else vec
|
|
97
|
+
|
|
98
|
+
def predict(self, text: str) -> tuple[str, float]:
|
|
99
|
+
vec = self._vector(tokenize(text))
|
|
100
|
+
scores = {
|
|
101
|
+
label: sum(w * centroid.get(t, 0.0) for t, w in vec.items())
|
|
102
|
+
for label, centroid in self.centroids.items()
|
|
103
|
+
}
|
|
104
|
+
if not scores:
|
|
105
|
+
return "", 0.0
|
|
106
|
+
best = max(scores, key=scores.get)
|
|
107
|
+
top = max(scores.values())
|
|
108
|
+
total = sum(math.exp(self.temperature * (s - top)) for s in scores.values())
|
|
109
|
+
return best, 1.0 / total
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main() -> None:
|
|
113
|
+
train = fetch("train")
|
|
114
|
+
test = fetch("test")
|
|
115
|
+
model = NearestCentroid().fit(train)
|
|
116
|
+
|
|
117
|
+
pilot = random.Random(SEED).sample(test, PILOT_SIZE)
|
|
118
|
+
cases = [
|
|
119
|
+
Case(input=text, expected=label, id=f"msg-{i:04d}")
|
|
120
|
+
for i, (text, label) in enumerate(pilot)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
def agent(case: Case) -> Prediction:
|
|
124
|
+
label, confidence = model.predict(case.input)
|
|
125
|
+
return Prediction(output=label, confidence=round(confidence, 3))
|
|
126
|
+
|
|
127
|
+
report = evaluate(
|
|
128
|
+
agent,
|
|
129
|
+
cases,
|
|
130
|
+
scorer=exact(),
|
|
131
|
+
task="Route customer message to the right queue",
|
|
132
|
+
target=0.95,
|
|
133
|
+
)
|
|
134
|
+
print(report.markdown())
|
|
135
|
+
|
|
136
|
+
out = Path(__file__).parent / "banking77_report.html"
|
|
137
|
+
out.write_text(report.html(), encoding="utf-8")
|
|
138
|
+
print(f"\n[html report written to {out}]")
|
|
139
|
+
|
|
140
|
+
# For context: the same agent measured on the full held-out split.
|
|
141
|
+
full = [Case(input=t, expected=l, id=str(i)) for i, (t, l) in enumerate(test)]
|
|
142
|
+
full_report = evaluate(agent, full, scorer=exact(), task="full test split", target=0.95)
|
|
143
|
+
print(f"[full split, n={full_report.n}: {full_report.decision.pass_rate}]")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""A worked example you can run with no API key.
|
|
2
|
+
|
|
3
|
+
Simulates a document-extraction pilot: 60 real-ish invoices, an agent that is
|
|
4
|
+
good but not perfect and reports a confidence, and the resulting decision.
|
|
5
|
+
|
|
6
|
+
python examples/invoice_extraction.py
|
|
7
|
+
|
|
8
|
+
The point of the example is the *shape of the answer*. The agent here gets about
|
|
9
|
+
88% of cases right, which looks shippable until you see the interval -- and then
|
|
10
|
+
you see that abstaining on its low-confidence cases buys you a defensible
|
|
11
|
+
operating point instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import random
|
|
17
|
+
|
|
18
|
+
from gonogo import Case, Prediction, evaluate
|
|
19
|
+
from gonogo.scoring import fields
|
|
20
|
+
|
|
21
|
+
VENDORS = ["Acme Supply", "Northwind Freight", "Globex Paper", "Initech Legal", "Umbrella Labs"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_cases(n: int = 60, seed: int = 7) -> list[Case]:
|
|
25
|
+
"""Stand-in for a real case set. In a pilot this is a JSONL file of your invoices."""
|
|
26
|
+
rng = random.Random(seed)
|
|
27
|
+
cases = []
|
|
28
|
+
for i in range(n):
|
|
29
|
+
vendor = rng.choice(VENDORS)
|
|
30
|
+
total = round(rng.uniform(40, 9000), 2)
|
|
31
|
+
# Some invoices are genuinely hard: handwritten, multi-page, or foreign currency.
|
|
32
|
+
hard = rng.random() < 0.25
|
|
33
|
+
cases.append(Case(
|
|
34
|
+
id=f"inv-{i:03d}",
|
|
35
|
+
input={"scan": f"invoice_{i:03d}.pdf", "pages": 3 if hard else 1},
|
|
36
|
+
expected={"vendor": vendor, "total": total, "currency": "USD"},
|
|
37
|
+
metadata={"hard": hard},
|
|
38
|
+
))
|
|
39
|
+
return cases
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def agent(case: Case) -> Prediction:
|
|
43
|
+
"""A plausible extraction agent: usually right, shakier on hard documents.
|
|
44
|
+
|
|
45
|
+
Crucially it reports a calibrated-ish confidence, which is what lets the
|
|
46
|
+
harness find an abstention threshold. An agent that always says 0.99 gives
|
|
47
|
+
you nothing to threshold on.
|
|
48
|
+
"""
|
|
49
|
+
rng = random.Random(hash(case.id) & 0xFFFF)
|
|
50
|
+
hard = case.metadata.get("hard", False)
|
|
51
|
+
correct = rng.random() > (0.35 if hard else 0.04)
|
|
52
|
+
|
|
53
|
+
expected = case.expected
|
|
54
|
+
if correct:
|
|
55
|
+
output = dict(expected)
|
|
56
|
+
else:
|
|
57
|
+
# Realistic failure modes: wrong total, or a truncated vendor name.
|
|
58
|
+
output = dict(expected)
|
|
59
|
+
if rng.random() < 0.6:
|
|
60
|
+
output["total"] = round(expected["total"] * rng.uniform(1.05, 1.4), 2)
|
|
61
|
+
else:
|
|
62
|
+
output["vendor"] = expected["vendor"].split()[0]
|
|
63
|
+
|
|
64
|
+
confidence = rng.uniform(0.35, 0.75) if hard else rng.uniform(0.85, 0.99)
|
|
65
|
+
return Prediction(output=output, confidence=round(confidence, 2))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main() -> None:
|
|
69
|
+
cases = build_cases()
|
|
70
|
+
report = evaluate(
|
|
71
|
+
agent,
|
|
72
|
+
cases,
|
|
73
|
+
scorer=fields(required=["vendor", "total", "currency"]),
|
|
74
|
+
task="Extract fields from invoice",
|
|
75
|
+
target=0.95,
|
|
76
|
+
)
|
|
77
|
+
print(report.markdown())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""gonogo -- decide whether an agent is good enough to ship.
|
|
2
|
+
|
|
3
|
+
Not an eval framework. A decision harness for pilot-scale case sets, where the
|
|
4
|
+
honest answer is often "not enough evidence yet" and sometimes "don't automate
|
|
5
|
+
this at all".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .cases import Case, CaseResult, Prediction
|
|
9
|
+
from .decide import Decision, Verdict, decide
|
|
10
|
+
from .evaluate import evaluate
|
|
11
|
+
from .report import Report
|
|
12
|
+
from .scoring import (
|
|
13
|
+
JudgeValidation,
|
|
14
|
+
exact,
|
|
15
|
+
fields,
|
|
16
|
+
judge,
|
|
17
|
+
judge_agreement,
|
|
18
|
+
numeric,
|
|
19
|
+
set_f1,
|
|
20
|
+
validate_judge,
|
|
21
|
+
)
|
|
22
|
+
from .stats import (
|
|
23
|
+
Interval,
|
|
24
|
+
OperatingPoint,
|
|
25
|
+
best_operating_point,
|
|
26
|
+
expected_calibration_error,
|
|
27
|
+
reliability_table,
|
|
28
|
+
required_n,
|
|
29
|
+
risk_coverage,
|
|
30
|
+
wilson,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Case", "CaseResult", "Prediction",
|
|
37
|
+
"Decision", "Verdict", "decide",
|
|
38
|
+
"evaluate", "Report",
|
|
39
|
+
"exact", "fields", "judge", "judge_agreement", "numeric", "set_f1",
|
|
40
|
+
"JudgeValidation", "validate_judge",
|
|
41
|
+
"Interval", "OperatingPoint", "best_operating_point",
|
|
42
|
+
"expected_calibration_error", "reliability_table", "required_n",
|
|
43
|
+
"risk_coverage", "wilson",
|
|
44
|
+
]
|