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.
Files changed (54) hide show
  1. hemp/__init__.py +32 -0
  2. hemp/_version.py +6 -0
  3. hemp/calibrate/__init__.py +24 -0
  4. hemp/calibrate/agreement.py +33 -0
  5. hemp/calibrate/labels.py +143 -0
  6. hemp/calibrate/optimize.py +213 -0
  7. hemp/calibrate/render.py +87 -0
  8. hemp/cli.py +649 -0
  9. hemp/config.py +38 -0
  10. hemp/dataset.py +92 -0
  11. hemp/doctor.py +189 -0
  12. hemp/hashing.py +14 -0
  13. hemp/images.py +209 -0
  14. hemp/metrics/__init__.py +31 -0
  15. hemp/metrics/embeddings.py +72 -0
  16. hemp/metrics/format.py +137 -0
  17. hemp/metrics/judge.py +178 -0
  18. hemp/metrics/plugins.py +73 -0
  19. hemp/metrics/reference.py +153 -0
  20. hemp/metrics/registry.py +205 -0
  21. hemp/metrics/text.py +146 -0
  22. hemp/plan/__init__.py +24 -0
  23. hemp/plan/defaults.py +125 -0
  24. hemp/plan/io.py +349 -0
  25. hemp/plan/planner.py +312 -0
  26. hemp/plan/render.py +48 -0
  27. hemp/plan/schema.py +110 -0
  28. hemp/plan/validate.py +39 -0
  29. hemp/profile/__init__.py +25 -0
  30. hemp/profile/infer.py +113 -0
  31. hemp/profile/profiler.py +185 -0
  32. hemp/profile/render.py +94 -0
  33. hemp/profile/roles.py +115 -0
  34. hemp/profile/schema.py +94 -0
  35. hemp/prompt.py +21 -0
  36. hemp/py.typed +0 -0
  37. hemp/runner/__init__.py +30 -0
  38. hemp/runner/baselines.py +62 -0
  39. hemp/runner/evaluate.py +45 -0
  40. hemp/runner/judging.py +117 -0
  41. hemp/runner/parallel.py +37 -0
  42. hemp/runner/render.py +135 -0
  43. hemp/runner/results.py +119 -0
  44. hemp/runner/run.py +261 -0
  45. hemp/runner/target.py +231 -0
  46. hemp/store/__init__.py +14 -0
  47. hemp/store/db.py +392 -0
  48. hemp/store/diff.py +172 -0
  49. hemp/store/render.py +160 -0
  50. hemp_eval-0.1.0.dist-info/METADATA +233 -0
  51. hemp_eval-0.1.0.dist-info/RECORD +54 -0
  52. hemp_eval-0.1.0.dist-info/WHEEL +4 -0
  53. hemp_eval-0.1.0.dist-info/entry_points.txt +3 -0
  54. 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,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("hemp-eval")
5
+ except PackageNotFoundError: # pragma: no cover - running from a source tree without install
6
+ __version__ = "0.0.0"
@@ -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)
@@ -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
@@ -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)