strands-decider 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.
- strands_decider/__init__.py +3 -0
- strands_decider/cli.py +390 -0
- strands_decider/data/__init__.py +12 -0
- strands_decider/data/adequacy.py +115 -0
- strands_decider/data/catchall.py +114 -0
- strands_decider/data/collate.py +248 -0
- strands_decider/data/distill.py +68 -0
- strands_decider/data/format.py +178 -0
- strands_decider/data/generated.py +190 -0
- strands_decider/data/multistep.py +281 -0
- strands_decider/data/policy.py +226 -0
- strands_decider/data/question_transforms.py +217 -0
- strands_decider/data/recipes.py +1307 -0
- strands_decider/data/replay.py +85 -0
- strands_decider/data/sampling.py +104 -0
- strands_decider/data/shards.py +71 -0
- strands_decider/data/synth.py +476 -0
- strands_decider/data/synth_pairs.py +572 -0
- strands_decider/data/teacher.py +263 -0
- strands_decider/distributed.py +298 -0
- strands_decider/evaluate.py +462 -0
- strands_decider/hf_export.py +844 -0
- strands_decider/infer.py +467 -0
- strands_decider/modeling.py +584 -0
- strands_decider/mps_kernels.py +192 -0
- strands_decider/prompting.py +185 -0
- strands_decider/schema.py +196 -0
- strands_decider/server.py +107 -0
- strands_decider/train.py +597 -0
- strands_decider-0.1.0.dist-info/METADATA +314 -0
- strands_decider-0.1.0.dist-info/RECORD +35 -0
- strands_decider-0.1.0.dist-info/WHEEL +5 -0
- strands_decider-0.1.0.dist-info/entry_points.txt +2 -0
- strands_decider-0.1.0.dist-info/licenses/LICENSE +202 -0
- strands_decider-0.1.0.dist-info/top_level.txt +1 -0
strands_decider/cli.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""The strands-decider CLI: build data, train, calibrate, evaluate, serve, ask.
|
|
2
|
+
|
|
3
|
+
Only `ask` and `serve` show in `--help`. The training and evaluation commands stay
|
|
4
|
+
registered but hidden: `strands-decider train --help` works, and training/recipe.sh
|
|
5
|
+
calls them through `python -m strands_decider.cli`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from itertools import islice
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from .data import recipes as _recipes
|
|
21
|
+
from .data.format import Example, read_jsonl, write_jsonl
|
|
22
|
+
from .prompting import build_prompt
|
|
23
|
+
from .schema import ChoiceQuestion, NoulQuestion, Question, ScoreQuestion
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="strands-decider",
|
|
27
|
+
help="A System One model: typed, calibrated answers instead of text.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
data_app = typer.Typer(help="Build and inspect training corpora.", no_args_is_help=True)
|
|
32
|
+
app.add_typer(data_app, name="data", hidden=True)
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------- data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@data_app.command("recipes")
|
|
41
|
+
def data_recipes() -> None:
|
|
42
|
+
"""List the datasets that can be converted into training examples."""
|
|
43
|
+
table = Table(title="Available recipes")
|
|
44
|
+
for col in ("name", "dataset", "config", "split", "max examples"):
|
|
45
|
+
table.add_column(col)
|
|
46
|
+
for name in sorted(_recipes.SPECS):
|
|
47
|
+
s = _recipes.SPECS[name]
|
|
48
|
+
table.add_row(name, s.path, s.config or "-", s.split, str(s.max_examples))
|
|
49
|
+
console.print(table)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@data_app.command("build")
|
|
53
|
+
def data_build(
|
|
54
|
+
out: str = typer.Option("data/train.jsonl", help="Output JSONL (.gz supported)."),
|
|
55
|
+
recipes: list[str] | None = typer.Option(
|
|
56
|
+
None, "--recipe", "-r",
|
|
57
|
+
help="Recipe name; repeat. Default: all. If a recipe fails, the build writes nothing.",
|
|
58
|
+
),
|
|
59
|
+
max_options: int = typer.Option(16, help="Cap options per example (large label sets)."),
|
|
60
|
+
max_chars: int = typer.Option(2000, help="Truncate each state to this many characters."),
|
|
61
|
+
max_examples: int | None = typer.Option(None, help="Per-recipe example cap."),
|
|
62
|
+
seed: int = typer.Option(0),
|
|
63
|
+
holdout: list[str] | None = typer.Option(
|
|
64
|
+
None, "--holdout", help="Task to route to a separate held-out file; repeat."
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Download datasets and convert them to (state, question, answer) examples.
|
|
68
|
+
|
|
69
|
+
Tasks named with --holdout are written to a sibling *.holdout.jsonl instead, so
|
|
70
|
+
you can measure generalisation to label sets the model never trained on.
|
|
71
|
+
"""
|
|
72
|
+
names = list(recipes) if recipes else _recipes.available_recipes()
|
|
73
|
+
hold = set(holdout or [])
|
|
74
|
+
|
|
75
|
+
kept: list[Example] = []
|
|
76
|
+
held: list[Example] = []
|
|
77
|
+
for name in names:
|
|
78
|
+
console.print(f"[cyan]building[/] {name} ...")
|
|
79
|
+
try:
|
|
80
|
+
examples = _recipes.build_recipe(
|
|
81
|
+
name,
|
|
82
|
+
max_options=max_options,
|
|
83
|
+
max_chars=max_chars,
|
|
84
|
+
seed=seed,
|
|
85
|
+
max_examples=max_examples,
|
|
86
|
+
)
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
# Stop here. A missing recipe removes rows and moves every later row to a new
|
|
89
|
+
# position. The frozen teacher and replay targets attach to rows by position.
|
|
90
|
+
hint = (' Install the "train" extra: pip install -e ".[train]".'
|
|
91
|
+
if isinstance(exc, ModuleNotFoundError) and exc.name == "datasets" else "")
|
|
92
|
+
raise SystemExit(f"recipe {name} failed, no corpus written: {exc!r}.{hint}") from exc
|
|
93
|
+
(held if name in hold else kept).extend(examples)
|
|
94
|
+
console.print(f" [green]{len(examples):,}[/] examples")
|
|
95
|
+
|
|
96
|
+
if not kept:
|
|
97
|
+
raise typer.Exit(code=1)
|
|
98
|
+
|
|
99
|
+
n = write_jsonl(out, kept)
|
|
100
|
+
console.print(f"[green]wrote[/] {n:,} examples -> {out}")
|
|
101
|
+
if held:
|
|
102
|
+
hpath = out.replace(".jsonl", ".holdout.jsonl")
|
|
103
|
+
console.print(f"[green]wrote[/] {write_jsonl(hpath, held):,} held-out -> {hpath}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@data_app.command("stats")
|
|
107
|
+
def data_stats(path: str = typer.Argument(..., help="JSONL corpus to summarise.")) -> None:
|
|
108
|
+
"""Show composition of a corpus: tasks, kinds, option-count distribution."""
|
|
109
|
+
tasks: Counter = Counter()
|
|
110
|
+
kinds: Counter = Counter()
|
|
111
|
+
nopts: Counter = Counter()
|
|
112
|
+
total = 0
|
|
113
|
+
for ex in read_jsonl(path):
|
|
114
|
+
tasks[ex.task] += 1
|
|
115
|
+
kinds[ex.kind] += 1
|
|
116
|
+
nopts[ex.n_options] += 1
|
|
117
|
+
total += 1
|
|
118
|
+
|
|
119
|
+
console.print(f"[bold]{total:,}[/] examples in {path}")
|
|
120
|
+
t = Table(title="By task")
|
|
121
|
+
t.add_column("task")
|
|
122
|
+
t.add_column("n", justify="right")
|
|
123
|
+
t.add_column("share", justify="right")
|
|
124
|
+
for name, c in tasks.most_common():
|
|
125
|
+
t.add_row(name, f"{c:,}", f"{100 * c / total:.1f}%")
|
|
126
|
+
console.print(t)
|
|
127
|
+
|
|
128
|
+
k = Table(title="By kind / option count")
|
|
129
|
+
k.add_column("kind")
|
|
130
|
+
k.add_column("n", justify="right")
|
|
131
|
+
for name, c in kinds.most_common():
|
|
132
|
+
k.add_row(name, f"{c:,}")
|
|
133
|
+
console.print(k)
|
|
134
|
+
console.print("option counts: " + ", ".join(f"{n}:{c:,}" for n, c in sorted(nopts.items())))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@data_app.command("peek")
|
|
138
|
+
def data_peek(
|
|
139
|
+
path: str = typer.Argument(...),
|
|
140
|
+
n: int = typer.Option(3, help="How many rendered prompts to print."),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Render a few examples exactly as the model will see them."""
|
|
143
|
+
for ex in islice(read_jsonl(path), n):
|
|
144
|
+
prompt, rq = build_prompt(ex.state, ex.to_question())
|
|
145
|
+
console.rule(f"{ex.task} / {ex.kind} / gold slot {ex.label}")
|
|
146
|
+
console.print(prompt)
|
|
147
|
+
console.print(f"[dim]slots: {rq.slot_labels}[/]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------- train
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command("train", hidden=True)
|
|
154
|
+
def train_cmd(
|
|
155
|
+
config: str | None = typer.Option(None, "--config", "-c", help="YAML training config."),
|
|
156
|
+
train_file: list[str] | None = typer.Option(None, "--train-file", help="Override data."),
|
|
157
|
+
base_model: str | None = typer.Option(None, help="Override base model id."),
|
|
158
|
+
output_dir: str | None = typer.Option(None, help="Override checkpoint directory."),
|
|
159
|
+
epochs: int | None = typer.Option(None),
|
|
160
|
+
micro_batch_size: int | None = typer.Option(None),
|
|
161
|
+
max_steps: int | None = typer.Option(None, help="Hard step cap (smoke tests)."),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Train the slot heads and a LoRA adapter on the torso."""
|
|
164
|
+
from .train import TrainConfig, train
|
|
165
|
+
|
|
166
|
+
cfg = TrainConfig.from_yaml(config) if config else TrainConfig()
|
|
167
|
+
if train_file:
|
|
168
|
+
cfg.train_files = list(train_file)
|
|
169
|
+
for key, val in (
|
|
170
|
+
("base_model", base_model),
|
|
171
|
+
("output_dir", output_dir),
|
|
172
|
+
("epochs", epochs),
|
|
173
|
+
("micro_batch_size", micro_batch_size),
|
|
174
|
+
("max_steps", max_steps),
|
|
175
|
+
):
|
|
176
|
+
if val is not None:
|
|
177
|
+
setattr(cfg, key, val)
|
|
178
|
+
if not cfg.train_files:
|
|
179
|
+
raise typer.BadParameter("no training files; pass --train-file or set train_files")
|
|
180
|
+
train(cfg)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("calibrate", hidden=True)
|
|
184
|
+
def calibrate_cmd(
|
|
185
|
+
checkpoint: str = typer.Argument(...),
|
|
186
|
+
data: str = typer.Option(..., "--data", help="Held-out JSONL for fitting temperature."),
|
|
187
|
+
limit: int = typer.Option(4000, help="Cap examples used."),
|
|
188
|
+
batch_size: int = typer.Option(16),
|
|
189
|
+
split: str = typer.Option(
|
|
190
|
+
"calib", help="Half of --data to use: calib|test|all. Disjoint from `eval --split test`."
|
|
191
|
+
),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Fit a temperature on held-out data and write it into the checkpoint.
|
|
194
|
+
|
|
195
|
+
Run this before serving. Without it, `confidence` is whatever the raw head
|
|
196
|
+
happened to produce, and the documented thresholds will not hold.
|
|
197
|
+
|
|
198
|
+
Defaults to the `calib` half so that `strands-decider eval --split test` scores rows the
|
|
199
|
+
temperature was never fitted on.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
from .evaluate import calibrate_checkpoint, partition_examples, sample_examples
|
|
203
|
+
|
|
204
|
+
examples = sample_examples(partition_examples(list(read_jsonl(data)), split), limit)
|
|
205
|
+
result = calibrate_checkpoint(checkpoint, examples, batch_size=batch_size)
|
|
206
|
+
console.print(f"[green]global temperature = {result['temperature']:.4f}[/]")
|
|
207
|
+
by_kind = result.get("temperature_by_kind") or {}
|
|
208
|
+
if by_kind:
|
|
209
|
+
console.print(
|
|
210
|
+
"[green]per-primitive[/] "
|
|
211
|
+
+ ", ".join(f"{k}={v:.4f}" for k, v in sorted(by_kind.items()))
|
|
212
|
+
+ " (written to checkpoint)"
|
|
213
|
+
)
|
|
214
|
+
console.print("before: ", result["before"])
|
|
215
|
+
if "after_global" in result:
|
|
216
|
+
console.print("after global:", result["after_global"])
|
|
217
|
+
console.print("after: ", result["after"])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command("eval", hidden=True)
|
|
221
|
+
def eval_cmd(
|
|
222
|
+
checkpoint: str = typer.Argument(...),
|
|
223
|
+
data: str = typer.Option(..., "--data"),
|
|
224
|
+
limit: int = typer.Option(4000),
|
|
225
|
+
batch_size: int = typer.Option(16),
|
|
226
|
+
out: str | None = typer.Option(None, help="Write the full report as JSON."),
|
|
227
|
+
raw: bool = typer.Option(False, "--raw", help="Ignore the fitted temperature."),
|
|
228
|
+
split: str = typer.Option(
|
|
229
|
+
"test", help="Half of --data to score: test|calib|all. Disjoint from `calibrate`."
|
|
230
|
+
),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Accuracy, ECE, NLL and score MAE, broken down by task and confidence band.
|
|
233
|
+
|
|
234
|
+
Defaults to the `test` half, which `strands-decider calibrate` does not fit on.
|
|
235
|
+
"""
|
|
236
|
+
from .evaluate import evaluate_checkpoint, partition_examples, sample_examples
|
|
237
|
+
|
|
238
|
+
examples = sample_examples(partition_examples(list(read_jsonl(data)), split), limit)
|
|
239
|
+
report = evaluate_checkpoint(
|
|
240
|
+
checkpoint, examples, batch_size=batch_size, apply_temperature=not raw
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
overall = report["overall"]
|
|
244
|
+
console.print(
|
|
245
|
+
f"[bold]overall[/] n={overall['n']:,} acc={overall['accuracy']:.3f} "
|
|
246
|
+
f"ece={overall['ece']:.3f} nll={overall['nll']:.3f} T={_fmt_temperature(report['temperature'])}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
for section in ("by_kind", "by_task", "by_confidence_band"):
|
|
250
|
+
rows = {k: v for k, v in report[section].items() if v}
|
|
251
|
+
if not rows:
|
|
252
|
+
continue
|
|
253
|
+
t = Table(title=section)
|
|
254
|
+
for col in ("group", "n", "accuracy", "ece", "mean conf"):
|
|
255
|
+
t.add_column(col, justify="right" if col != "group" else "left")
|
|
256
|
+
for name, m in rows.items():
|
|
257
|
+
t.add_row(
|
|
258
|
+
name, f"{m['n']:,}", f"{m['accuracy']:.3f}",
|
|
259
|
+
f"{m['ece']:.3f}", f"{m['mean_confidence']:.3f}",
|
|
260
|
+
)
|
|
261
|
+
console.print(t)
|
|
262
|
+
|
|
263
|
+
if out:
|
|
264
|
+
os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
|
|
265
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
266
|
+
json.dump(report, fh, indent=2)
|
|
267
|
+
console.print(f"[green]report -> {out}[/]")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _auto_device() -> str:
|
|
271
|
+
"""Best available torch device: cuda > mps > cpu."""
|
|
272
|
+
if torch.cuda.is_available():
|
|
273
|
+
return "cuda"
|
|
274
|
+
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
|
|
275
|
+
return "mps"
|
|
276
|
+
return "cpu"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _fmt_temperature(t: float | dict[str, float]) -> str:
|
|
280
|
+
"""Temperature is a scalar, or a {primitive: value} map once fitted per kind."""
|
|
281
|
+
if isinstance(t, dict):
|
|
282
|
+
return "{" + ", ".join(f"{k}:{v:.3f}" for k, v in sorted(t.items())) + "}"
|
|
283
|
+
return f"{t:.3f}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------- serve / ask
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command("serve")
|
|
290
|
+
def serve_cmd(
|
|
291
|
+
checkpoint: str = typer.Argument(...),
|
|
292
|
+
host: str = typer.Option("127.0.0.1"),
|
|
293
|
+
port: int = typer.Option(8000),
|
|
294
|
+
device: str | None = typer.Option(
|
|
295
|
+
None, "--device",
|
|
296
|
+
help="Torch device (cuda|mps|cpu). Auto-detected when omitted.",
|
|
297
|
+
),
|
|
298
|
+
no_prefix_cache: bool = typer.Option(False, "--no-prefix-cache"),
|
|
299
|
+
model_name: str | None = typer.Option(
|
|
300
|
+
None, "--model-name",
|
|
301
|
+
help="Value returned as `model` in responses. Defaults to the checkpoint basename.",
|
|
302
|
+
),
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Serve POST /v1/systemone. JevBench's typesafe adapter runs against it unchanged."""
|
|
305
|
+
from .server import serve
|
|
306
|
+
|
|
307
|
+
console.print(f"[green]serving[/] {checkpoint} on http://{host}:{port}")
|
|
308
|
+
serve(
|
|
309
|
+
checkpoint, host=host, port=port, device=device or _auto_device(),
|
|
310
|
+
use_prefix_cache=not no_prefix_cache, model_name=model_name,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command("ask")
|
|
315
|
+
def ask_cmd(
|
|
316
|
+
checkpoint: str = typer.Argument(...),
|
|
317
|
+
state: str = typer.Option(..., "--state", "-s", help="The content to evaluate."),
|
|
318
|
+
noul: list[str] | None = typer.Option(None, "--noul", help="Yes/no question; repeat."),
|
|
319
|
+
choice: list[str] | None = typer.Option(
|
|
320
|
+
None, "--choice", help="'question?=opt1,opt2,opt3'; repeat."
|
|
321
|
+
),
|
|
322
|
+
score: list[str] | None = typer.Option(
|
|
323
|
+
None, "--score", help="'question?=low,mid,high' (ascending); repeat."
|
|
324
|
+
),
|
|
325
|
+
device: str | None = typer.Option(
|
|
326
|
+
None, "--device",
|
|
327
|
+
help="Torch device (cuda|mps|cpu). Auto-detected when omitted.",
|
|
328
|
+
),
|
|
329
|
+
as_json: bool = typer.Option(False, "--json", help="Print the raw API response."),
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Ask one state a set of typed questions from the command line."""
|
|
332
|
+
from .infer import load_engine
|
|
333
|
+
|
|
334
|
+
questions: dict[str, Question] = {}
|
|
335
|
+
for i, q in enumerate(noul or []):
|
|
336
|
+
questions[f"noul_{i}"] = NoulQuestion(instructions=q)
|
|
337
|
+
for i, spec in enumerate(choice or []):
|
|
338
|
+
q, _, opts = spec.partition("=")
|
|
339
|
+
if not opts:
|
|
340
|
+
raise typer.BadParameter(f"--choice needs 'question=opt1,opt2': {spec!r}")
|
|
341
|
+
questions[f"choice_{i}"] = ChoiceQuestion(
|
|
342
|
+
instructions=q, criteria={o.strip(): "" for o in opts.split(",") if o.strip()}
|
|
343
|
+
)
|
|
344
|
+
for i, spec in enumerate(score or []):
|
|
345
|
+
q, _, levels = spec.partition("=")
|
|
346
|
+
if not levels:
|
|
347
|
+
raise typer.BadParameter(f"--score needs 'question=low,high': {spec!r}")
|
|
348
|
+
questions[f"score_{i}"] = ScoreQuestion(
|
|
349
|
+
instructions=q, criteria=[s.strip() for s in levels.split(",") if s.strip()]
|
|
350
|
+
)
|
|
351
|
+
if not questions:
|
|
352
|
+
raise typer.BadParameter("ask at least one --noul / --choice / --score question")
|
|
353
|
+
|
|
354
|
+
engine = load_engine(checkpoint, device=device or _auto_device())
|
|
355
|
+
response = engine.ask(state, questions)
|
|
356
|
+
|
|
357
|
+
if as_json:
|
|
358
|
+
console.print_json(response.model_dump_json())
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
for name, ans in response.answers.items():
|
|
362
|
+
if ans.type == "noul":
|
|
363
|
+
console.print(f"[bold]{name}[/] noul = [cyan]{ans.noul:.3f}[/]")
|
|
364
|
+
elif ans.type == "choice":
|
|
365
|
+
console.print(
|
|
366
|
+
f"[bold]{name}[/] -> [cyan]{ans.choice}[/] "
|
|
367
|
+
f"(confidence {ans.confidence:.3f})"
|
|
368
|
+
)
|
|
369
|
+
for opt, p in sorted(ans.probabilities.items(), key=lambda kv: -kv[1]):
|
|
370
|
+
console.print(f" {opt:<24} {p:.3f}")
|
|
371
|
+
else:
|
|
372
|
+
console.print(
|
|
373
|
+
f"[bold]{name}[/] score = [cyan]{ans.score:.2f}[/] "
|
|
374
|
+
f"(confidence {ans.confidence:.3f})"
|
|
375
|
+
)
|
|
376
|
+
for lvl, p in ans.probabilities.items():
|
|
377
|
+
console.print(f" {lvl}: {ans.legend.get(lvl, ''):<40} {p:.3f}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.command("info", hidden=True)
|
|
381
|
+
def info_cmd(checkpoint: str = typer.Argument(...)) -> None:
|
|
382
|
+
"""Print a checkpoint's configuration."""
|
|
383
|
+
from .modeling import StrandsDeciderConfig, checkpoint_dir, config_path
|
|
384
|
+
|
|
385
|
+
cfg = StrandsDeciderConfig.from_json(config_path(checkpoint_dir(checkpoint)))
|
|
386
|
+
console.print_json(cfg.to_json())
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
if __name__ == "__main__": # pragma: no cover
|
|
390
|
+
app()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .collate import CollatorConfig, SystemOneCollator
|
|
2
|
+
from .format import Example, load_examples, read_jsonl, split_examples, write_jsonl
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"CollatorConfig",
|
|
6
|
+
"Example",
|
|
7
|
+
"SystemOneCollator",
|
|
8
|
+
"load_examples",
|
|
9
|
+
"read_jsonl",
|
|
10
|
+
"split_examples",
|
|
11
|
+
"write_jsonl",
|
|
12
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Answer adequacy from HelpSteer2 (nvidia/HelpSteer2, CC-BY-4.0): a request, a response,
|
|
2
|
+
and "is the response adequate?".
|
|
3
|
+
|
|
4
|
+
JevBench's `adequacy` family asks this question, and nothing in the training corpus
|
|
5
|
+
does: v17 answers "adequate" on 11 of that family's 12 public tasks. HelpSteer2 has
|
|
6
|
+
human ratings (Scale AI, 3-5 annotators per response, 0-4 scales) of responses written
|
|
7
|
+
by NVIDIA's in-house models, two responses per prompt.
|
|
8
|
+
|
|
9
|
+
adequate helpfulness >= 3 and correctness >= 3
|
|
10
|
+
inadequate helpfulness <= 1 or correctness <= 1
|
|
11
|
+
(the middle band is dropped as ambiguous)
|
|
12
|
+
|
|
13
|
+
Single-turn prompts only. Both splits are balanced to equal "yes" and "no": every
|
|
14
|
+
inadequate response is kept, and the adequate ones are drawn first from the same
|
|
15
|
+
prompts (the other response to a prompt whose first is inadequate), so that the
|
|
16
|
+
request alone does not predict the answer. The eval comes from HelpSteer2's
|
|
17
|
+
validation split and training rows only from its train split.
|
|
18
|
+
|
|
19
|
+
python -m strands_decider.data.adequacy --raw data/raw/helpsteer2 \
|
|
20
|
+
--out data/adequacy_hs2.jsonl --eval-out data/adequacy_hs2_eval.jsonl
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import gzip
|
|
25
|
+
import json
|
|
26
|
+
import random
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
|
|
29
|
+
from .format import Example
|
|
30
|
+
|
|
31
|
+
TASK = "adequacy_hs2"
|
|
32
|
+
QUESTION = "Does the response adequately answer the request: correct, complete and on point?"
|
|
33
|
+
VARIANTS = [
|
|
34
|
+
QUESTION,
|
|
35
|
+
"Is this response good enough to send as the answer to the request?",
|
|
36
|
+
"Would a careful reviewer accept this response as an adequate answer to the request?",
|
|
37
|
+
"Does the response do what the request asks, without significant errors or omissions?",
|
|
38
|
+
]
|
|
39
|
+
OPTIONS = [["false", "the response is not adequate"], ["true", "the response is adequate"]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def verdict(r: dict) -> bool | None:
|
|
43
|
+
if r["helpfulness"] >= 3 and r["correctness"] >= 3:
|
|
44
|
+
return True
|
|
45
|
+
if r["helpfulness"] <= 1 or r["correctness"] <= 1:
|
|
46
|
+
return False
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(path: str) -> list[dict]:
|
|
51
|
+
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
|
52
|
+
rows = [json.loads(line) for line in fh if line.strip()]
|
|
53
|
+
return [r for r in rows if "<extra_id_1>" not in r["prompt"] and verdict(r) is not None]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def balanced(rows: list[dict], seed: int = 0) -> list[dict]:
|
|
57
|
+
"""Every inadequate response, and as many adequate ones, pair-mates first."""
|
|
58
|
+
rng = random.Random(seed)
|
|
59
|
+
bad = [r for r in rows if not verdict(r)]
|
|
60
|
+
bad_prompts = {r["prompt"] for r in bad}
|
|
61
|
+
good = [r for r in rows if verdict(r)]
|
|
62
|
+
mates = [r for r in good if r["prompt"] in bad_prompts]
|
|
63
|
+
others = [r for r in good if r["prompt"] not in bad_prompts]
|
|
64
|
+
rng.shuffle(mates)
|
|
65
|
+
rng.shuffle(others)
|
|
66
|
+
return bad + (mates + others)[:len(bad)]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def to_example(r: dict) -> Example:
|
|
70
|
+
state = f"Request:\n{r['prompt'].strip()}\n\nResponse:\n{r['response'].strip()}"
|
|
71
|
+
return Example(kind="noul", state=state, instructions=QUESTION, options=[list(o) for o in OPTIONS],
|
|
72
|
+
label=int(bool(verdict(r))), task=TASK, instruction_variants=list(VARIANTS))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> None:
|
|
76
|
+
import argparse
|
|
77
|
+
|
|
78
|
+
from transformers import AutoTokenizer
|
|
79
|
+
|
|
80
|
+
from .format import write_jsonl
|
|
81
|
+
from .multistep import fits
|
|
82
|
+
|
|
83
|
+
ap = argparse.ArgumentParser(description="Build the HelpSteer2 answer-adequacy rows.")
|
|
84
|
+
ap.add_argument("--raw", default="data/raw/helpsteer2")
|
|
85
|
+
ap.add_argument("--out", default="", help="training rows (train split); omitted if empty")
|
|
86
|
+
ap.add_argument("--eval-out", default="data/adequacy_hs2_eval.jsonl")
|
|
87
|
+
ap.add_argument("--tokenizer", default="Qwen/Qwen3.5-2B-Base")
|
|
88
|
+
ap.add_argument("--max-tokens", type=int, default=3000)
|
|
89
|
+
ap.add_argument("--seed", type=int, default=0)
|
|
90
|
+
args = ap.parse_args(argv)
|
|
91
|
+
tok = AutoTokenizer.from_pretrained(args.tokenizer)
|
|
92
|
+
|
|
93
|
+
splits = {"eval": (f"{args.raw}/validation.jsonl.gz", args.eval_out)}
|
|
94
|
+
if args.out:
|
|
95
|
+
splits["train"] = (f"{args.raw}/train.jsonl.gz", args.out)
|
|
96
|
+
prompts = defaultdict(set)
|
|
97
|
+
for name, (src, dst) in splits.items():
|
|
98
|
+
rows = balanced(load(src), seed=args.seed)
|
|
99
|
+
prompts[name] = {r["prompt"] for r in rows}
|
|
100
|
+
examples = fits([to_example(r) for r in rows], tok, args.max_tokens)
|
|
101
|
+
# fits() may drop rows over the window unevenly; rebalance what remains
|
|
102
|
+
yes = [e for e in examples if e.label == 1]
|
|
103
|
+
no = [e for e in examples if e.label == 0]
|
|
104
|
+
n = min(len(yes), len(no))
|
|
105
|
+
examples = yes[:n] + no[:n]
|
|
106
|
+
random.Random(args.seed).shuffle(examples)
|
|
107
|
+
print(f"{name}: {len(examples):,} rows ({n:,} adequate, {n:,} not), "
|
|
108
|
+
f"{len(prompts[name]):,} distinct prompts")
|
|
109
|
+
write_jsonl(dst, examples)
|
|
110
|
+
if "train" in prompts:
|
|
111
|
+
print(f"prompts shared by train and eval: {len(prompts['train'] & prompts['eval'])}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Catch-all options (v20): choice rows where "other" / "none of these" is an option, and
|
|
2
|
+
half the time the right one.
|
|
3
|
+
|
|
4
|
+
The classification corpus names every category it asks about, so the model has never
|
|
5
|
+
had to pick a catch-all because the true category is missing, or to pass one over
|
|
6
|
+
because it is present. JevBench's routing and intent questions carry such options, and
|
|
7
|
+
v19 misses one of each with high confidence. This adds rows built from the corpus's own
|
|
8
|
+
choice tasks with named categories (ag_news, banking77, clinc150, dbpedia,
|
|
9
|
+
yahoo_topics):
|
|
10
|
+
|
|
11
|
+
absent the gold option is removed and a catch-all is added: the catch-all is right
|
|
12
|
+
present a catch-all is added beside the gold option: the gold option is right
|
|
13
|
+
|
|
14
|
+
in equal numbers, so the presence of a catch-all never predicts the answer. Rows whose
|
|
15
|
+
options already include CLINC150's out-of-scope class are skipped. The catch-all goes
|
|
16
|
+
at the end of the canonical order; options are shuffled at collate time.
|
|
17
|
+
|
|
18
|
+
The eval applies the same transform to the test half of two held-out tasks never
|
|
19
|
+
trained on (`massive_intent`, `emotion` in data/holdout_v5_norule.jsonl).
|
|
20
|
+
|
|
21
|
+
python -m strands_decider.data.catchall --out data/catchall_v20.jsonl --eval-out data/catchall_v20_eval.jsonl
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import random
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
|
|
28
|
+
from .format import Example
|
|
29
|
+
|
|
30
|
+
SOURCES = ("ag_news", "banking77", "clinc150", "dbpedia", "yahoo_topics")
|
|
31
|
+
HELDOUT = ("massive_intent", "emotion")
|
|
32
|
+
NAMES = ["other", "none of these", "something else", "none of the listed categories"]
|
|
33
|
+
DESCRIPTION = "the text fits none of the other options"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _oos(ex: Example) -> bool:
|
|
37
|
+
return any("oos" in n.lower() or "out of scope" in n.lower() or "out_of_scope" in n.lower()
|
|
38
|
+
for n, _ in ex.options)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def transform(ex: Example, mode: str, rng: random.Random, max_options: int = 24) -> Example | None:
|
|
42
|
+
"""One catch-all row from `ex`; None if the row cannot take one. A row already at
|
|
43
|
+
`max_options` (the model's num_slots, the width of its training KL reference) gives
|
|
44
|
+
up one wrong option at random to make room for the catch-all."""
|
|
45
|
+
if ex.kind != "choice" or _oos(ex) or any(n.lower() in NAMES for n, _ in ex.options):
|
|
46
|
+
return None
|
|
47
|
+
catch = [rng.choice(NAMES), DESCRIPTION]
|
|
48
|
+
if mode == "absent":
|
|
49
|
+
rest = [o for i, o in enumerate(ex.options) if i != ex.label]
|
|
50
|
+
if len(rest) < 2:
|
|
51
|
+
return None
|
|
52
|
+
options, label = [*rest, catch], len(rest)
|
|
53
|
+
elif mode == "present":
|
|
54
|
+
options, label = list(ex.options), ex.label
|
|
55
|
+
if len(options) >= max_options:
|
|
56
|
+
drop = rng.choice([i for i in range(len(options)) if i != label])
|
|
57
|
+
options = [o for i, o in enumerate(options) if i != drop]
|
|
58
|
+
label = label - (drop < label)
|
|
59
|
+
options = [*options, catch]
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(mode)
|
|
62
|
+
return Example(kind="choice", state=ex.state, instructions=ex.instructions,
|
|
63
|
+
options=[list(o) for o in options], label=label, task=f"{ex.task}+catchall:{mode}",
|
|
64
|
+
weight=ex.weight, instruction_variants=list(ex.instruction_variants))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build(examples: list[Example], tasks: Iterable[str], per_task: int, seed: int = 0) -> list[Example]:
|
|
68
|
+
"""`per_task` rows from each task, half absent and half present."""
|
|
69
|
+
rng = random.Random(seed)
|
|
70
|
+
out: list[Example] = []
|
|
71
|
+
for task in tasks:
|
|
72
|
+
pool = [e for e in examples if e.task == task and e.kind == "choice"]
|
|
73
|
+
rng.shuffle(pool)
|
|
74
|
+
want = {"absent": per_task // 2, "present": per_task - per_task // 2}
|
|
75
|
+
for e in pool:
|
|
76
|
+
mode = "absent" if want["absent"] >= want["present"] and want["absent"] > 0 else "present"
|
|
77
|
+
if want[mode] == 0:
|
|
78
|
+
break
|
|
79
|
+
row = transform(e, mode, rng)
|
|
80
|
+
if row is not None:
|
|
81
|
+
out.append(row)
|
|
82
|
+
want[mode] -= 1
|
|
83
|
+
rng.shuffle(out)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> None:
|
|
88
|
+
import argparse
|
|
89
|
+
|
|
90
|
+
from ..evaluate import partition_examples
|
|
91
|
+
from .format import read_jsonl, write_jsonl
|
|
92
|
+
|
|
93
|
+
ap = argparse.ArgumentParser(description="Build the catch-all option rows (v20).")
|
|
94
|
+
ap.add_argument("--src", default="data/train_v5.jsonl")
|
|
95
|
+
ap.add_argument("--heldout", default="data/holdout_v5_norule.jsonl")
|
|
96
|
+
ap.add_argument("--out", default="data/catchall_v20.jsonl")
|
|
97
|
+
ap.add_argument("--eval-out", default="data/catchall_v20_eval.jsonl")
|
|
98
|
+
ap.add_argument("--per-task", type=int, default=1000)
|
|
99
|
+
ap.add_argument("--eval-per-task", type=int, default=400)
|
|
100
|
+
ap.add_argument("--seed", type=int, default=0)
|
|
101
|
+
args = ap.parse_args(argv)
|
|
102
|
+
|
|
103
|
+
train = build(list(read_jsonl(args.src)), SOURCES, args.per_task, seed=args.seed)
|
|
104
|
+
test_half = partition_examples(list(read_jsonl(args.heldout)), "test")
|
|
105
|
+
evals = build(test_half, HELDOUT, args.eval_per_task, seed=args.seed)
|
|
106
|
+
for name, rows in (("train", train), ("eval", evals)):
|
|
107
|
+
absent = sum(r.task.endswith(":absent") for r in rows)
|
|
108
|
+
print(f"{name}: {len(rows):,} rows ({absent:,} catch-all right, {len(rows) - absent:,} catch-all a distractor)")
|
|
109
|
+
write_jsonl(args.out, train)
|
|
110
|
+
write_jsonl(args.eval_out, evals)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|