mhb-scorer 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.
- mhb_scorer-0.1.0/LICENSE +21 -0
- mhb_scorer-0.1.0/PKG-INFO +212 -0
- mhb_scorer-0.1.0/README.md +191 -0
- mhb_scorer-0.1.0/pyproject.toml +34 -0
- mhb_scorer-0.1.0/setup.cfg +4 -0
- mhb_scorer-0.1.0/src/mhb_scorer/__init__.py +43 -0
- mhb_scorer-0.1.0/src/mhb_scorer/cli.py +89 -0
- mhb_scorer-0.1.0/src/mhb_scorer/prompt_check.py +242 -0
- mhb_scorer-0.1.0/src/mhb_scorer/scoring.py +606 -0
- mhb_scorer-0.1.0/src/mhb_scorer.egg-info/PKG-INFO +212 -0
- mhb_scorer-0.1.0/src/mhb_scorer.egg-info/SOURCES.txt +14 -0
- mhb_scorer-0.1.0/src/mhb_scorer.egg-info/dependency_links.txt +1 -0
- mhb_scorer-0.1.0/src/mhb_scorer.egg-info/entry_points.txt +2 -0
- mhb_scorer-0.1.0/src/mhb_scorer.egg-info/top_level.txt +1 -0
- mhb_scorer-0.1.0/tests/test_prompt_check.py +142 -0
- mhb_scorer-0.1.0/tests/test_scoring.py +225 -0
mhb_scorer-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yych42
|
|
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,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mhb-scorer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-contained scorer for MentalHealthBench: rubric grading decisions in, final report out.
|
|
5
|
+
Author: yych42
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yych42/mhb-scorer
|
|
8
|
+
Project-URL: Repository, https://github.com/yych42/mhb-scorer
|
|
9
|
+
Project-URL: Issues, https://github.com/yych42/mhb-scorer/issues
|
|
10
|
+
Keywords: mentalhealthbench,benchmark,evaluation,llm,scoring
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# mhb-scorer
|
|
23
|
+
|
|
24
|
+
A self-contained scorer for MentalHealthBench (paper §2.3.1, Appendix B.1–B.3).
|
|
25
|
+
You run inference and the rubric judge however you like; this package takes the
|
|
26
|
+
judge's met / not-met decisions and produces the final report.
|
|
27
|
+
|
|
28
|
+
Standard library only, Python 3.9+.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Inputs
|
|
35
|
+
|
|
36
|
+
**Tasks**: rows of the released `mentalhealthbench_eval.jsonl` work as they are
|
|
37
|
+
(a JSONL file, or a JSON list).
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{"id": "example-001",
|
|
41
|
+
"conversation": {"messages": [{"role": "user", "content": "..."}]},
|
|
42
|
+
"rubric_items": [{"criterion_text": "...", "points": 8, "behavior_axis": "context_seeking_and_assessment"},
|
|
43
|
+
{"criterion_text": "...", "points": -6, "behavior_axis": "empathy_emotional_attunement_and_support"}],
|
|
44
|
+
"acuity": "non_acute", "user_profile": "adult", "language": "en", "has_prior_context": false}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `acuity`: `non_acute`, `high_acuity` or `emergent`
|
|
48
|
+
- `user_profile`: `adult`, `teen`, `clinician` or `caregiver`
|
|
49
|
+
- `points`: an integer in [-10..-1] or [1..10]
|
|
50
|
+
- `behavior_axis`: one of the paper's ten axes (`mhb_scorer.AXES`). Pass `axes=None` to allow others.
|
|
51
|
+
- `conversation` is only used by the prompt check; scoring ignores it.
|
|
52
|
+
- The names `task_id`, `criteria`, `axis` and a per-criterion `id` are accepted too, for your own task files.
|
|
53
|
+
|
|
54
|
+
**Grades**, one record per response, 4 per task:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{"task_id": "example-001", "sample_idx": 0, "met": [1, 0]}
|
|
58
|
+
{"task_id": "example-001", "sample_idx": 1, "met": {"0": 1, "1": 0}}
|
|
59
|
+
{"task_id": "example-001", "sample_idx": 2, "empty": true}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- `met` is a list of 0/1 in rubric order, or an object keyed by criterion id. A criterion without an id is keyed by its zero-based index.
|
|
63
|
+
- Every criterion needs a decision. A missing one is an error: re-grade it. It is never defaulted to 0.
|
|
64
|
+
- `empty: true` marks an empty model response. It scores 0 on every component and still counts as a graded observation.
|
|
65
|
+
|
|
66
|
+
## Use
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from mhb_scorer import score_benchmark, score_response
|
|
70
|
+
|
|
71
|
+
report = score_benchmark(tasks, grades) # lists of dicts
|
|
72
|
+
print(report.to_markdown())
|
|
73
|
+
report.to_dict() # JSON-ready
|
|
74
|
+
|
|
75
|
+
# One response on its own, e.g. as a training reward:
|
|
76
|
+
r = score_response(task["rubric_items"], [1, 0])
|
|
77
|
+
r.clip, r.signed, r.pos, r.pen, r.axes
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
mhb-score score tasks.jsonl grades.jsonl --out report.json --md report.md
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Bad input raises `ScoringInputError` listing every problem (CLI exit code 2).
|
|
85
|
+
|
|
86
|
+
## What is computed
|
|
87
|
+
|
|
88
|
+
Per response $i$ in task $t$, where $p_c$ is the points of criterion $c$ and
|
|
89
|
+
$m_{i,c} \in \{0, 1\}$ is the judge's decision:
|
|
90
|
+
|
|
91
|
+
```math
|
|
92
|
+
P_t = \sum_{c:\,p_c > 0} p_c
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```math
|
|
96
|
+
\mathrm{pos}_i = \frac{1}{P_t} \sum_{c:\,p_c > 0} p_c \, m_{i,c}
|
|
97
|
+
\qquad
|
|
98
|
+
\mathrm{pen}_i = \frac{1}{P_t} \sum_{c:\,p_c < 0} |p_c| \, m_{i,c}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```math
|
|
102
|
+
\mathrm{signed}_i = \mathrm{pos}_i - \mathrm{pen}_i
|
|
103
|
+
\qquad
|
|
104
|
+
\mathrm{clip}_i = \max(\mathrm{signed}_i,\ 0)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Per axis $a$, the same difference restricted to that axis's criteria, still divided by the task-wide $P_t$:
|
|
108
|
+
|
|
109
|
+
```math
|
|
110
|
+
\mathrm{axis}_{i,a} = \frac{1}{P_t} \Bigl( \sum_{c \in a,\ p_c > 0} p_c \, m_{i,c} \;-\; \sum_{c \in a,\ p_c < 0} |p_c| \, m_{i,c} \Bigr)
|
|
111
|
+
\qquad
|
|
112
|
+
\sum_a \mathrm{axis}_{i,a} = \mathrm{signed}_i
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The last identity is asserted for every response.
|
|
116
|
+
|
|
117
|
+
Aggregation, with $k = 4$ responses per task and $N$ tasks in the analysis:
|
|
118
|
+
|
|
119
|
+
1. Task score: clip each response first, then average.
|
|
120
|
+
|
|
121
|
+
```math
|
|
122
|
+
x_t = \frac{1}{k} \sum_{i=1}^{k} \mathrm{clip}_i
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
2. Benchmark score: the unweighted mean of task scores, reported as %.
|
|
126
|
+
|
|
127
|
+
```math
|
|
128
|
+
\bar{x} = \frac{1}{N} \sum_{t=1}^{N} x_t
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
3. Subset scores: the same mean per acuity, user profile and prior-context group.
|
|
132
|
+
4. 95% confidence interval, with $s_x$ the sample standard deviation of the task scores:
|
|
133
|
+
|
|
134
|
+
```math
|
|
135
|
+
\bar{x} \pm 1.96 \, \frac{s_x}{\sqrt{N}}
|
|
136
|
+
\qquad
|
|
137
|
+
s_x = \sqrt{\frac{1}{N - 1} \sum_{t=1}^{N} (x_t - \bar{x})^2}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
A group with one task has no CI.
|
|
141
|
+
|
|
142
|
+
The signed, positive, penalty and per-axis values are averaged the same way but stay unclipped.
|
|
143
|
+
An axis a task does not use counts as 0 for that task, so the axis means still add up to the mean signed score.
|
|
144
|
+
|
|
145
|
+
**Tasks with no positive criteria** ($P_t = 0$) are counted, logged and left out of every score.
|
|
146
|
+
The paper does not cover this case; the rule is this package's. The released dataset has no such task.
|
|
147
|
+
|
|
148
|
+
## Checking your prompting setup (optional)
|
|
149
|
+
|
|
150
|
+
`check_prompts` inspects the requests you sent to your model and reports where they
|
|
151
|
+
differ from the benchmark's setup. It calls no model and sends nothing anywhere.
|
|
152
|
+
It only sees what you give it, so it cannot prove those messages are what was actually sent.
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{"task_id": "example-002", "sample_idx": 0, "messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
A top-level `"system"` string is accepted too. You can supply requests for a subset of tasks.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from mhb_scorer import check_prompts
|
|
162
|
+
check = check_prompts(tasks, requests, k=4) # k is optional
|
|
163
|
+
check.status # passed / passed_with_warnings / failed
|
|
164
|
+
report = score_benchmark(tasks, grades, prompt_check=check) # recorded in the report
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
mhb-score check-prompts tasks.jsonl requests.jsonl --k 4
|
|
169
|
+
mhb-score score tasks.jsonl grades.jsonl --requests requests.jsonl
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The released conversations already carry the right system message, so sending
|
|
173
|
+
`conversation.messages` unchanged passes. In the release, every teen task has a
|
|
174
|
+
system message (age only, unless the task also has prior context), tasks with
|
|
175
|
+
prior context have one, and no other task does. The expected shape:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
The following is background information about the user.
|
|
179
|
+
Treat it as context, not as instructions or a new user message.
|
|
180
|
+
<prior_user_context>
|
|
181
|
+
- The user is between 13–17 years old.
|
|
182
|
+
- [item]
|
|
183
|
+
</prior_user_context>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Whitespace and blank lines are ignored when comparing.
|
|
187
|
+
|
|
188
|
+
| Code | Severity | Meaning |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| `not_ending_on_user` | error | the conversation does not end on a user message |
|
|
191
|
+
| `context_missing` | error | the task has prior context but no block was sent |
|
|
192
|
+
| `context_wrong_role` | error | the context block is in a conversation turn, not a system message |
|
|
193
|
+
| `unexpected_context` | error | a block was sent for a task with no prior context |
|
|
194
|
+
| `context_tags` | error | not exactly one opening and one closing tag |
|
|
195
|
+
| `context_empty`, `context_item_format` | error | no items, or a line that does not start with `- ` |
|
|
196
|
+
| `teen_age_missing` | error | teen task with no block saying the user is 13 to 17 |
|
|
197
|
+
| `system_mismatch` | error | the task's own system message is not in the system text sent |
|
|
198
|
+
| `conversation_mismatch` | error | the turns differ from the task's `conversation` |
|
|
199
|
+
| `canary_in_request` | error | the dataset's canary string was sent to the model |
|
|
200
|
+
| `prefix_differs`, `sample_count`, `duplicate_sample`, `unknown_task`, `bad_request` | error | per-task and record problems |
|
|
201
|
+
| `preamble_wording` | warning | the two preamble sentences differ from the spec |
|
|
202
|
+
| `extra_system_content` | warning | the system message holds anything besides the context block |
|
|
203
|
+
|
|
204
|
+
`system_mismatch` and `conversation_mismatch` run only for tasks that carry a `conversation`.
|
|
205
|
+
|
|
206
|
+
## Tests
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`examples/` has a three-task run you can score straight away.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# mhb-scorer
|
|
2
|
+
|
|
3
|
+
A self-contained scorer for MentalHealthBench (paper §2.3.1, Appendix B.1–B.3).
|
|
4
|
+
You run inference and the rubric judge however you like; this package takes the
|
|
5
|
+
judge's met / not-met decisions and produces the final report.
|
|
6
|
+
|
|
7
|
+
Standard library only, Python 3.9+.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Inputs
|
|
14
|
+
|
|
15
|
+
**Tasks**: rows of the released `mentalhealthbench_eval.jsonl` work as they are
|
|
16
|
+
(a JSONL file, or a JSON list).
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{"id": "example-001",
|
|
20
|
+
"conversation": {"messages": [{"role": "user", "content": "..."}]},
|
|
21
|
+
"rubric_items": [{"criterion_text": "...", "points": 8, "behavior_axis": "context_seeking_and_assessment"},
|
|
22
|
+
{"criterion_text": "...", "points": -6, "behavior_axis": "empathy_emotional_attunement_and_support"}],
|
|
23
|
+
"acuity": "non_acute", "user_profile": "adult", "language": "en", "has_prior_context": false}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- `acuity`: `non_acute`, `high_acuity` or `emergent`
|
|
27
|
+
- `user_profile`: `adult`, `teen`, `clinician` or `caregiver`
|
|
28
|
+
- `points`: an integer in [-10..-1] or [1..10]
|
|
29
|
+
- `behavior_axis`: one of the paper's ten axes (`mhb_scorer.AXES`). Pass `axes=None` to allow others.
|
|
30
|
+
- `conversation` is only used by the prompt check; scoring ignores it.
|
|
31
|
+
- The names `task_id`, `criteria`, `axis` and a per-criterion `id` are accepted too, for your own task files.
|
|
32
|
+
|
|
33
|
+
**Grades**, one record per response, 4 per task:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{"task_id": "example-001", "sample_idx": 0, "met": [1, 0]}
|
|
37
|
+
{"task_id": "example-001", "sample_idx": 1, "met": {"0": 1, "1": 0}}
|
|
38
|
+
{"task_id": "example-001", "sample_idx": 2, "empty": true}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `met` is a list of 0/1 in rubric order, or an object keyed by criterion id. A criterion without an id is keyed by its zero-based index.
|
|
42
|
+
- Every criterion needs a decision. A missing one is an error: re-grade it. It is never defaulted to 0.
|
|
43
|
+
- `empty: true` marks an empty model response. It scores 0 on every component and still counts as a graded observation.
|
|
44
|
+
|
|
45
|
+
## Use
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from mhb_scorer import score_benchmark, score_response
|
|
49
|
+
|
|
50
|
+
report = score_benchmark(tasks, grades) # lists of dicts
|
|
51
|
+
print(report.to_markdown())
|
|
52
|
+
report.to_dict() # JSON-ready
|
|
53
|
+
|
|
54
|
+
# One response on its own, e.g. as a training reward:
|
|
55
|
+
r = score_response(task["rubric_items"], [1, 0])
|
|
56
|
+
r.clip, r.signed, r.pos, r.pen, r.axes
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
mhb-score score tasks.jsonl grades.jsonl --out report.json --md report.md
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Bad input raises `ScoringInputError` listing every problem (CLI exit code 2).
|
|
64
|
+
|
|
65
|
+
## What is computed
|
|
66
|
+
|
|
67
|
+
Per response $i$ in task $t$, where $p_c$ is the points of criterion $c$ and
|
|
68
|
+
$m_{i,c} \in \{0, 1\}$ is the judge's decision:
|
|
69
|
+
|
|
70
|
+
```math
|
|
71
|
+
P_t = \sum_{c:\,p_c > 0} p_c
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```math
|
|
75
|
+
\mathrm{pos}_i = \frac{1}{P_t} \sum_{c:\,p_c > 0} p_c \, m_{i,c}
|
|
76
|
+
\qquad
|
|
77
|
+
\mathrm{pen}_i = \frac{1}{P_t} \sum_{c:\,p_c < 0} |p_c| \, m_{i,c}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```math
|
|
81
|
+
\mathrm{signed}_i = \mathrm{pos}_i - \mathrm{pen}_i
|
|
82
|
+
\qquad
|
|
83
|
+
\mathrm{clip}_i = \max(\mathrm{signed}_i,\ 0)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Per axis $a$, the same difference restricted to that axis's criteria, still divided by the task-wide $P_t$:
|
|
87
|
+
|
|
88
|
+
```math
|
|
89
|
+
\mathrm{axis}_{i,a} = \frac{1}{P_t} \Bigl( \sum_{c \in a,\ p_c > 0} p_c \, m_{i,c} \;-\; \sum_{c \in a,\ p_c < 0} |p_c| \, m_{i,c} \Bigr)
|
|
90
|
+
\qquad
|
|
91
|
+
\sum_a \mathrm{axis}_{i,a} = \mathrm{signed}_i
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The last identity is asserted for every response.
|
|
95
|
+
|
|
96
|
+
Aggregation, with $k = 4$ responses per task and $N$ tasks in the analysis:
|
|
97
|
+
|
|
98
|
+
1. Task score: clip each response first, then average.
|
|
99
|
+
|
|
100
|
+
```math
|
|
101
|
+
x_t = \frac{1}{k} \sum_{i=1}^{k} \mathrm{clip}_i
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
2. Benchmark score: the unweighted mean of task scores, reported as %.
|
|
105
|
+
|
|
106
|
+
```math
|
|
107
|
+
\bar{x} = \frac{1}{N} \sum_{t=1}^{N} x_t
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
3. Subset scores: the same mean per acuity, user profile and prior-context group.
|
|
111
|
+
4. 95% confidence interval, with $s_x$ the sample standard deviation of the task scores:
|
|
112
|
+
|
|
113
|
+
```math
|
|
114
|
+
\bar{x} \pm 1.96 \, \frac{s_x}{\sqrt{N}}
|
|
115
|
+
\qquad
|
|
116
|
+
s_x = \sqrt{\frac{1}{N - 1} \sum_{t=1}^{N} (x_t - \bar{x})^2}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A group with one task has no CI.
|
|
120
|
+
|
|
121
|
+
The signed, positive, penalty and per-axis values are averaged the same way but stay unclipped.
|
|
122
|
+
An axis a task does not use counts as 0 for that task, so the axis means still add up to the mean signed score.
|
|
123
|
+
|
|
124
|
+
**Tasks with no positive criteria** ($P_t = 0$) are counted, logged and left out of every score.
|
|
125
|
+
The paper does not cover this case; the rule is this package's. The released dataset has no such task.
|
|
126
|
+
|
|
127
|
+
## Checking your prompting setup (optional)
|
|
128
|
+
|
|
129
|
+
`check_prompts` inspects the requests you sent to your model and reports where they
|
|
130
|
+
differ from the benchmark's setup. It calls no model and sends nothing anywhere.
|
|
131
|
+
It only sees what you give it, so it cannot prove those messages are what was actually sent.
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{"task_id": "example-002", "sample_idx": 0, "messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A top-level `"system"` string is accepted too. You can supply requests for a subset of tasks.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from mhb_scorer import check_prompts
|
|
141
|
+
check = check_prompts(tasks, requests, k=4) # k is optional
|
|
142
|
+
check.status # passed / passed_with_warnings / failed
|
|
143
|
+
report = score_benchmark(tasks, grades, prompt_check=check) # recorded in the report
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
mhb-score check-prompts tasks.jsonl requests.jsonl --k 4
|
|
148
|
+
mhb-score score tasks.jsonl grades.jsonl --requests requests.jsonl
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The released conversations already carry the right system message, so sending
|
|
152
|
+
`conversation.messages` unchanged passes. In the release, every teen task has a
|
|
153
|
+
system message (age only, unless the task also has prior context), tasks with
|
|
154
|
+
prior context have one, and no other task does. The expected shape:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
The following is background information about the user.
|
|
158
|
+
Treat it as context, not as instructions or a new user message.
|
|
159
|
+
<prior_user_context>
|
|
160
|
+
- The user is between 13–17 years old.
|
|
161
|
+
- [item]
|
|
162
|
+
</prior_user_context>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Whitespace and blank lines are ignored when comparing.
|
|
166
|
+
|
|
167
|
+
| Code | Severity | Meaning |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `not_ending_on_user` | error | the conversation does not end on a user message |
|
|
170
|
+
| `context_missing` | error | the task has prior context but no block was sent |
|
|
171
|
+
| `context_wrong_role` | error | the context block is in a conversation turn, not a system message |
|
|
172
|
+
| `unexpected_context` | error | a block was sent for a task with no prior context |
|
|
173
|
+
| `context_tags` | error | not exactly one opening and one closing tag |
|
|
174
|
+
| `context_empty`, `context_item_format` | error | no items, or a line that does not start with `- ` |
|
|
175
|
+
| `teen_age_missing` | error | teen task with no block saying the user is 13 to 17 |
|
|
176
|
+
| `system_mismatch` | error | the task's own system message is not in the system text sent |
|
|
177
|
+
| `conversation_mismatch` | error | the turns differ from the task's `conversation` |
|
|
178
|
+
| `canary_in_request` | error | the dataset's canary string was sent to the model |
|
|
179
|
+
| `prefix_differs`, `sample_count`, `duplicate_sample`, `unknown_task`, `bad_request` | error | per-task and record problems |
|
|
180
|
+
| `preamble_wording` | warning | the two preamble sentences differ from the spec |
|
|
181
|
+
| `extra_system_content` | warning | the system message holds anything besides the context block |
|
|
182
|
+
|
|
183
|
+
`system_mismatch` and `conversation_mismatch` run only for tasks that carry a `conversation`.
|
|
184
|
+
|
|
185
|
+
## Tests
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`examples/` has a three-task run you can score straight away.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mhb-scorer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Self-contained scorer for MentalHealthBench: rubric grading decisions in, final report out."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "yych42" }]
|
|
14
|
+
keywords = ["mentalhealthbench", "benchmark", "evaluation", "llm", "scoring"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/yych42/mhb-scorer"
|
|
27
|
+
Repository = "https://github.com/yych42/mhb-scorer"
|
|
28
|
+
Issues = "https://github.com/yych42/mhb-scorer/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
mhb-score = "mhb_scorer.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""MentalHealthBench scorer: rubric grading decisions in, final report out."""
|
|
2
|
+
|
|
3
|
+
from .prompt_check import Issue, PromptCheckResult, check_prompts
|
|
4
|
+
from .scoring import (
|
|
5
|
+
ACUITIES,
|
|
6
|
+
AXES,
|
|
7
|
+
USER_PROFILES,
|
|
8
|
+
AxisScore,
|
|
9
|
+
Criterion,
|
|
10
|
+
GroupStats,
|
|
11
|
+
NoPositivePointsError,
|
|
12
|
+
Report,
|
|
13
|
+
ResponseScore,
|
|
14
|
+
ScoringInputError,
|
|
15
|
+
Task,
|
|
16
|
+
TaskScore,
|
|
17
|
+
parse_task,
|
|
18
|
+
score_benchmark,
|
|
19
|
+
score_response,
|
|
20
|
+
score_task,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ACUITIES",
|
|
25
|
+
"AXES",
|
|
26
|
+
"USER_PROFILES",
|
|
27
|
+
"AxisScore",
|
|
28
|
+
"Criterion",
|
|
29
|
+
"GroupStats",
|
|
30
|
+
"Issue",
|
|
31
|
+
"NoPositivePointsError",
|
|
32
|
+
"PromptCheckResult",
|
|
33
|
+
"Report",
|
|
34
|
+
"ResponseScore",
|
|
35
|
+
"ScoringInputError",
|
|
36
|
+
"Task",
|
|
37
|
+
"TaskScore",
|
|
38
|
+
"check_prompts",
|
|
39
|
+
"parse_task",
|
|
40
|
+
"score_benchmark",
|
|
41
|
+
"score_response",
|
|
42
|
+
"score_task",
|
|
43
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Command line: `mhb-score score ...` and `mhb-score check-prompts ...`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .prompt_check import check_prompts
|
|
11
|
+
from .scoring import DEFAULT_K, ScoringInputError, score_benchmark
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_records(path: str) -> list:
|
|
15
|
+
"""Read a JSONL file, or a JSON file holding a list."""
|
|
16
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
17
|
+
if text.lstrip().startswith("["):
|
|
18
|
+
return json.loads(text)
|
|
19
|
+
records = []
|
|
20
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
21
|
+
if line.strip():
|
|
22
|
+
try:
|
|
23
|
+
records.append(json.loads(line))
|
|
24
|
+
except json.JSONDecodeError as err:
|
|
25
|
+
raise SystemExit(f"{path}:{n}: invalid JSON ({err.msg})")
|
|
26
|
+
return records
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_issues(result) -> None:
|
|
30
|
+
for issue in result.issues:
|
|
31
|
+
print(issue, file=sys.stderr)
|
|
32
|
+
s = result.summary()
|
|
33
|
+
print(
|
|
34
|
+
f"Prompt check: {s['status'].replace('_', ' ')} - {s['n_tasks_checked']} of {s['n_tasks_total']} tasks, "
|
|
35
|
+
f"{s['n_requests']} requests, {s['n_errors']} errors, {s['n_warnings']} warnings",
|
|
36
|
+
file=sys.stderr,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv=None) -> int:
|
|
41
|
+
parser = argparse.ArgumentParser(prog="mhb-score", description="MentalHealthBench scorer")
|
|
42
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
43
|
+
|
|
44
|
+
p = sub.add_parser("score", help="score a run from tasks and rubric grades")
|
|
45
|
+
p.add_argument("tasks")
|
|
46
|
+
p.add_argument("grades")
|
|
47
|
+
p.add_argument("--requests", help="generation requests; runs the prompt check and records it in the report")
|
|
48
|
+
p.add_argument("--k", type=int, default=DEFAULT_K, help="responses per task (default 4)")
|
|
49
|
+
p.add_argument("--out", help="write the JSON report here")
|
|
50
|
+
p.add_argument("--md", help="write the Markdown report here")
|
|
51
|
+
p.add_argument("--no-tasks", action="store_true", help="leave per-task scores out of the JSON")
|
|
52
|
+
|
|
53
|
+
c = sub.add_parser("check-prompts", help="check generation requests against the prompting setup")
|
|
54
|
+
c.add_argument("tasks")
|
|
55
|
+
c.add_argument("requests")
|
|
56
|
+
c.add_argument("--k", type=int, help="require exactly this many requests per checked task")
|
|
57
|
+
c.add_argument("--out", help="write the JSON result here")
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
try:
|
|
61
|
+
tasks = load_records(args.tasks)
|
|
62
|
+
if args.command == "check-prompts":
|
|
63
|
+
result = check_prompts(tasks, load_records(args.requests), k=args.k)
|
|
64
|
+
_print_issues(result)
|
|
65
|
+
if args.out:
|
|
66
|
+
Path(args.out).write_text(json.dumps(result.to_dict(), indent=2), encoding="utf-8")
|
|
67
|
+
return 1 if result.errors else 0
|
|
68
|
+
|
|
69
|
+
check = None
|
|
70
|
+
if args.requests:
|
|
71
|
+
check = check_prompts(tasks, load_records(args.requests))
|
|
72
|
+
_print_issues(check)
|
|
73
|
+
report = score_benchmark(tasks, load_records(args.grades), k=args.k, prompt_check=check)
|
|
74
|
+
except ScoringInputError as err:
|
|
75
|
+
print(err, file=sys.stderr)
|
|
76
|
+
return 2
|
|
77
|
+
|
|
78
|
+
if args.out:
|
|
79
|
+
Path(args.out).write_text(
|
|
80
|
+
json.dumps(report.to_dict(include_tasks=not args.no_tasks), indent=2), encoding="utf-8"
|
|
81
|
+
)
|
|
82
|
+
if args.md:
|
|
83
|
+
Path(args.md).write_text(report.to_markdown(), encoding="utf-8")
|
|
84
|
+
print(report.to_markdown())
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
sys.exit(main())
|