hemp-eval 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hemp/__init__.py +32 -0
- hemp/_version.py +6 -0
- hemp/calibrate/__init__.py +24 -0
- hemp/calibrate/agreement.py +33 -0
- hemp/calibrate/labels.py +143 -0
- hemp/calibrate/optimize.py +213 -0
- hemp/calibrate/render.py +87 -0
- hemp/cli.py +649 -0
- hemp/config.py +38 -0
- hemp/dataset.py +92 -0
- hemp/doctor.py +189 -0
- hemp/hashing.py +14 -0
- hemp/images.py +209 -0
- hemp/metrics/__init__.py +31 -0
- hemp/metrics/embeddings.py +72 -0
- hemp/metrics/format.py +137 -0
- hemp/metrics/judge.py +178 -0
- hemp/metrics/plugins.py +73 -0
- hemp/metrics/reference.py +153 -0
- hemp/metrics/registry.py +205 -0
- hemp/metrics/text.py +146 -0
- hemp/plan/__init__.py +24 -0
- hemp/plan/defaults.py +125 -0
- hemp/plan/io.py +349 -0
- hemp/plan/planner.py +312 -0
- hemp/plan/render.py +48 -0
- hemp/plan/schema.py +110 -0
- hemp/plan/validate.py +39 -0
- hemp/profile/__init__.py +25 -0
- hemp/profile/infer.py +113 -0
- hemp/profile/profiler.py +185 -0
- hemp/profile/render.py +94 -0
- hemp/profile/roles.py +115 -0
- hemp/profile/schema.py +94 -0
- hemp/prompt.py +21 -0
- hemp/py.typed +0 -0
- hemp/runner/__init__.py +30 -0
- hemp/runner/baselines.py +62 -0
- hemp/runner/evaluate.py +45 -0
- hemp/runner/judging.py +117 -0
- hemp/runner/parallel.py +37 -0
- hemp/runner/render.py +135 -0
- hemp/runner/results.py +119 -0
- hemp/runner/run.py +261 -0
- hemp/runner/target.py +231 -0
- hemp/store/__init__.py +14 -0
- hemp/store/db.py +392 -0
- hemp/store/diff.py +172 -0
- hemp/store/render.py +160 -0
- hemp_eval-0.1.0.dist-info/METADATA +233 -0
- hemp_eval-0.1.0.dist-info/RECORD +54 -0
- hemp_eval-0.1.0.dist-info/WHEEL +4 -0
- hemp_eval-0.1.0.dist-info/entry_points.txt +3 -0
- hemp_eval-0.1.0.dist-info/licenses/LICENSE +21 -0
hemp/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""HEMP: Help Evaluate My Prompt."""
|
|
2
|
+
|
|
3
|
+
from hemp._version import __version__
|
|
4
|
+
from hemp.dataset import DatasetError, load_dataset
|
|
5
|
+
from hemp.metrics import MetricContext, MetricParams, MetricResult, metric
|
|
6
|
+
from hemp.plan import EvalPlan, PlanError, create_plan, read_plan, write_plan
|
|
7
|
+
from hemp.profile import DatasetProfile, profile_dataset, profile_rows
|
|
8
|
+
from hemp.runner import RunResult, run_eval
|
|
9
|
+
from hemp.store import RunDiff, Store, diff_runs
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DatasetError",
|
|
13
|
+
"DatasetProfile",
|
|
14
|
+
"EvalPlan",
|
|
15
|
+
"MetricContext",
|
|
16
|
+
"MetricParams",
|
|
17
|
+
"MetricResult",
|
|
18
|
+
"PlanError",
|
|
19
|
+
"RunDiff",
|
|
20
|
+
"RunResult",
|
|
21
|
+
"Store",
|
|
22
|
+
"__version__",
|
|
23
|
+
"create_plan",
|
|
24
|
+
"diff_runs",
|
|
25
|
+
"load_dataset",
|
|
26
|
+
"metric",
|
|
27
|
+
"profile_dataset",
|
|
28
|
+
"profile_rows",
|
|
29
|
+
"read_plan",
|
|
30
|
+
"run_eval",
|
|
31
|
+
"write_plan",
|
|
32
|
+
]
|
hemp/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Human labeling, judge tuning, and judge-human agreement."""
|
|
2
|
+
|
|
3
|
+
from hemp.calibrate.agreement import Agreement, agreement
|
|
4
|
+
from hemp.calibrate.labels import Label, LabelItem, label_candidates
|
|
5
|
+
from hemp.calibrate.optimize import (
|
|
6
|
+
MIN_LABELS_TO_TUNE,
|
|
7
|
+
CalibrationFailed,
|
|
8
|
+
CalibrationReport,
|
|
9
|
+
QuestionAgreement,
|
|
10
|
+
calibrate,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MIN_LABELS_TO_TUNE",
|
|
15
|
+
"Agreement",
|
|
16
|
+
"CalibrationFailed",
|
|
17
|
+
"CalibrationReport",
|
|
18
|
+
"Label",
|
|
19
|
+
"LabelItem",
|
|
20
|
+
"QuestionAgreement",
|
|
21
|
+
"agreement",
|
|
22
|
+
"calibrate",
|
|
23
|
+
"label_candidates",
|
|
24
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""How well the judge agrees with human labels: accuracy and Cohen's kappa.
|
|
2
|
+
|
|
3
|
+
Accuracy alone flatters a judge on lopsided labels (if 90% of outputs are good, a
|
|
4
|
+
judge that always says yes agrees 90% of the time). Kappa corrects for agreement
|
|
5
|
+
expected by chance: 1 is perfect, 0 is no better than chance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Agreement(BaseModel):
|
|
14
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
15
|
+
|
|
16
|
+
n: int
|
|
17
|
+
n_agree: int
|
|
18
|
+
accuracy: float | None
|
|
19
|
+
kappa: float | None # None when chance agreement is total (one answer only)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def agreement(pairs: Sequence[tuple[bool, bool]]) -> Agreement:
|
|
23
|
+
"""Agreement over (human, judge) verdict pairs."""
|
|
24
|
+
n = len(pairs)
|
|
25
|
+
if n == 0:
|
|
26
|
+
return Agreement(n=0, n_agree=0, accuracy=None, kappa=None)
|
|
27
|
+
n_agree = sum(1 for human, judge in pairs if human == judge)
|
|
28
|
+
observed = n_agree / n
|
|
29
|
+
human_yes = sum(1 for human, _ in pairs if human) / n
|
|
30
|
+
judge_yes = sum(1 for _, judge in pairs if judge) / n
|
|
31
|
+
chance = human_yes * judge_yes + (1 - human_yes) * (1 - judge_yes)
|
|
32
|
+
kappa = None if chance >= 1 else (observed - chance) / (1 - chance)
|
|
33
|
+
return Agreement(n=n, n_agree=n_agree, accuracy=observed, kappa=kappa)
|
hemp/calibrate/labels.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Human labels for judge questions, and choosing which outputs to label.
|
|
2
|
+
|
|
3
|
+
A label is keyed by exactly what the judge sees (question, task, output, reference),
|
|
4
|
+
so it stays valid across runs. Rewording a question means its old labels no longer
|
|
5
|
+
apply, which is intended: the criterion changed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import dspy
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
from hemp.dataset import Row
|
|
17
|
+
from hemp.hashing import canonical_json, sha256_text
|
|
18
|
+
from hemp.images import image_columns
|
|
19
|
+
from hemp.metrics.judge import to_dspy_image
|
|
20
|
+
from hemp.plan.schema import EvalPlan
|
|
21
|
+
from hemp.prompt import template_fields
|
|
22
|
+
from hemp.runner.judging import reference_for
|
|
23
|
+
from hemp.runner.results import RunResult
|
|
24
|
+
from hemp.runner.target import build_prompt, prompt_images, prompt_text
|
|
25
|
+
|
|
26
|
+
JUDGE_INPUTS = ("task", "output", "reference", "question", "images")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Model(BaseModel):
|
|
30
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def label_key(
|
|
34
|
+
question: str, task: str, output: str, reference: str, images: Sequence[str] = ()
|
|
35
|
+
) -> str:
|
|
36
|
+
parts: list[object] = [question, task, output, reference]
|
|
37
|
+
if images:
|
|
38
|
+
parts.append(list(images))
|
|
39
|
+
return sha256_text(canonical_json(parts))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LabelItem(_Model):
|
|
43
|
+
"""One judge question about one stored output, waiting for a human answer."""
|
|
44
|
+
|
|
45
|
+
question_id: str
|
|
46
|
+
question: str
|
|
47
|
+
task: str
|
|
48
|
+
output: str
|
|
49
|
+
reference: str
|
|
50
|
+
images: tuple[str, ...] = () # image files or URLs in the task, in order
|
|
51
|
+
judge_passed: bool # what the judge said in the run (hidden while labeling)
|
|
52
|
+
run_id: int | None
|
|
53
|
+
variant: str
|
|
54
|
+
example: int
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def key(self) -> str:
|
|
58
|
+
return label_key(self.question, self.task, self.output, self.reference, self.images)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Label(_Model):
|
|
62
|
+
question: str
|
|
63
|
+
task: str
|
|
64
|
+
output: str
|
|
65
|
+
reference: str
|
|
66
|
+
passed: bool # the human's answer
|
|
67
|
+
images: tuple[str, ...] = ()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def key(self) -> str:
|
|
71
|
+
return label_key(self.question, self.task, self.output, self.reference, self.images)
|
|
72
|
+
|
|
73
|
+
def as_example(self) -> dspy.Example:
|
|
74
|
+
return dspy.Example(
|
|
75
|
+
task=self.task,
|
|
76
|
+
output=self.output,
|
|
77
|
+
reference=self.reference,
|
|
78
|
+
question=self.question,
|
|
79
|
+
images=[to_dspy_image(i) for i in self.images],
|
|
80
|
+
answer=self.passed,
|
|
81
|
+
).with_inputs(*JUDGE_INPUTS)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def label_candidates(
|
|
85
|
+
run: RunResult,
|
|
86
|
+
rows: Sequence[Row],
|
|
87
|
+
prompt: str,
|
|
88
|
+
plan: EvalPlan,
|
|
89
|
+
*,
|
|
90
|
+
skip: set[str] | None = None,
|
|
91
|
+
n: int = 20,
|
|
92
|
+
seed: int = 0,
|
|
93
|
+
) -> list[LabelItem]:
|
|
94
|
+
"""Up to `n` outputs to label, spread across questions and across the judge's yes/no.
|
|
95
|
+
|
|
96
|
+
Outputs from baselines are included: they're often bad, which gives the judge
|
|
97
|
+
examples of failures to learn from. Items whose key is in `skip` are left out.
|
|
98
|
+
"""
|
|
99
|
+
skip = skip or set()
|
|
100
|
+
images = image_columns(rows, {*template_fields(prompt), *plan.input_columns})
|
|
101
|
+
base_dir = Path(run.info.data_dir) if run.info.data_dir else None
|
|
102
|
+
groups: dict[tuple[str, bool], list[LabelItem]] = defaultdict(list)
|
|
103
|
+
seen: set[str] = set()
|
|
104
|
+
for criterion in plan.criteria:
|
|
105
|
+
if run.questions.get(criterion.id) != criterion.question:
|
|
106
|
+
continue # this run judged a different version of the question
|
|
107
|
+
for variant in run.variants:
|
|
108
|
+
for ex in variant.examples:
|
|
109
|
+
outcome = next((o for o in ex.checks if o.check_id == criterion.id), None)
|
|
110
|
+
if outcome is None or outcome.passed is None or ex.error is not None:
|
|
111
|
+
continue
|
|
112
|
+
row = rows[ex.index]
|
|
113
|
+
reference = reference_for(criterion, row)
|
|
114
|
+
if reference is None:
|
|
115
|
+
continue
|
|
116
|
+
content = build_prompt(prompt, row, images, base_dir)
|
|
117
|
+
item = LabelItem(
|
|
118
|
+
question_id=criterion.id,
|
|
119
|
+
question=criterion.question,
|
|
120
|
+
task=prompt_text(content),
|
|
121
|
+
output=ex.output,
|
|
122
|
+
reference=reference,
|
|
123
|
+
images=tuple(i.location for i in prompt_images(content)),
|
|
124
|
+
judge_passed=outcome.passed,
|
|
125
|
+
run_id=run.id,
|
|
126
|
+
variant=variant.name,
|
|
127
|
+
example=ex.index,
|
|
128
|
+
)
|
|
129
|
+
if item.key in skip or item.key in seen:
|
|
130
|
+
continue
|
|
131
|
+
seen.add(item.key)
|
|
132
|
+
groups[(criterion.id, outcome.passed)].append(item)
|
|
133
|
+
|
|
134
|
+
rng = random.Random(seed)
|
|
135
|
+
queues = [groups[k] for k in sorted(groups)]
|
|
136
|
+
for queue in queues:
|
|
137
|
+
rng.shuffle(queue)
|
|
138
|
+
picked: list[LabelItem] = []
|
|
139
|
+
while len(picked) < n and any(queues):
|
|
140
|
+
for queue in queues:
|
|
141
|
+
if queue and len(picked) < n:
|
|
142
|
+
picked.append(queue.pop())
|
|
143
|
+
return picked
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Measure the judge against human labels and tune it with DSPy.
|
|
2
|
+
|
|
3
|
+
With enough labels, they're split in two: the judge is tuned on one half (DSPy
|
|
4
|
+
picks worked examples to show it) and both the default and the tuned judge are
|
|
5
|
+
tested on the other half, which tuning never saw. The tuned judge is only kept if
|
|
6
|
+
it agrees with the human more on that held-out half.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import io
|
|
11
|
+
import random
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import dspy
|
|
17
|
+
from pydantic import BaseModel, ConfigDict
|
|
18
|
+
|
|
19
|
+
from hemp.calibrate.agreement import Agreement, agreement
|
|
20
|
+
from hemp.calibrate.labels import Label
|
|
21
|
+
from hemp.metrics.judge import (
|
|
22
|
+
CalibratedJudge,
|
|
23
|
+
Judge,
|
|
24
|
+
_as_bool,
|
|
25
|
+
make_judge,
|
|
26
|
+
module_state,
|
|
27
|
+
to_dspy_image,
|
|
28
|
+
)
|
|
29
|
+
from hemp.runner.parallel import run_all
|
|
30
|
+
|
|
31
|
+
MIN_LABELS_TO_TUNE = 8
|
|
32
|
+
MAX_EXAMPLES_SHOWN = 4 # worked examples the tuned judge sees in its prompt
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CalibrationFailed(RuntimeError):
|
|
36
|
+
"""The judge couldn't be measured, e.g. its model is unreachable."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class QuestionAgreement(BaseModel):
|
|
40
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
41
|
+
|
|
42
|
+
question: str
|
|
43
|
+
n_labels: int
|
|
44
|
+
before: Agreement # the default judge
|
|
45
|
+
after: Agreement | None = None # the tuned judge, on the same held-out labels
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CalibrationReport(BaseModel):
|
|
49
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
50
|
+
|
|
51
|
+
n_labels: int
|
|
52
|
+
n_train: int
|
|
53
|
+
n_test: int # labels the agreement numbers are measured on
|
|
54
|
+
before: Agreement
|
|
55
|
+
after: Agreement | None
|
|
56
|
+
by_question: list[QuestionAgreement]
|
|
57
|
+
n_failed: int # judge calls that failed and were left out
|
|
58
|
+
tuned: CalibratedJudge | None # set only when tuning improved agreement
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def improved(self) -> bool:
|
|
62
|
+
return self.tuned is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def calibrate(
|
|
66
|
+
labels: Sequence[Label],
|
|
67
|
+
judge_lm: Any,
|
|
68
|
+
*,
|
|
69
|
+
judge_model: str,
|
|
70
|
+
workers: int = 4,
|
|
71
|
+
seed: int = 0,
|
|
72
|
+
now: datetime | None = None,
|
|
73
|
+
) -> CalibrationReport:
|
|
74
|
+
"""Measure the default judge on the labels and, with enough of them, tune it."""
|
|
75
|
+
if not labels:
|
|
76
|
+
raise CalibrationFailed("There are no labels yet. Label some outputs first.")
|
|
77
|
+
base = make_judge()
|
|
78
|
+
base.set_lm(judge_lm)
|
|
79
|
+
|
|
80
|
+
if len(labels) < MIN_LABELS_TO_TUNE:
|
|
81
|
+
verdicts = _predict(base, labels, workers)
|
|
82
|
+
return CalibrationReport(
|
|
83
|
+
n_labels=len(labels),
|
|
84
|
+
n_train=0,
|
|
85
|
+
n_test=len(labels),
|
|
86
|
+
before=_agree(labels, verdicts),
|
|
87
|
+
after=None,
|
|
88
|
+
by_question=_by_question(labels, verdicts, None),
|
|
89
|
+
n_failed=sum(1 for v in verdicts if v is None),
|
|
90
|
+
tuned=None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
train, test = split(labels, seed)
|
|
94
|
+
before_verdicts = _predict(base, test, workers)
|
|
95
|
+
tuned_judge = _tune(train, judge_lm, seed)
|
|
96
|
+
after_verdicts = _predict(tuned_judge, test, workers)
|
|
97
|
+
before, after = _agree(test, before_verdicts), _agree(test, after_verdicts)
|
|
98
|
+
improved = (
|
|
99
|
+
after.accuracy is not None
|
|
100
|
+
and before.accuracy is not None
|
|
101
|
+
and after.accuracy > before.accuracy
|
|
102
|
+
)
|
|
103
|
+
tuned = (
|
|
104
|
+
CalibratedJudge(
|
|
105
|
+
judge_model=judge_model,
|
|
106
|
+
state=module_state(tuned_judge),
|
|
107
|
+
created_at=now or datetime.now(UTC).replace(microsecond=0),
|
|
108
|
+
n_labels=len(labels),
|
|
109
|
+
n_test=len(test),
|
|
110
|
+
agreement=after.accuracy,
|
|
111
|
+
kappa=after.kappa,
|
|
112
|
+
)
|
|
113
|
+
if improved
|
|
114
|
+
else None
|
|
115
|
+
)
|
|
116
|
+
return CalibrationReport(
|
|
117
|
+
n_labels=len(labels),
|
|
118
|
+
n_train=len(train),
|
|
119
|
+
n_test=len(test),
|
|
120
|
+
before=before,
|
|
121
|
+
after=after,
|
|
122
|
+
by_question=_by_question(test, before_verdicts, after_verdicts),
|
|
123
|
+
n_failed=sum(1 for v in [*before_verdicts, *after_verdicts] if v is None),
|
|
124
|
+
tuned=tuned,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def split(labels: Sequence[Label], seed: int = 0) -> tuple[list[Label], list[Label]]:
|
|
129
|
+
"""Half for tuning, half for testing, keeping yes and no labels in both halves."""
|
|
130
|
+
rng = random.Random(seed)
|
|
131
|
+
train: list[Label] = []
|
|
132
|
+
test: list[Label] = []
|
|
133
|
+
for answer in (True, False):
|
|
134
|
+
group = sorted((lb for lb in labels if lb.passed is answer), key=lambda lb: lb.key)
|
|
135
|
+
rng.shuffle(group)
|
|
136
|
+
half = len(group) // 2
|
|
137
|
+
train += group[half:]
|
|
138
|
+
test += group[:half]
|
|
139
|
+
return train, test
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _tune(train: Sequence[Label], judge_lm: Any, seed: int) -> Judge:
|
|
143
|
+
def metric(example: dspy.Example, pred: dspy.Prediction, trace: object = None) -> bool:
|
|
144
|
+
try:
|
|
145
|
+
return _as_bool(pred.answer) is bool(example.answer)
|
|
146
|
+
except ValueError:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
student = make_judge()
|
|
150
|
+
student.set_lm(judge_lm)
|
|
151
|
+
optimizer = dspy.BootstrapFewShot(
|
|
152
|
+
metric=metric,
|
|
153
|
+
max_bootstrapped_demos=MAX_EXAMPLES_SHOWN,
|
|
154
|
+
max_labeled_demos=MAX_EXAMPLES_SHOWN,
|
|
155
|
+
)
|
|
156
|
+
trainset = [lb.as_example() for lb in train]
|
|
157
|
+
random.Random(seed).shuffle(trainset)
|
|
158
|
+
# redirect_stdout: BootstrapFewShot prints a progress line we don't want in HEMP's output.
|
|
159
|
+
with dspy.context(lm=judge_lm), contextlib.redirect_stdout(io.StringIO()):
|
|
160
|
+
tuned: Judge = optimizer.compile(student, trainset=trainset)
|
|
161
|
+
tuned.set_lm(judge_lm)
|
|
162
|
+
return tuned
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _predict(judge: Judge, labels: Sequence[Label], workers: int) -> list[bool | None]:
|
|
166
|
+
def one(label: Label) -> bool | None:
|
|
167
|
+
try:
|
|
168
|
+
pred = judge(
|
|
169
|
+
task=label.task,
|
|
170
|
+
output=label.output,
|
|
171
|
+
question=label.question,
|
|
172
|
+
reference=label.reference,
|
|
173
|
+
images=[to_dspy_image(i) for i in label.images],
|
|
174
|
+
)
|
|
175
|
+
return _as_bool(pred.answer)
|
|
176
|
+
except Exception:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def check_first(verdict: bool | None) -> None:
|
|
180
|
+
if verdict is None:
|
|
181
|
+
raise CalibrationFailed(
|
|
182
|
+
"The judge couldn't answer. Is its model running (`ollama serve`)? "
|
|
183
|
+
"You can pick another one with --judge-model."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return run_all(one, labels, workers=workers, check_first=check_first)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _agree(labels: Sequence[Label], verdicts: Sequence[bool | None]) -> Agreement:
|
|
190
|
+
return agreement(
|
|
191
|
+
[(lb.passed, v) for lb, v in zip(labels, verdicts, strict=True) if v is not None]
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _by_question(
|
|
196
|
+
labels: Sequence[Label],
|
|
197
|
+
before: Sequence[bool | None],
|
|
198
|
+
after: Sequence[bool | None] | None,
|
|
199
|
+
) -> list[QuestionAgreement]:
|
|
200
|
+
questions = list(dict.fromkeys(lb.question for lb in labels))
|
|
201
|
+
out = []
|
|
202
|
+
for question in questions:
|
|
203
|
+
idx = [i for i, lb in enumerate(labels) if lb.question == question]
|
|
204
|
+
subset = [labels[i] for i in idx]
|
|
205
|
+
out.append(
|
|
206
|
+
QuestionAgreement(
|
|
207
|
+
question=question,
|
|
208
|
+
n_labels=len(idx),
|
|
209
|
+
before=_agree(subset, [before[i] for i in idx]),
|
|
210
|
+
after=_agree(subset, [after[i] for i in idx]) if after is not None else None,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return out
|
hemp/calibrate/render.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Terminal output for `hemp calibrate`: labeling screens and the agreement report."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Group, RenderableType
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from hemp.calibrate.agreement import Agreement
|
|
9
|
+
from hemp.calibrate.labels import LabelItem
|
|
10
|
+
from hemp.calibrate.optimize import MIN_LABELS_TO_TUNE, CalibrationReport
|
|
11
|
+
from hemp.metrics.judge import NO_REFERENCE
|
|
12
|
+
|
|
13
|
+
TASK_PREVIEW_CHARS = 1500
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_item(item: LabelItem, number: int, total: int) -> RenderableType:
|
|
17
|
+
task = item.task
|
|
18
|
+
if len(task) > TASK_PREVIEW_CHARS:
|
|
19
|
+
task = task[:TASK_PREVIEW_CHARS] + "\n[...]"
|
|
20
|
+
source = "your prompt" if item.variant == "prompt" else f"baseline {item.variant}"
|
|
21
|
+
parts: list[RenderableType] = [
|
|
22
|
+
Text.assemble(("Question: ", "bold"), (item.question, "bold cyan")),
|
|
23
|
+
Text(""),
|
|
24
|
+
Text("The task the model got:", style="bold"),
|
|
25
|
+
Text(task, style="dim"),
|
|
26
|
+
]
|
|
27
|
+
if item.images:
|
|
28
|
+
parts += [Text(""), Text("Images in the task (open them to look):", style="bold")]
|
|
29
|
+
parts += [Text(f" [image {n}] {path}") for n, path in enumerate(item.images, start=1)]
|
|
30
|
+
if item.reference != NO_REFERENCE:
|
|
31
|
+
parts += [Text(""), Text.assemble(("Reference answer: ", "bold"), item.reference)]
|
|
32
|
+
parts += [Text(""), Text(f"Output ({source}):", style="bold"), Text(item.output)]
|
|
33
|
+
return Panel(Group(*parts), title=f"Label {number} of {total}", title_align="left")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cell(a: Agreement | None) -> Text:
|
|
37
|
+
if a is None:
|
|
38
|
+
return Text("-", style="dim")
|
|
39
|
+
if a.accuracy is None:
|
|
40
|
+
return Text("no verdicts", style="dim")
|
|
41
|
+
kappa = "n/a" if a.kappa is None else f"{a.kappa:.2f}"
|
|
42
|
+
return Text.assemble(f"{a.accuracy:.0%} ({a.n_agree}/{a.n})", (f" kappa {kappa}", "dim"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_report(report: CalibrationReport) -> RenderableType:
|
|
46
|
+
tuned = report.after is not None
|
|
47
|
+
table = Table(header_style="bold", title_justify="left")
|
|
48
|
+
table.add_column("Question", overflow="fold")
|
|
49
|
+
table.add_column("Labels", justify="right")
|
|
50
|
+
table.add_column("Default judge" if tuned else "Judge agrees with you", justify="right")
|
|
51
|
+
if tuned:
|
|
52
|
+
table.add_column("Tuned judge", justify="right")
|
|
53
|
+
for q in report.by_question:
|
|
54
|
+
cells = [_cell(q.before)] + ([_cell(q.after)] if tuned else [])
|
|
55
|
+
table.add_row(q.question, str(q.n_labels), *cells)
|
|
56
|
+
table.add_section()
|
|
57
|
+
overall = [_cell(report.before)] + ([_cell(report.after)] if tuned else [])
|
|
58
|
+
table.add_row(Text("All questions", style="bold"), str(report.n_test), *overall)
|
|
59
|
+
|
|
60
|
+
if tuned:
|
|
61
|
+
intro = (
|
|
62
|
+
f"Tuned the judge on {report.n_train} of your {report.n_labels} labels and tested "
|
|
63
|
+
f"both judges on the other {report.n_test}, which tuning never saw."
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
intro = f"How often the judge agrees with your {report.n_labels} label(s):"
|
|
67
|
+
parts: list[RenderableType] = [Text(intro), table]
|
|
68
|
+
parts.append(
|
|
69
|
+
Text(
|
|
70
|
+
"Kappa corrects for agreement by chance: 1 is perfect, 0 is no better than "
|
|
71
|
+
"guessing, above 0.6 is good.",
|
|
72
|
+
style="dim",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
if report.n_failed:
|
|
76
|
+
parts.append(
|
|
77
|
+
Text(f"{report.n_failed} judge call(s) failed and were left out.", style="yellow")
|
|
78
|
+
)
|
|
79
|
+
if not tuned:
|
|
80
|
+
parts.append(
|
|
81
|
+
Text(
|
|
82
|
+
f"Label at least {MIN_LABELS_TO_TUNE} outputs to tune the judge "
|
|
83
|
+
f"(you have {report.n_labels}).",
|
|
84
|
+
style="yellow",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return Group(*parts)
|