rulesmith 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.
- rulesmith/__init__.py +1 -0
- rulesmith/ablate.py +99 -0
- rulesmith/arena.py +172 -0
- rulesmith/bench.py +1068 -0
- rulesmith/calibrate.py +457 -0
- rulesmith/chat_judge.py +205 -0
- rulesmith/chess.py +526 -0
- rulesmith/clef.py +66 -0
- rulesmith/cli.py +1234 -0
- rulesmith/diagram.py +226 -0
- rulesmith/doom.py +550 -0
- rulesmith/extract.py +77 -0
- rulesmith/grade.py +85 -0
- rulesmith/graph.py +975 -0
- rulesmith/label.py +67 -0
- rulesmith/level.py +389 -0
- rulesmith/maps.py +96 -0
- rulesmith/mine.py +313 -0
- rulesmith/optimize.py +931 -0
- rulesmith/rules.py +1017 -0
- rulesmith/runtime.py +711 -0
- rulesmith/serve.py +68 -0
- rulesmith/tuning.py +134 -0
- rulesmith-0.1.0.dist-info/METADATA +131 -0
- rulesmith-0.1.0.dist-info/RECORD +28 -0
- rulesmith-0.1.0.dist-info/WHEEL +4 -0
- rulesmith-0.1.0.dist-info/entry_points.txt +2 -0
- rulesmith-0.1.0.dist-info/licenses/LICENSE +21 -0
rulesmith/bench.py
ADDED
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
"""Public benchmarks: rulesmith against the baselines a reader would reach for first.
|
|
2
|
+
|
|
3
|
+
Each run draws a task from a public dataset with a seed, scores every method on the same held-out
|
|
4
|
+
test split, and records accuracy with the judge calls and tokens each example cost. A method a
|
|
5
|
+
run has already recorded is skipped, so an interrupted benchmark resumes where it stopped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import csv
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import random
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from statistics import mean, stdev
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
# pyarrow must load before dspy: DSPy 3.3 swaps numpy for a lazy proxy that breaks a compiled
|
|
25
|
+
# extension importing numpy afterwards ("numpy._core.multiarray failed to import").
|
|
26
|
+
import pyarrow.parquet as parquet # isort: skip
|
|
27
|
+
import dspy
|
|
28
|
+
from dspy.utils.exceptions import AdapterParseError
|
|
29
|
+
|
|
30
|
+
from rulesmith import calibrate
|
|
31
|
+
from rulesmith.graph import Plan, State, Task
|
|
32
|
+
from rulesmith.optimize import (
|
|
33
|
+
GraphProposer,
|
|
34
|
+
Labels,
|
|
35
|
+
SearchConfig,
|
|
36
|
+
evaluate_plan,
|
|
37
|
+
optimize_plan,
|
|
38
|
+
text_adapter,
|
|
39
|
+
)
|
|
40
|
+
from rulesmith.rules import compile_rules
|
|
41
|
+
from rulesmith.runtime import DecisionProgram
|
|
42
|
+
from rulesmith.tuning import direct_question
|
|
43
|
+
|
|
44
|
+
Rows = dict[str, list[tuple[State, str]]]
|
|
45
|
+
COVERAGES = (0.8, 0.5)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Dataset:
|
|
50
|
+
"""A public dataset as rows of (input, label), pooled by the dataset's own splits. Training
|
|
51
|
+
and validation draw from its 'train' pool and the test split from its 'test' pool, so nothing
|
|
52
|
+
a search sees overlaps what it is scored on."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
description: str
|
|
56
|
+
rows: Callable[[], Rows]
|
|
57
|
+
describe: Callable[[str], str]
|
|
58
|
+
# Draw every label equally, for a dataset whose rare label is the one that matters.
|
|
59
|
+
balanced: bool = False
|
|
60
|
+
# The rules a person would write in a few minutes, where a field obviously decides most cases.
|
|
61
|
+
handwritten: str | None = None
|
|
62
|
+
|
|
63
|
+
def prevalence(self) -> dict[str, float]:
|
|
64
|
+
"""Each label's share of the dataset's own training pool: the rate real traffic has."""
|
|
65
|
+
found = [label for _, label in self.rows()["train"]]
|
|
66
|
+
return {label: found.count(label) / len(found) for label in sorted(set(found))}
|
|
67
|
+
|
|
68
|
+
def labels(self) -> dict[str, str]:
|
|
69
|
+
"""Every label the dataset trains on, with what the direct question says about it."""
|
|
70
|
+
found = sorted({label for _, label in self.rows()["train"]})
|
|
71
|
+
return {label: self.describe(label) for label in found}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sample(dataset: Dataset, train: int, validation: int, test: int, seed: int) -> Task:
|
|
75
|
+
"""A task of the requested sizes, the same for the same seed. Repeated inputs are dropped,
|
|
76
|
+
since a task holds each input once."""
|
|
77
|
+
pools = dataset.rows()
|
|
78
|
+
rng = random.Random(seed)
|
|
79
|
+
seen: set[str] = set()
|
|
80
|
+
|
|
81
|
+
def order(pool: str) -> list[tuple[State, str]]:
|
|
82
|
+
rows = list(pools[pool])
|
|
83
|
+
rng.shuffle(rows)
|
|
84
|
+
if not dataset.balanced:
|
|
85
|
+
return rows
|
|
86
|
+
by_label: dict[str, list] = {}
|
|
87
|
+
for row in rows:
|
|
88
|
+
by_label.setdefault(row[1], []).append(row)
|
|
89
|
+
# Take one of each label in turn, so any prefix holds every label about equally.
|
|
90
|
+
groups = [by_label[label] for label in sorted(by_label)]
|
|
91
|
+
return [
|
|
92
|
+
group[i] for i in range(max(map(len, groups))) for group in groups if i < len(group)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def draw(pool: str, count: int) -> list[dict]:
|
|
96
|
+
drawn = []
|
|
97
|
+
for state, label in order(pool):
|
|
98
|
+
key = json.dumps(state, sort_keys=True)
|
|
99
|
+
if key not in seen:
|
|
100
|
+
seen.add(key)
|
|
101
|
+
drawn.append({"state": state, "label": label})
|
|
102
|
+
if len(drawn) == count:
|
|
103
|
+
return drawn
|
|
104
|
+
raise ValueError(f"the {pool} pool of {dataset.name} holds fewer than {count} inputs")
|
|
105
|
+
|
|
106
|
+
held = draw("test", test)
|
|
107
|
+
searched = draw("train", train + validation)
|
|
108
|
+
# A balanced draw alternates labels, so split it without handing one split every even row.
|
|
109
|
+
rng.shuffle(searched)
|
|
110
|
+
splits = {"train": searched[:train], "validation": searched[train:], "test": held}
|
|
111
|
+
described = dataset.labels()
|
|
112
|
+
return Task(
|
|
113
|
+
description=dataset.description,
|
|
114
|
+
labels=sorted(described),
|
|
115
|
+
descriptions=described,
|
|
116
|
+
**{
|
|
117
|
+
split: [{"id": f"{split}-{index}", **row} for index, row in enumerate(rows)]
|
|
118
|
+
for split, rows in splits.items()
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def direct_plan(task: Task) -> Plan:
|
|
124
|
+
"""The simplest thing a judge can do: one choice question over every label, no graph."""
|
|
125
|
+
return Plan.model_validate(
|
|
126
|
+
{
|
|
127
|
+
"nodes": {
|
|
128
|
+
"label": {
|
|
129
|
+
"type": "call",
|
|
130
|
+
"question": direct_question(task),
|
|
131
|
+
"inputs": {"observation": {"type": "state"}},
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"outputs": {"label": {"type": "node", "node": "label"}},
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class Answer:
|
|
141
|
+
"""One test example as a method answered it, with what the answer cost."""
|
|
142
|
+
|
|
143
|
+
correct: bool
|
|
144
|
+
# How sure the method was, to rank answers by; None for a method that cannot say.
|
|
145
|
+
confidence: float | None = None
|
|
146
|
+
calls: int = 0
|
|
147
|
+
input_tokens: int = 0
|
|
148
|
+
output_tokens: int = 0
|
|
149
|
+
invalid: bool = False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def graph_answers(records: list[dict]) -> list[Answer]:
|
|
153
|
+
return [
|
|
154
|
+
Answer(
|
|
155
|
+
correct=bool(record["correct"]),
|
|
156
|
+
# A graph that failed to answer is not sure at all.
|
|
157
|
+
confidence=0.0 if record.get("error") else record["confidence"],
|
|
158
|
+
calls=len(record.get("calls", {})),
|
|
159
|
+
input_tokens=record.get("input_tokens", 0),
|
|
160
|
+
output_tokens=record.get("output_tokens", 0),
|
|
161
|
+
invalid=bool(record.get("error")),
|
|
162
|
+
)
|
|
163
|
+
for record in records
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def selective(answers: list[Answer], coverage: float) -> float | None:
|
|
168
|
+
"""Accuracy on the share of examples a method was surest of: what it scores when it may pass
|
|
169
|
+
the rest to a person. None for a method with no confidence to rank by."""
|
|
170
|
+
if any(answer.confidence is None for answer in answers):
|
|
171
|
+
return None
|
|
172
|
+
ranked = sorted(answers, key=lambda answer: -answer.confidence)
|
|
173
|
+
kept = ranked[: max(1, math.ceil(coverage * len(ranked)))]
|
|
174
|
+
return mean(answer.correct for answer in kept)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def result(method: str, seed: int, answers: list[Answer], seconds: float, **extra) -> dict:
|
|
178
|
+
count = len(answers)
|
|
179
|
+
return {
|
|
180
|
+
"method": method,
|
|
181
|
+
"seed": seed,
|
|
182
|
+
"accuracy": mean(answer.correct for answer in answers),
|
|
183
|
+
**{f"accuracy_at_{round(k * 100)}": selective(answers, k) for k in COVERAGES},
|
|
184
|
+
"calls_per_example": sum(answer.calls for answer in answers) / count,
|
|
185
|
+
"tokens_per_example": sum(answer.input_tokens for answer in answers) / count,
|
|
186
|
+
"output_tokens_per_example": sum(answer.output_tokens for answer in answers) / count,
|
|
187
|
+
"seconds_per_example": seconds / count,
|
|
188
|
+
"invalid_rate": mean(answer.invalid for answer in answers),
|
|
189
|
+
# Kept per example, so two methods can be compared on the very same examples.
|
|
190
|
+
"correct": [answer.correct for answer in answers],
|
|
191
|
+
**extra,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def record(method: str, seed: int, task: Task, plan: Plan, client, model: str, **extra) -> dict:
|
|
196
|
+
"""A graph scored on the task's test split."""
|
|
197
|
+
started = time.perf_counter()
|
|
198
|
+
scored = evaluate_plan(plan, task.test, client, model, Labels(task))
|
|
199
|
+
seconds = time.perf_counter() - started
|
|
200
|
+
real = calibrate.at_prevalence(task, scored["examples"])
|
|
201
|
+
if real is not None:
|
|
202
|
+
extra = extra | {"at_prevalence": real}
|
|
203
|
+
return result(method, seed, graph_answers(scored["examples"]), seconds, **extra)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def majority(seed: int, task: Task) -> dict:
|
|
207
|
+
run = record("majority", seed, task, task.baseline_plan(), None, "none")
|
|
208
|
+
# One label for everything ranks nothing, so it has no coverage to trade.
|
|
209
|
+
return run | {f"accuracy_at_{round(k * 100)}": None for k in COVERAGES}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def text(state: State) -> str:
|
|
213
|
+
return state if isinstance(state, str) else json.dumps(state, sort_keys=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def tfidf(seed: int, task: Task) -> dict:
|
|
217
|
+
"""Logistic regression on word and word-pair counts, trained on everything search sees."""
|
|
218
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
219
|
+
from sklearn.linear_model import LogisticRegression
|
|
220
|
+
from sklearn.pipeline import make_pipeline
|
|
221
|
+
|
|
222
|
+
seen = task.train + task.validation
|
|
223
|
+
model = make_pipeline(
|
|
224
|
+
TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True),
|
|
225
|
+
LogisticRegression(max_iter=2000),
|
|
226
|
+
)
|
|
227
|
+
model.fit([text(e.state) for e in seen], [e.label for e in seen])
|
|
228
|
+
started = time.perf_counter()
|
|
229
|
+
odds = model.predict_proba([text(e.state) for e in task.test])
|
|
230
|
+
seconds = time.perf_counter() - started
|
|
231
|
+
classes = list(model.classes_)
|
|
232
|
+
answers = [
|
|
233
|
+
Answer(
|
|
234
|
+
correct=classes[max(range(len(row)), key=row.__getitem__)] == e.label,
|
|
235
|
+
confidence=float(max(row)),
|
|
236
|
+
)
|
|
237
|
+
for row, e in zip(odds, task.test, strict=True)
|
|
238
|
+
]
|
|
239
|
+
return result("tfidf", seed, answers, seconds)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def search(
|
|
243
|
+
seed: int, task: Task, client, model: str, proposer, budget: int, call_cost: float
|
|
244
|
+
) -> dict:
|
|
245
|
+
"""rulesmith's search, seeded with the direct call, so the comparison that matters is
|
|
246
|
+
whether a graph improves on asking the judge once."""
|
|
247
|
+
# 50 validation examples reward a lucky candidate; keep the direct call unless the gain holds.
|
|
248
|
+
config = SearchConfig(
|
|
249
|
+
max_metric_calls=budget,
|
|
250
|
+
seed=seed,
|
|
251
|
+
call_cost=call_cost,
|
|
252
|
+
significance=SIGNIFICANCE,
|
|
253
|
+
mine=True,
|
|
254
|
+
)
|
|
255
|
+
best, report = optimize_plan(task, direct_plan(task), client, model, proposer, config)
|
|
256
|
+
# Measured, its rules are as sure as they were right on the examples search saw, rather than
|
|
257
|
+
# certain, which is what ranking answers by confidence needs.
|
|
258
|
+
best = calibrate.measure(best, task, client, model)
|
|
259
|
+
return record(
|
|
260
|
+
"search",
|
|
261
|
+
seed,
|
|
262
|
+
task,
|
|
263
|
+
best,
|
|
264
|
+
client,
|
|
265
|
+
model,
|
|
266
|
+
validation=report["validation_score"],
|
|
267
|
+
seed_validation=report["seed_validation_score"],
|
|
268
|
+
kept_seed=report["kept_seed"],
|
|
269
|
+
mined=report["mined"],
|
|
270
|
+
nodes=len(best.nodes),
|
|
271
|
+
graph=best.model_dump(mode="json"),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def dspy_examples(split) -> list[dspy.Example]:
|
|
276
|
+
return [dspy.Example(text=text(e.state), label=e.label).with_inputs("text") for e in split]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def dspy_answers(program: dspy.Module, task: Task, student: dspy.LM) -> tuple[list[Answer], float]:
|
|
280
|
+
"""A DSPy program's answers on the test split. A reply that names no allowed label is an
|
|
281
|
+
invalid answer, counted wrong, since a typed judgment cannot give one."""
|
|
282
|
+
answers, started = [], time.perf_counter()
|
|
283
|
+
with dspy.context(lm=student, adapter=text_adapter()):
|
|
284
|
+
for example in dspy_examples(task.test):
|
|
285
|
+
before = len(student.history)
|
|
286
|
+
try:
|
|
287
|
+
label = program(text=example.text).label
|
|
288
|
+
except AdapterParseError:
|
|
289
|
+
label = None
|
|
290
|
+
# A reply naming no allowed label, or a failure the program reported as no answer.
|
|
291
|
+
invalid = label is None
|
|
292
|
+
usage = [entry.get("usage") or {} for entry in student.history[before:]]
|
|
293
|
+
answers.append(
|
|
294
|
+
Answer(
|
|
295
|
+
correct=label == example.label,
|
|
296
|
+
calls=len(usage),
|
|
297
|
+
input_tokens=sum(u.get("prompt_tokens", 0) for u in usage),
|
|
298
|
+
output_tokens=sum(u.get("completion_tokens", 0) for u in usage),
|
|
299
|
+
invalid=invalid,
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
return answers, time.perf_counter() - started
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def typed_program(task: Task, judge_url: str, judge: str, timeout: float) -> dspy.Predict:
|
|
306
|
+
"""DSPy's own typed classifier on the judge: one choice question over the labels, asked as
|
|
307
|
+
the direct call asks it, with the task description in the state for DSPy's GEPA to rewrite.
|
|
308
|
+
DSPy's decision types are experimental, so they are imported where they are used."""
|
|
309
|
+
from dspy.experimental import Choice, TypeSafe
|
|
310
|
+
|
|
311
|
+
options = tuple((label, task.descriptions[label]) for label in task.labels)
|
|
312
|
+
signature = dspy.make_signature(
|
|
313
|
+
{
|
|
314
|
+
"text": (str, dspy.InputField()),
|
|
315
|
+
"label": (Choice[options], dspy.OutputField(desc=task.description)),
|
|
316
|
+
},
|
|
317
|
+
task.description,
|
|
318
|
+
)
|
|
319
|
+
program = dspy.Predict(signature)
|
|
320
|
+
program.set_lm(TypeSafe(judge, api_key="local-no-auth", base_url=judge_url, timeout=timeout))
|
|
321
|
+
return program
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def typed_answers(program: dspy.Module, task: Task) -> tuple[list[Answer], float]:
|
|
325
|
+
"""A typed DSPy program's answers on the test split, each one judge call, as sure as the
|
|
326
|
+
judge said. The judge is asked afresh: DSPy's cache, which the fit relies on, would make a
|
|
327
|
+
row asked during search cost nothing here."""
|
|
328
|
+
program = program.deepcopy()
|
|
329
|
+
lm = next(predict for _, predict in program.named_predictors()).lm.copy(cache=False)
|
|
330
|
+
program.set_lm(lm)
|
|
331
|
+
answers, started = [], time.perf_counter()
|
|
332
|
+
for example in dspy_examples(task.test):
|
|
333
|
+
before = len(lm.history)
|
|
334
|
+
label = program(text=example.text).label
|
|
335
|
+
usage = [entry.get("usage") or {} for entry in lm.history[before:]]
|
|
336
|
+
answers.append(
|
|
337
|
+
Answer(
|
|
338
|
+
correct=label is not None and label.value == example.label,
|
|
339
|
+
confidence=None if label is None else label.confidence,
|
|
340
|
+
calls=len(usage),
|
|
341
|
+
input_tokens=sum(u.get("prompt_tokens", 0) for u in usage),
|
|
342
|
+
output_tokens=sum(u.get("completion_tokens", 0) for u in usage),
|
|
343
|
+
invalid=label is None,
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
return answers, time.perf_counter() - started
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def typed_metric(gold, predicted, trace=None, pred_name=None, pred_trace=None):
|
|
350
|
+
answered = None if predicted.label is None else predicted.label.value
|
|
351
|
+
right = answered == gold.label
|
|
352
|
+
return dspy.Prediction(
|
|
353
|
+
score=float(right),
|
|
354
|
+
feedback="Correct." if right else f"Expected {gold.label}, answered {answered}.",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def fitted_weights(program: dspy.Module, task: Task) -> tuple[dspy.Module, dict]:
|
|
359
|
+
"""The program with its option weights fitted by DSPy's ReAnchor on the training examples,
|
|
360
|
+
and what the fit did: the training and validation scores before and after, and whether any
|
|
361
|
+
weight was kept."""
|
|
362
|
+
from dspy.experimental import ReAnchor
|
|
363
|
+
|
|
364
|
+
optimizer = ReAnchor(typed_metric, num_threads=DSPY_THREADS)
|
|
365
|
+
fitted = optimizer.compile(
|
|
366
|
+
program, trainset=dspy_examples(task.train), valset=dspy_examples(task.validation)
|
|
367
|
+
)
|
|
368
|
+
report = optimizer.report
|
|
369
|
+
return fitted, {
|
|
370
|
+
"train_before": report["train_score_before"],
|
|
371
|
+
"train_after": report["train_score"],
|
|
372
|
+
"validation_before": report.get("val_score_before"),
|
|
373
|
+
"validation_after": report.get("val_score"),
|
|
374
|
+
"kept": any("skipped" not in row for row in report["fitted"]),
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def dspy_typed(
|
|
379
|
+
seed: int,
|
|
380
|
+
task: Task,
|
|
381
|
+
judge_url: str,
|
|
382
|
+
judge: str,
|
|
383
|
+
reflection: dspy.LM,
|
|
384
|
+
budget: int,
|
|
385
|
+
timeout: float,
|
|
386
|
+
) -> dict:
|
|
387
|
+
"""The fair DSPy comparison: its typed classifier on the same judge as the graphs, the
|
|
388
|
+
instructions the judge reads rewritten by DSPy's GEPA on the same budget as search, then
|
|
389
|
+
the option weights fitted by ReAnchor. What a graph gets from being a graph is what is left
|
|
390
|
+
over this."""
|
|
391
|
+
program = Failsafe(typed_program(task, judge_url, judge, timeout))
|
|
392
|
+
optimizer = dspy.GEPA(
|
|
393
|
+
metric=typed_metric,
|
|
394
|
+
max_metric_calls=budget,
|
|
395
|
+
reflection_lm=reflection,
|
|
396
|
+
seed=seed,
|
|
397
|
+
num_threads=DSPY_THREADS,
|
|
398
|
+
)
|
|
399
|
+
tuned = optimizer.compile(
|
|
400
|
+
program, trainset=dspy_examples(task.train), valset=dspy_examples(task.validation)
|
|
401
|
+
)
|
|
402
|
+
fitted, reanchor = fitted_weights(tuned, task)
|
|
403
|
+
answers, seconds = typed_answers(fitted, task)
|
|
404
|
+
return result("dspy-typed", seed, answers, seconds, reanchor=reanchor)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class Failsafe(dspy.Module):
|
|
408
|
+
"""A DSPy program whose failure on one example is an answer of None, scored zero, as DSPy's
|
|
409
|
+
GEPA means a failed example to be. Its integration instead crashes the whole optimization
|
|
410
|
+
when an example comes back missing, which ended runs over one dropped connection or one
|
|
411
|
+
error inside DSPy. Each failure is logged; GEPA scores copies of the program, so a count kept
|
|
412
|
+
here would miss theirs."""
|
|
413
|
+
|
|
414
|
+
def __init__(self, program: dspy.Module):
|
|
415
|
+
super().__init__()
|
|
416
|
+
self.program = program
|
|
417
|
+
self.failures = 0
|
|
418
|
+
|
|
419
|
+
def forward(self, text: str) -> dspy.Prediction:
|
|
420
|
+
try:
|
|
421
|
+
return self.program(text=text)
|
|
422
|
+
except Exception as error: # any failure: DSPy counts it at failure_score, not a crash
|
|
423
|
+
self.failures += 1
|
|
424
|
+
logging.getLogger(__name__).warning("DSPy example failed: %r", error)
|
|
425
|
+
return dspy.Prediction(label=None)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def dspy_baseline(
|
|
429
|
+
seed: int,
|
|
430
|
+
task: Task,
|
|
431
|
+
student: dspy.LM,
|
|
432
|
+
reflection: dspy.LM,
|
|
433
|
+
budget: int,
|
|
434
|
+
reasoning: bool = False,
|
|
435
|
+
) -> dict:
|
|
436
|
+
"""A standard DSPy classifier on a text-generating model, optimized with DSPy's own GEPA.
|
|
437
|
+
With `reasoning` it thinks step by step first, the stronger and costlier standard form."""
|
|
438
|
+
signature = dspy.make_signature(
|
|
439
|
+
{
|
|
440
|
+
"text": (str, dspy.InputField()),
|
|
441
|
+
"label": (Literal[tuple(task.labels)], dspy.OutputField()),
|
|
442
|
+
},
|
|
443
|
+
task.description,
|
|
444
|
+
)
|
|
445
|
+
program = Failsafe(dspy.ChainOfThought(signature) if reasoning else dspy.Predict(signature))
|
|
446
|
+
|
|
447
|
+
def metric(gold, predicted, trace=None, pred_name=None, pred_trace=None):
|
|
448
|
+
right = predicted.label == gold.label
|
|
449
|
+
return dspy.Prediction(
|
|
450
|
+
score=float(right),
|
|
451
|
+
feedback="Correct." if right else f"Expected {gold.label}, answered {predicted.label}.",
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
with dspy.context(lm=student, adapter=text_adapter()):
|
|
455
|
+
optimizer = dspy.GEPA(
|
|
456
|
+
metric=metric,
|
|
457
|
+
max_metric_calls=budget,
|
|
458
|
+
reflection_lm=reflection,
|
|
459
|
+
seed=seed,
|
|
460
|
+
num_threads=DSPY_THREADS,
|
|
461
|
+
)
|
|
462
|
+
tuned = optimizer.compile(
|
|
463
|
+
program, trainset=dspy_examples(task.train), valset=dspy_examples(task.validation)
|
|
464
|
+
)
|
|
465
|
+
answers, seconds = dspy_answers(tuned, task, student)
|
|
466
|
+
return result("dspy-cot" if reasoning else "dspy", seed, answers, seconds)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def mcnemar(first: list[bool], second: list[bool]) -> float:
|
|
470
|
+
"""Exact two-sided McNemar p-value: could two methods scored on the same examples differ
|
|
471
|
+
this much by chance? Only the examples exactly one of them got right count."""
|
|
472
|
+
only_first = sum(a and not b for a, b in zip(first, second, strict=True))
|
|
473
|
+
only_second = sum(b and not a for a, b in zip(first, second, strict=True))
|
|
474
|
+
split = only_first + only_second
|
|
475
|
+
tail = sum(math.comb(split, k) for k in range(min(only_first, only_second) + 1)) / 2**split
|
|
476
|
+
return min(1.0, 2 * tail)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def summarize(runs: list[dict]) -> dict[str, dict]:
|
|
480
|
+
"""Each method averaged over seeds, with the spread of its accuracy between seeds."""
|
|
481
|
+
methods: dict[str, list[dict]] = {}
|
|
482
|
+
for run in runs:
|
|
483
|
+
methods.setdefault(run["method"], []).append(run)
|
|
484
|
+
|
|
485
|
+
def averaged(done: list[dict], key: str) -> float | None:
|
|
486
|
+
values = [run.get(key) for run in done]
|
|
487
|
+
return None if any(value is None for value in values) else mean(values)
|
|
488
|
+
|
|
489
|
+
fields = (
|
|
490
|
+
"calls_per_example",
|
|
491
|
+
"tokens_per_example",
|
|
492
|
+
"output_tokens_per_example",
|
|
493
|
+
"seconds_per_example",
|
|
494
|
+
"invalid_rate",
|
|
495
|
+
*(f"accuracy_at_{round(k * 100)}" for k in COVERAGES),
|
|
496
|
+
)
|
|
497
|
+
return {
|
|
498
|
+
method: {
|
|
499
|
+
"seeds": len(done),
|
|
500
|
+
"accuracy": mean(run["accuracy"] for run in done),
|
|
501
|
+
"spread": stdev(run["accuracy"] for run in done) if len(done) > 1 else 0.0,
|
|
502
|
+
**{field: averaged(done, field) for field in fields},
|
|
503
|
+
}
|
|
504
|
+
for method, done in methods.items()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def against(runs: list[dict], method: str) -> dict[str, float]:
|
|
509
|
+
"""McNemar p-values of `method` against every other, over the seeds both finished, their
|
|
510
|
+
test examples pooled."""
|
|
511
|
+
by_seed = {(run["method"], run["seed"]): run["correct"] for run in runs}
|
|
512
|
+
found = {}
|
|
513
|
+
for other in sorted({run["method"] for run in runs} - {method}):
|
|
514
|
+
seeds = sorted(s for m, s in by_seed if m == method and (other, s) in by_seed)
|
|
515
|
+
if seeds:
|
|
516
|
+
mine = [c for s in seeds for c in by_seed[method, s]]
|
|
517
|
+
theirs = [c for s in seeds for c in by_seed[other, s]]
|
|
518
|
+
found[other] = mcnemar(mine, theirs)
|
|
519
|
+
return found
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def report(results: dict) -> str:
|
|
523
|
+
"""The results as a Markdown table, best accuracy first, and whether search's differences
|
|
524
|
+
from each other method could be chance."""
|
|
525
|
+
|
|
526
|
+
def shown(value: float | None, digits: str = ".3f") -> str:
|
|
527
|
+
return "n/a" if value is None else format(value, digits)
|
|
528
|
+
|
|
529
|
+
rows = sorted(summarize(results["runs"]).items(), key=lambda item: -item[1]["accuracy"])
|
|
530
|
+
lines = [
|
|
531
|
+
f"{results['dataset']}, {results['test']} test examples per seed",
|
|
532
|
+
"",
|
|
533
|
+
"| Method | Accuracy | At 80% | At 50% | Seeds | Judge or LM calls | Input tokens "
|
|
534
|
+
"| Output tokens | Seconds | Invalid |",
|
|
535
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
536
|
+
]
|
|
537
|
+
for method, row in rows:
|
|
538
|
+
lines.append(
|
|
539
|
+
f"| {method} | {row['accuracy']:.3f} ± {row['spread']:.3f} | "
|
|
540
|
+
f"{shown(row['accuracy_at_80'])} | {shown(row['accuracy_at_50'])} | {row['seeds']} | "
|
|
541
|
+
f"{shown(row['calls_per_example'], '.2f')} | {shown(row['tokens_per_example'], '.0f')} "
|
|
542
|
+
f"| {shown(row['output_tokens_per_example'], '.0f')} | "
|
|
543
|
+
f"{shown(row['seconds_per_example'], '.2f')} | {shown(row['invalid_rate'], '.1%')} |"
|
|
544
|
+
)
|
|
545
|
+
compared = against(results["runs"], "search")
|
|
546
|
+
if compared:
|
|
547
|
+
lines += ["", "Search against each method, exact McNemar p over pooled test examples:", ""]
|
|
548
|
+
lines += [f"- {other}: p = {p:.3g}" for other, p in compared.items()]
|
|
549
|
+
return "\n".join(lines)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@dataclass(frozen=True)
|
|
553
|
+
class Models:
|
|
554
|
+
"""What the model-backed methods run on. Unused by majority and tfidf."""
|
|
555
|
+
|
|
556
|
+
client: object = None
|
|
557
|
+
judge: str = "none"
|
|
558
|
+
proposer: object = None
|
|
559
|
+
student: dspy.LM | None = None
|
|
560
|
+
reflection: dspy.LM | None = None
|
|
561
|
+
budget: int = 0
|
|
562
|
+
call_cost: float = 0
|
|
563
|
+
judge_url: str = ""
|
|
564
|
+
timeout: float = 10
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def run(
|
|
568
|
+
dataset: Dataset,
|
|
569
|
+
seeds: list[int],
|
|
570
|
+
methods: list[str],
|
|
571
|
+
sizes: dict[str, int],
|
|
572
|
+
output: Path,
|
|
573
|
+
models: dict[str, str],
|
|
574
|
+
resources: Models | None = None,
|
|
575
|
+
deadline: float = math.inf,
|
|
576
|
+
real_rate: bool = False,
|
|
577
|
+
) -> list[dict]:
|
|
578
|
+
"""Every method on every seed, written to `output` after each one finishes. Seeds go in
|
|
579
|
+
order, each finishing every method before the next begins, so a run cut short by `deadline`
|
|
580
|
+
(seconds since the epoch) loses whole seeds rather than whole methods. With `real_rate`, each
|
|
581
|
+
task carries the dataset's own label rates as its prevalence, so search and scoring weigh
|
|
582
|
+
each example as the real traffic it stands for."""
|
|
583
|
+
resources = resources or Models()
|
|
584
|
+
results = {"dataset": dataset.name, **sizes, "real_rate": real_rate, "runs": []}
|
|
585
|
+
if output.exists():
|
|
586
|
+
results = json.loads(output.read_text())
|
|
587
|
+
if {key: results[key] for key in sizes} != sizes:
|
|
588
|
+
raise ValueError(f"{output} holds runs of other sizes; write to a new file")
|
|
589
|
+
if results.get("real_rate", False) != real_rate:
|
|
590
|
+
raise ValueError(f"{output} holds runs at another rate (real_rate={not real_rate})")
|
|
591
|
+
done = {(run["method"], run["seed"]) for run in results["runs"]}
|
|
592
|
+
for seed in seeds:
|
|
593
|
+
task = sample(dataset, sizes["train"], sizes["validation"], sizes["test"], seed)
|
|
594
|
+
if real_rate:
|
|
595
|
+
task = Task.model_validate(task.model_dump() | {"prevalence": dataset.prevalence()})
|
|
596
|
+
for method in methods:
|
|
597
|
+
if (method, seed) in done:
|
|
598
|
+
continue
|
|
599
|
+
if time.time() > deadline:
|
|
600
|
+
return results["runs"]
|
|
601
|
+
# Per run, since a resumed file may be finished with other models than started it.
|
|
602
|
+
result = measure(method, seed, task, dataset, resources) | {"models": models}
|
|
603
|
+
results["runs"].append(result)
|
|
604
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
605
|
+
output.write_text(json.dumps(results, indent=2) + "\n")
|
|
606
|
+
return results["runs"]
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def measure(method: str, seed: int, task: Task, dataset: Dataset, resources: Models) -> dict:
|
|
610
|
+
if method == "majority":
|
|
611
|
+
return majority(seed, task)
|
|
612
|
+
if method == "tfidf":
|
|
613
|
+
return tfidf(seed, task)
|
|
614
|
+
if method == "direct":
|
|
615
|
+
plan = direct_plan(task)
|
|
616
|
+
return record("direct", seed, task, plan, resources.client, resources.judge)
|
|
617
|
+
if method == "handwritten":
|
|
618
|
+
plan = calibrate.measure(
|
|
619
|
+
compile_rules(dataset.handwritten, task.criteria()),
|
|
620
|
+
task,
|
|
621
|
+
resources.client,
|
|
622
|
+
resources.judge,
|
|
623
|
+
)
|
|
624
|
+
return record("handwritten", seed, task, plan, resources.client, resources.judge)
|
|
625
|
+
if method == "search":
|
|
626
|
+
return search(
|
|
627
|
+
seed,
|
|
628
|
+
task,
|
|
629
|
+
resources.client,
|
|
630
|
+
resources.judge,
|
|
631
|
+
resources.proposer,
|
|
632
|
+
resources.budget,
|
|
633
|
+
resources.call_cost,
|
|
634
|
+
)
|
|
635
|
+
if method == "dspy-typed":
|
|
636
|
+
return dspy_typed(
|
|
637
|
+
seed,
|
|
638
|
+
task,
|
|
639
|
+
resources.judge_url,
|
|
640
|
+
resources.judge,
|
|
641
|
+
resources.reflection,
|
|
642
|
+
resources.budget,
|
|
643
|
+
resources.timeout,
|
|
644
|
+
)
|
|
645
|
+
return dspy_baseline(
|
|
646
|
+
seed,
|
|
647
|
+
task,
|
|
648
|
+
resources.student,
|
|
649
|
+
resources.reflection,
|
|
650
|
+
resources.budget,
|
|
651
|
+
reasoning=method == "dspy-cot",
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def elo_against(reference: float, score: float) -> float | None:
|
|
656
|
+
"""The rating that expects `score` against an opponent rated `reference`, by the logistic
|
|
657
|
+
Elo curve. A clean sweep either way has no finite answer."""
|
|
658
|
+
if not 0 < score < 1:
|
|
659
|
+
return None
|
|
660
|
+
return reference + 400 * math.log10(score / (1 - score))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def chess_reference(
|
|
664
|
+
task, graphs: list[Path], engine, games: int, rating: float, output: Path
|
|
665
|
+
) -> list[dict]:
|
|
666
|
+
"""Every graph against one engine over the same seeds, colors alternating by seed, written
|
|
667
|
+
to `output` after each graph. A graph already there is skipped."""
|
|
668
|
+
from rulesmith.chess import Games
|
|
669
|
+
from rulesmith.cli import read_plan
|
|
670
|
+
|
|
671
|
+
results = json.loads(output.read_text()) if output.exists() else {"runs": []}
|
|
672
|
+
done = {run["graph"] for run in results["runs"]}
|
|
673
|
+
cli = argparse.ArgumentParser()
|
|
674
|
+
for path in graphs:
|
|
675
|
+
if path.stem in done:
|
|
676
|
+
continue
|
|
677
|
+
program = DecisionProgram(read_plan(cli, path), None, "none")
|
|
678
|
+
scorer = Games(task, engine)
|
|
679
|
+
records = [scorer(program, seed).record for seed in range(games)]
|
|
680
|
+
points = [record["points"] for record in records]
|
|
681
|
+
score = mean(points)
|
|
682
|
+
results["runs"].append(
|
|
683
|
+
{
|
|
684
|
+
"graph": path.stem,
|
|
685
|
+
"games": games,
|
|
686
|
+
"won": points.count(1.0),
|
|
687
|
+
"drew": points.count(0.5),
|
|
688
|
+
"lost": points.count(0.0),
|
|
689
|
+
"score": score,
|
|
690
|
+
"material": mean(record["material"] for record in records),
|
|
691
|
+
# A graph that loses every game still differs in how long it holds out.
|
|
692
|
+
"plies": mean(record["plies"] for record in records),
|
|
693
|
+
"elo": elo_against(rating, score),
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
697
|
+
output.write_text(json.dumps(results, indent=2) + "\n")
|
|
698
|
+
return results["runs"]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def language_model(
|
|
702
|
+
model: str | None,
|
|
703
|
+
base_url: str | None,
|
|
704
|
+
options: str | None,
|
|
705
|
+
max_tokens: int,
|
|
706
|
+
timeout: float,
|
|
707
|
+
retries: int,
|
|
708
|
+
) -> dspy.LM | None:
|
|
709
|
+
"""A generative model, given its DSPy id. `options` is extra request JSON, such as switching
|
|
710
|
+
off a model's thinking so a one-word label does not cost a page of reasoning. A failed request
|
|
711
|
+
is retried: a local server under several runs' load drops connections, and DSPy's GEPA ends
|
|
712
|
+
the whole run on a single missing answer."""
|
|
713
|
+
if model is None:
|
|
714
|
+
return None
|
|
715
|
+
extra = {"extra_body": json.loads(options)} if options else {}
|
|
716
|
+
endpoint = {"api_base": base_url} if base_url else {}
|
|
717
|
+
return dspy.LM(
|
|
718
|
+
model,
|
|
719
|
+
cache=False,
|
|
720
|
+
num_retries=retries,
|
|
721
|
+
max_tokens=max_tokens,
|
|
722
|
+
timeout=timeout,
|
|
723
|
+
**endpoint,
|
|
724
|
+
**extra,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def judge_client(base_url: str, timeout: float):
|
|
729
|
+
"""The local judge, waiting as long as a generative request may: every run of a benchmark
|
|
730
|
+
queues on the same judge, and the SDK's ten seconds ended runs that were only waiting."""
|
|
731
|
+
from typesafe_sdk import TypeSafeClient
|
|
732
|
+
|
|
733
|
+
return TypeSafeClient(base_url=base_url, api_key="local-no-auth", timeout=timeout)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def main() -> None:
|
|
737
|
+
root = argparse.ArgumentParser(description=__doc__)
|
|
738
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
739
|
+
bench = commands.add_parser("run", help="Run methods over seeds, resuming a results file")
|
|
740
|
+
bench.add_argument("dataset", choices=sorted(DATASETS))
|
|
741
|
+
bench.add_argument("--seeds", type=int, nargs="+", default=[0, 1, 2])
|
|
742
|
+
bench.add_argument("--methods", nargs="+", default=list(METHODS), choices=METHODS)
|
|
743
|
+
for split, default in (("train", 50), ("validation", 50), ("test", 300)):
|
|
744
|
+
bench.add_argument(f"--{split}", type=int, default=default)
|
|
745
|
+
bench.add_argument("--budget", type=int, default=150, help="Metric calls per search")
|
|
746
|
+
bench.add_argument(
|
|
747
|
+
"--call-cost",
|
|
748
|
+
type=float,
|
|
749
|
+
default=0.02,
|
|
750
|
+
help="Score search takes off per judge question: a whole call is worth two points",
|
|
751
|
+
)
|
|
752
|
+
bench.add_argument("--judge-base-url", default="http://127.0.0.1:8010")
|
|
753
|
+
bench.add_argument("--judge-model", default="default")
|
|
754
|
+
for role in ("proposer", "student", "reflection"):
|
|
755
|
+
bench.add_argument(f"--{role}-model")
|
|
756
|
+
bench.add_argument(f"--{role}-base-url")
|
|
757
|
+
bench.add_argument(f"--{role}-options", help="Extra request JSON for this model")
|
|
758
|
+
bench.add_argument(
|
|
759
|
+
"--max-tokens",
|
|
760
|
+
type=int,
|
|
761
|
+
default=16384,
|
|
762
|
+
help="Per reply; a proposal restating 77 labels with its reasoning needs room",
|
|
763
|
+
)
|
|
764
|
+
bench.add_argument(
|
|
765
|
+
"--student-max-tokens",
|
|
766
|
+
type=int,
|
|
767
|
+
default=2048,
|
|
768
|
+
help="Per DSPy reply: a label with its reasoning fits, and a local server reserves "
|
|
769
|
+
"memory for this many tokens in every reply it is generating at once",
|
|
770
|
+
)
|
|
771
|
+
bench.add_argument(
|
|
772
|
+
"--note", help="How the judge was served, e.g. RULING_ROTATIONS=1, kept with the results"
|
|
773
|
+
)
|
|
774
|
+
bench.add_argument(
|
|
775
|
+
"--timeout",
|
|
776
|
+
type=float,
|
|
777
|
+
default=3600,
|
|
778
|
+
help="Seconds a generative request may take; runs sharing one local model queue behind "
|
|
779
|
+
"each other",
|
|
780
|
+
)
|
|
781
|
+
bench.add_argument(
|
|
782
|
+
"--hours",
|
|
783
|
+
type=float,
|
|
784
|
+
help="Start nothing new after this many hours; finished results are kept either way",
|
|
785
|
+
)
|
|
786
|
+
bench.add_argument(
|
|
787
|
+
"--retries", type=int, default=10, help="Retries for a failed generative request"
|
|
788
|
+
)
|
|
789
|
+
bench.add_argument(
|
|
790
|
+
"--real-rate",
|
|
791
|
+
action="store_true",
|
|
792
|
+
help="Weigh each example as the real traffic it stands for, at the dataset's own label "
|
|
793
|
+
"rates, in search and in scoring; graph results report precision and recall there",
|
|
794
|
+
)
|
|
795
|
+
bench.add_argument("--output", type=Path, required=True)
|
|
796
|
+
games = commands.add_parser("chess", help="Play chess graphs against a fixed-strength engine")
|
|
797
|
+
games.add_argument("task", type=Path, help="A chess task; its move limit and openings apply")
|
|
798
|
+
games.add_argument("graphs", type=Path, nargs="+")
|
|
799
|
+
games.add_argument("--engine", required=True, help="Path to a UCI engine such as stockfish")
|
|
800
|
+
games.add_argument("--skill", type=int, default=0, help="Stockfish Skill Level, 0 to 20")
|
|
801
|
+
games.add_argument(
|
|
802
|
+
"--rating", type=float, default=1320, help="The engine's rating at that skill"
|
|
803
|
+
)
|
|
804
|
+
games.add_argument("--seconds", type=float, default=0.05, help="Engine time per move")
|
|
805
|
+
games.add_argument("--games", type=int, default=40)
|
|
806
|
+
games.add_argument("--output", type=Path, required=True)
|
|
807
|
+
shown = commands.add_parser("report", help="Print a results file as a Markdown table")
|
|
808
|
+
shown.add_argument("results", type=Path)
|
|
809
|
+
args = root.parse_args()
|
|
810
|
+
if args.command == "report":
|
|
811
|
+
print(report(json.loads(args.results.read_text())))
|
|
812
|
+
return
|
|
813
|
+
if args.command == "chess":
|
|
814
|
+
from rulesmith.chess import ChessTask, Engine
|
|
815
|
+
|
|
816
|
+
task = ChessTask.model_validate_json(args.task.read_text())
|
|
817
|
+
with Engine(args.engine, args.skill, args.seconds) as engine:
|
|
818
|
+
runs = chess_reference(task, args.graphs, engine, args.games, args.rating, args.output)
|
|
819
|
+
print(json.dumps(runs, indent=2))
|
|
820
|
+
return
|
|
821
|
+
lms = {
|
|
822
|
+
role: language_model(
|
|
823
|
+
getattr(args, f"{role}_model"),
|
|
824
|
+
getattr(args, f"{role}_base_url"),
|
|
825
|
+
getattr(args, f"{role}_options"),
|
|
826
|
+
args.student_max_tokens if role == "student" else args.max_tokens,
|
|
827
|
+
args.timeout,
|
|
828
|
+
args.retries,
|
|
829
|
+
)
|
|
830
|
+
for role in ("proposer", "student", "reflection")
|
|
831
|
+
}
|
|
832
|
+
needed = {"search": "proposer", "dspy": "student", "dspy-cot": "student"}
|
|
833
|
+
for method, role in needed.items():
|
|
834
|
+
if method in args.methods and lms[role] is None:
|
|
835
|
+
root.error(f"{method} needs --{role}-model")
|
|
836
|
+
if "dspy-typed" in args.methods and not (lms["reflection"] or lms["proposer"]):
|
|
837
|
+
root.error("dspy-typed needs --reflection-model or --proposer-model")
|
|
838
|
+
dataset = DATASETS[args.dataset]
|
|
839
|
+
if "handwritten" in args.methods and dataset.handwritten is None:
|
|
840
|
+
root.error(f"{dataset.name} has no hand-written rules to run")
|
|
841
|
+
sizes = {split: getattr(args, split) for split in ("train", "validation", "test")}
|
|
842
|
+
models = {"judge": args.judge_model, "note": args.note or "", "call_cost": args.call_cost} | {
|
|
843
|
+
role: getattr(args, f"{role}_model") for role in lms if getattr(args, f"{role}_model")
|
|
844
|
+
}
|
|
845
|
+
with judge_client(args.judge_base_url, args.timeout) as client:
|
|
846
|
+
resources = Models(
|
|
847
|
+
client=client,
|
|
848
|
+
judge=args.judge_model,
|
|
849
|
+
proposer=GraphProposer(lms["proposer"]) if lms["proposer"] else None,
|
|
850
|
+
student=lms["student"],
|
|
851
|
+
reflection=lms["reflection"] or lms["proposer"],
|
|
852
|
+
budget=args.budget,
|
|
853
|
+
call_cost=args.call_cost,
|
|
854
|
+
judge_url=args.judge_base_url,
|
|
855
|
+
timeout=args.timeout,
|
|
856
|
+
)
|
|
857
|
+
deadline = time.time() + args.hours * 3600 if args.hours else math.inf
|
|
858
|
+
run(
|
|
859
|
+
dataset,
|
|
860
|
+
args.seeds,
|
|
861
|
+
args.methods,
|
|
862
|
+
sizes,
|
|
863
|
+
args.output,
|
|
864
|
+
models,
|
|
865
|
+
resources,
|
|
866
|
+
deadline,
|
|
867
|
+
real_rate=args.real_rate,
|
|
868
|
+
)
|
|
869
|
+
print(report(json.loads(args.output.read_text())))
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def cached(url: str, name: str, sha1: str | None = None) -> Path:
|
|
873
|
+
"""A downloaded file, fetched once. Datasets stay out of the repo; their licenses allow
|
|
874
|
+
use, and fetching from the source keeps attribution where the publisher put it. With a
|
|
875
|
+
published checksum, a file that differs is refused rather than measured."""
|
|
876
|
+
path = Path(os.environ.get("RULESMITH_CACHE", Path.home() / ".cache" / "rulesmith")) / name
|
|
877
|
+
if not path.exists():
|
|
878
|
+
with urllib.request.urlopen(url, timeout=60) as response:
|
|
879
|
+
data = response.read()
|
|
880
|
+
if sha1 is not None and hashlib.sha1(data).hexdigest() != sha1:
|
|
881
|
+
raise ValueError(f"{url} does not match its published checksum {sha1}")
|
|
882
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
883
|
+
path.write_bytes(data)
|
|
884
|
+
return path
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def banking77() -> Rows:
|
|
888
|
+
"""PolyAI's Banking77 (CC BY 4.0): 13,083 banking queries across 77 intents."""
|
|
889
|
+
base = "https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets/master/banking_data"
|
|
890
|
+
rows = {}
|
|
891
|
+
for split in ("train", "test"):
|
|
892
|
+
with cached(f"{base}/{split}.csv", f"banking77/{split}.csv").open(newline="") as file:
|
|
893
|
+
rows[split] = [(row["text"], row["category"]) for row in csv.DictReader(file)]
|
|
894
|
+
return rows
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def hub(repo: str, revision: str, path: str) -> Path:
|
|
898
|
+
"""A file from a Hugging Face dataset, pinned to the revision these results came from."""
|
|
899
|
+
from huggingface_hub import hf_hub_download
|
|
900
|
+
|
|
901
|
+
return Path(hf_hub_download(repo, path, repo_type="dataset", revision=revision))
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def clothing() -> Rows:
|
|
905
|
+
"""Women's E-Commerce Clothing Reviews (CC0): whether a reviewer recommends the item, from
|
|
906
|
+
their rating, age, the product's department and the review itself. The rating settles most
|
|
907
|
+
cases; a three-star review needs its text read."""
|
|
908
|
+
repo, revision = "saattrupdan/womens-clothing-ecommerce-reviews", CLOTHING_REVISION
|
|
909
|
+
files = {
|
|
910
|
+
"train": "data/train-00000-of-00001-44ddebcb331919e6.parquet",
|
|
911
|
+
"test": "data/test-00000-of-00001-90b9c3d0edf6fc3c.parquet",
|
|
912
|
+
}
|
|
913
|
+
rows = {}
|
|
914
|
+
for split, path in files.items():
|
|
915
|
+
table = parquet.read_table(hub(repo, revision, path)).to_pylist()
|
|
916
|
+
rows[split] = [
|
|
917
|
+
(
|
|
918
|
+
{
|
|
919
|
+
"rating": row["rating"],
|
|
920
|
+
"age": row["age"],
|
|
921
|
+
"helpful_votes": row["positive_feedback_count"],
|
|
922
|
+
"division": row["division_name"],
|
|
923
|
+
"department": row["department_name"],
|
|
924
|
+
"category": row["class_name"],
|
|
925
|
+
"review": row["review_text"],
|
|
926
|
+
},
|
|
927
|
+
"recommended" if row["recommended_ind"] else "not_recommended",
|
|
928
|
+
)
|
|
929
|
+
for row in table
|
|
930
|
+
]
|
|
931
|
+
return rows
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def trec() -> Rows:
|
|
935
|
+
"""TREC question classification (Li and Roth), coarse: the kind of answer a question asks
|
|
936
|
+
for. A small, fast sanity check; its license is not stated, so it is for research use."""
|
|
937
|
+
rows = {}
|
|
938
|
+
for split in ("train", "test"):
|
|
939
|
+
with hub("SetFit/TREC-QC", TREC_REVISION, f"{split}.jsonl").open() as file:
|
|
940
|
+
lines = [json.loads(line) for line in file]
|
|
941
|
+
rows[split] = [(line["text"], line["label_coarse_text"]) for line in lines]
|
|
942
|
+
return rows
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def jobs() -> Rows:
|
|
946
|
+
"""Real or Fake Job Postings (CC0): whether a posting is a scam, from its structured fields
|
|
947
|
+
(company logo, screening questions, salary, employment type) and its text. The source has one
|
|
948
|
+
file, so every fifth posting is held out as the test pool."""
|
|
949
|
+
path = hub(JOBS_REPO, JOBS_REVISION, "fake_job_postings.csv")
|
|
950
|
+
with path.open(newline="") as file:
|
|
951
|
+
postings = list(csv.DictReader(file))
|
|
952
|
+
rows: Rows = {"train": [], "test": []}
|
|
953
|
+
for index, row in enumerate(postings):
|
|
954
|
+
state = {
|
|
955
|
+
"title": row["title"],
|
|
956
|
+
"location": row["location"],
|
|
957
|
+
"salary_range": row["salary_range"],
|
|
958
|
+
"employment_type": row["employment_type"],
|
|
959
|
+
"required_experience": row["required_experience"],
|
|
960
|
+
"required_education": row["required_education"],
|
|
961
|
+
"industry": row["industry"],
|
|
962
|
+
"function": row["function"],
|
|
963
|
+
"telecommuting": int(row["telecommuting"]),
|
|
964
|
+
"has_company_logo": int(row["has_company_logo"]),
|
|
965
|
+
"has_questions": int(row["has_questions"]),
|
|
966
|
+
"company_profile": row["company_profile"],
|
|
967
|
+
"description": row["description"],
|
|
968
|
+
}
|
|
969
|
+
label = "fraudulent" if row["fraudulent"] == "1" else "genuine"
|
|
970
|
+
rows["test" if index % 5 == 0 else "train"].append((state, label))
|
|
971
|
+
return rows
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def news() -> Rows:
|
|
975
|
+
"""Which channel a Mashable article ran in, from its title and sixteen numbers about its text
|
|
976
|
+
(length, links, images, sentiment): UCI's Online News Popularity (CC BY 4.0) as the
|
|
977
|
+
multimodal AutoML benchmark of Shi et al. (2021) prepared it. Chosen before any result, and
|
|
978
|
+
used for nothing while rulesmith was built, as a check on the other datasets."""
|
|
979
|
+
base = "https://automl-mm-bench.s3.amazonaws.com/news_channel"
|
|
980
|
+
rows = {}
|
|
981
|
+
for split, digest in NEWS_SHA1.items():
|
|
982
|
+
path = cached(f"{base}/{split}.csv", f"news_channel/{split}.csv", digest)
|
|
983
|
+
with path.open(newline="") as file:
|
|
984
|
+
table = [
|
|
985
|
+
{key.strip(): value.strip() for key, value in row.items()}
|
|
986
|
+
for row in csv.DictReader(file)
|
|
987
|
+
]
|
|
988
|
+
rows[split] = [
|
|
989
|
+
(
|
|
990
|
+
{"title": row.pop("article_title")}
|
|
991
|
+
| {key: float(value) for key, value in row.items() if key != "channel"},
|
|
992
|
+
CHANNELS[row["channel"].removeprefix("data_channel_is_")],
|
|
993
|
+
)
|
|
994
|
+
for row in table
|
|
995
|
+
]
|
|
996
|
+
return rows
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def spelled(label: str) -> str:
|
|
1000
|
+
"""A label described by its own words, the most a direct question gets for free."""
|
|
1001
|
+
return label.replace("_", " ")
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
CLOTHING_REVISION = "4e788183125a6143d8bdba24e58f1efda2e4b48f"
|
|
1005
|
+
TREC_REVISION = "0a34640b3cac3affef6f0abdfb81e3d0ecffcf92"
|
|
1006
|
+
JOBS_REPO = "victor/real-or-fake-fake-jobposting-prediction"
|
|
1007
|
+
JOBS_REVISION = "a751f55136a26da3c36e26aa207a9e187ca24b45"
|
|
1008
|
+
SIGNIFICANCE = 0.1
|
|
1009
|
+
# DSPy evaluates eight examples at once by default; five datasets doing that at once swamp one
|
|
1010
|
+
# local model server, and a connection it drops ends the run inside DSPy's GEPA.
|
|
1011
|
+
DSPY_THREADS = 4
|
|
1012
|
+
NEWS_SHA1 = {
|
|
1013
|
+
"train": "ab226210b6a878b449d01f33a195014c65c22311",
|
|
1014
|
+
"test": "a71516784ce6e168bd9933e9ec50080f65cb05fd",
|
|
1015
|
+
}
|
|
1016
|
+
CHANNELS = {
|
|
1017
|
+
"bus": "business",
|
|
1018
|
+
"entertainment": "entertainment",
|
|
1019
|
+
"lifestyle": "lifestyle",
|
|
1020
|
+
"socmed": "social_media",
|
|
1021
|
+
"tech": "tech",
|
|
1022
|
+
"world": "world",
|
|
1023
|
+
}
|
|
1024
|
+
METHODS = ("majority", "tfidf", "direct", "handwritten", "search", "dspy", "dspy-cot", "dspy-typed")
|
|
1025
|
+
DATASETS: dict[str, Dataset] = {
|
|
1026
|
+
"banking77": Dataset(
|
|
1027
|
+
"banking77",
|
|
1028
|
+
"Which intent does this online banking customer's message express?",
|
|
1029
|
+
banking77,
|
|
1030
|
+
spelled,
|
|
1031
|
+
),
|
|
1032
|
+
"clothing": Dataset(
|
|
1033
|
+
"clothing",
|
|
1034
|
+
"Would this shopper recommend the clothing item they reviewed?",
|
|
1035
|
+
clothing,
|
|
1036
|
+
spelled,
|
|
1037
|
+
handwritten="\n".join(
|
|
1038
|
+
[
|
|
1039
|
+
"read rating in [1, 5]",
|
|
1040
|
+
"when rating >= 4: recommended",
|
|
1041
|
+
"when rating < 3: not_recommended",
|
|
1042
|
+
'always: ask "Would this shopper recommend the clothing item they reviewed?" '
|
|
1043
|
+
'[recommended: "They would recommend it."] '
|
|
1044
|
+
'[not_recommended: "They would not recommend it."]',
|
|
1045
|
+
]
|
|
1046
|
+
),
|
|
1047
|
+
),
|
|
1048
|
+
"jobs": Dataset(
|
|
1049
|
+
"jobs",
|
|
1050
|
+
"Is this job posting a scam?",
|
|
1051
|
+
jobs,
|
|
1052
|
+
spelled,
|
|
1053
|
+
balanced=True,
|
|
1054
|
+
handwritten="\n".join(
|
|
1055
|
+
[
|
|
1056
|
+
"read has_company_logo in [0, 1]",
|
|
1057
|
+
"when has_company_logo >= 1: genuine",
|
|
1058
|
+
'always: ask "Is this job posting a scam?" '
|
|
1059
|
+
'[fraudulent: "It is a scam."] [genuine: "It is a real job."]',
|
|
1060
|
+
]
|
|
1061
|
+
),
|
|
1062
|
+
),
|
|
1063
|
+
"news": Dataset("news", "Which channel did this article run in?", news, spelled),
|
|
1064
|
+
"trec": Dataset("trec", "What kind of answer does this question ask for?", trec, spelled),
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if __name__ == "__main__":
|
|
1068
|
+
main()
|