sdm-learn 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.
- sdm/__init__.py +77 -0
- sdm/__main__.py +58 -0
- sdm/benchmarks.py +221 -0
- sdm/data.py +227 -0
- sdm/datasets.py +695 -0
- sdm/experiment.py +228 -0
- sdm/format.md +86 -0
- sdm/formats.py +1269 -0
- sdm/gateway.py +387 -0
- sdm/learn.py +933 -0
- sdm/prauc.py +166 -0
- sdm/science.py +700 -0
- sdm/signals.py +262 -0
- sdm_learn-0.1.0.dist-info/METADATA +589 -0
- sdm_learn-0.1.0.dist-info/RECORD +19 -0
- sdm_learn-0.1.0.dist-info/WHEEL +5 -0
- sdm_learn-0.1.0.dist-info/entry_points.txt +2 -0
- sdm_learn-0.1.0.dist-info/licenses/LICENSE +201 -0
- sdm_learn-0.1.0.dist-info/top_level.txt +1 -0
sdm/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Self-documenting models: the document *is* the model.
|
|
2
|
+
|
|
3
|
+
There are no weights and no classifier head. A document holds the learned
|
|
4
|
+
hypothesis, an LLM reads it to classify inputs, and then rewrites it from the
|
|
5
|
+
revealed labels. What you train is readable, and what you read is what runs.
|
|
6
|
+
|
|
7
|
+
Four components plug into one interface, one file each, and the imports only
|
|
8
|
+
point downward:
|
|
9
|
+
|
|
10
|
+
gateway.py the wire to an LLM, and SetupNeeded
|
|
11
|
+
data.py the dataset, the input representation, row rendering
|
|
12
|
+
formats.py how the hypothesis is written down and revised
|
|
13
|
+
signals.py the audio codec, imported only on demand
|
|
14
|
+
learn.py run artifacts, the gate, the baseline, train / evaluate
|
|
15
|
+
|
|
16
|
+
``import sdm`` is the only entry point: everything public is re-exported here,
|
|
17
|
+
so callers never see the split.
|
|
18
|
+
|
|
19
|
+
The gate algorithm runs this loop per epoch::
|
|
20
|
+
|
|
21
|
+
predict the feedback batches from the current document (labels hidden)
|
|
22
|
+
reveal the labels and ask for one bounded revision
|
|
23
|
+
score the current and revised documents head to head on protected rows
|
|
24
|
+
keep the revision only if it wins
|
|
25
|
+
|
|
26
|
+
``state.json`` is the truth; the skill file is rendered from it.
|
|
27
|
+
|
|
28
|
+
import sdm
|
|
29
|
+
|
|
30
|
+
data = sdm.dataset(
|
|
31
|
+
train=[{"text": "wire me $500 today", "label": "spam"},
|
|
32
|
+
{"text": "lunch at one?", "label": "ham"}],
|
|
33
|
+
classes=("spam", "ham"),
|
|
34
|
+
description="Each input is one email body.",
|
|
35
|
+
)
|
|
36
|
+
skill = sdm.train(data, algo="sdm")
|
|
37
|
+
print(skill)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from . import datasets, science
|
|
43
|
+
from .data import (REPRESENTATIONS, Dataset, Report, content, dataset,
|
|
44
|
+
describe, from_jsonl, labeled)
|
|
45
|
+
from .formats import (DEFAULT_FORMAT_MD, PACKAGED_FORMAT_FILE, apply_edits, clean_condition, codecs,
|
|
46
|
+
credit, format_file, formats, get_format, load_formats,
|
|
47
|
+
new_state, parse_edits, parse_predictions, render,
|
|
48
|
+
validate_html, validate_svg, validate_text,
|
|
49
|
+
write_format_file)
|
|
50
|
+
from .gateway import SetupNeeded
|
|
51
|
+
from .learn import (ALGOS, GATE_SCORING, Skill, epoch_points, decide,
|
|
52
|
+
evaluate, load, parse_paired, proposal_history_text,
|
|
53
|
+
split_holdout, stratified_holdout, train)
|
|
54
|
+
from .experiment import run_experiment
|
|
55
|
+
from .prauc import average_precision, evaluate_prauc
|
|
56
|
+
|
|
57
|
+
__version__ = "0.1.0"
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# the API
|
|
61
|
+
"Dataset", "Report", "Skill", "SetupNeeded",
|
|
62
|
+
"dataset", "datasets", "science", "from_jsonl", "load", "train", "evaluate",
|
|
63
|
+
"run_experiment", "evaluate_prauc", "average_precision",
|
|
64
|
+
# the four components
|
|
65
|
+
"REPRESENTATIONS", "describe",
|
|
66
|
+
"formats", "get_format", "load_formats", "DEFAULT_FORMAT_MD",
|
|
67
|
+
"format_file", "write_format_file", "PACKAGED_FORMAT_FILE",
|
|
68
|
+
"ALGOS", "GATE_SCORING", "codecs",
|
|
69
|
+
# the pieces the README and the checks reach for
|
|
70
|
+
"render", "new_state", "parse_edits", "apply_edits", "clean_condition",
|
|
71
|
+
"parse_predictions", "parse_paired", "credit",
|
|
72
|
+
"validate_text", "validate_html", "validate_svg",
|
|
73
|
+
"stratified_holdout", "split_holdout", "epoch_points", "decide",
|
|
74
|
+
"proposal_history_text",
|
|
75
|
+
"content", "labeled",
|
|
76
|
+
"__version__",
|
|
77
|
+
]
|
sdm/__main__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``python -m sdm``, and the ``sdm`` console script.
|
|
2
|
+
|
|
3
|
+
Small on purpose. The library is the product; this exists so a pip-installed
|
|
4
|
+
user can get an editable ``format.md``, confirm the install works without an
|
|
5
|
+
API key, and run the shipped benchmarks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from . import __version__, format_file, formats, write_format_file
|
|
14
|
+
from .gateway import SetupNeeded
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None) -> int:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="sdm", description="Self-documenting models.")
|
|
20
|
+
parser.add_argument("--version", action="version",
|
|
21
|
+
version=f"sdm {__version__}")
|
|
22
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
initialize = commands.add_parser(
|
|
25
|
+
"init", help="write an editable format.md into this directory")
|
|
26
|
+
initialize.add_argument("--force", action="store_true",
|
|
27
|
+
help="overwrite an existing format.md")
|
|
28
|
+
|
|
29
|
+
commands.add_parser(
|
|
30
|
+
"formats", help="list the skill formats format.md defines")
|
|
31
|
+
|
|
32
|
+
from . import benchmarks
|
|
33
|
+
benchmarks.add_arguments(commands.add_parser(
|
|
34
|
+
"run", help="run a shipped benchmark: --dataset, --algo, --format"))
|
|
35
|
+
|
|
36
|
+
options = parser.parse_args(argv)
|
|
37
|
+
try:
|
|
38
|
+
if options.command == "run":
|
|
39
|
+
return benchmarks.run(options)
|
|
40
|
+
|
|
41
|
+
if options.command == "init":
|
|
42
|
+
path = write_format_file(force=options.force)
|
|
43
|
+
print(f"wrote {path}")
|
|
44
|
+
print("Edit the 'format:' line to change the skill format.")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
active = format_file()
|
|
48
|
+
print(f"reading {active}")
|
|
49
|
+
for name, skill_format in sorted(formats().items()):
|
|
50
|
+
print(f" {name:10s} mechanism: {skill_format.mechanism}")
|
|
51
|
+
return 0
|
|
52
|
+
except SetupNeeded as error:
|
|
53
|
+
print(f"sdm: {error}", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|
sdm/benchmarks.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The shipped benchmarks, and the one command that runs them.
|
|
2
|
+
|
|
3
|
+
sdm run --dataset pods --algo sdm --format html
|
|
4
|
+
sdm run --dataset data/mine # train.jsonl and test.jsonl inside
|
|
5
|
+
|
|
6
|
+
Each benchmark is a loader plus the gate settings its protocol calls for. The
|
|
7
|
+
command line only overrides those defaults; the lifecycle (zero-training
|
|
8
|
+
control, gated training, evaluation, resume, result files) is the shared
|
|
9
|
+
:func:`sdm.run_experiment`. PODS and DF2 add the original paper's one-vs-all
|
|
10
|
+
PR-AUC after top-1 accuracy. A directory of JSONL rows is a benchmark too,
|
|
11
|
+
with the generic defaults.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import datasets, science
|
|
22
|
+
from .data import from_jsonl
|
|
23
|
+
from .experiment import run_experiment
|
|
24
|
+
from .learn import ALGOS, GATE_SCORING, load
|
|
25
|
+
from .prauc import evaluate_prauc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Benchmark:
|
|
30
|
+
"""One dataset and the training settings its protocol uses."""
|
|
31
|
+
|
|
32
|
+
load: object
|
|
33
|
+
protocol: str
|
|
34
|
+
settings: dict = field(default_factory=dict)
|
|
35
|
+
train: int | None = None
|
|
36
|
+
test: int | None = None
|
|
37
|
+
image_size: int | None = None
|
|
38
|
+
prauc: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PERSONAL = {"holdout_per_class": 1, "epochs": 7, "gate_scoring": "separate",
|
|
42
|
+
"proposal_history": True, "max_semantic_edits": 60}
|
|
43
|
+
SCIENCE = {"epochs": 4}
|
|
44
|
+
|
|
45
|
+
BENCHMARKS = {
|
|
46
|
+
"cifar10": Benchmark(
|
|
47
|
+
load=lambda o: datasets.cifar10(o.train, o.test, root=o.data_dir or "data/cifar"),
|
|
48
|
+
protocol="direct RGB images, 10 classes",
|
|
49
|
+
settings={"holdout_per_class": 1, "epochs": 5,
|
|
50
|
+
"max_semantic_edits": 100, "max_support_edits": 100},
|
|
51
|
+
train=100, test=100),
|
|
52
|
+
"pods": Benchmark(
|
|
53
|
+
load=lambda o: datasets.pods("test", o.test, image_size=o.image_size,
|
|
54
|
+
root=o.data_dir or "data/pods"),
|
|
55
|
+
protocol="PODS paper class split: 70 test objects, 3 training photos "
|
|
56
|
+
"each, test photos per object from the full test split",
|
|
57
|
+
settings=PERSONAL, test=10, image_size=512, prauc=True),
|
|
58
|
+
"df2": Benchmark(
|
|
59
|
+
load=lambda o: datasets.df2("train", o.test, image_size=o.image_size,
|
|
60
|
+
val_per_class=0,
|
|
61
|
+
root=o.data_dir or "data/personal/df2/df2"),
|
|
62
|
+
protocol="PODS paper protocol on DF2: 139 test shirts, 3 training "
|
|
63
|
+
"photos each, test photos per shirt",
|
|
64
|
+
settings=PERSONAL, test=10, image_size=512, prauc=True),
|
|
65
|
+
"camelyon17": Benchmark(
|
|
66
|
+
load=lambda o: datasets.camelyon17(
|
|
67
|
+
o.train, o.test, image_size=o.image_size, seed=o.seed,
|
|
68
|
+
root=o.data_dir or "data/wilds/camelyon17"),
|
|
69
|
+
protocol="WILDS in-distribution training patches, official "
|
|
70
|
+
"out-of-distribution test patches",
|
|
71
|
+
settings={"holdout_per_class": 10, "epochs": 4,
|
|
72
|
+
"gate_scoring": "separate"},
|
|
73
|
+
train=100, test=500, image_size=288),
|
|
74
|
+
# The four science benchmarks of reproduce/ai4science. Their reported
|
|
75
|
+
# numbers were produced with the SLMD-dev learner (see that README); here
|
|
76
|
+
# they run under the generic gate settings.
|
|
77
|
+
"gravityspy": Benchmark(
|
|
78
|
+
load=lambda o: science.gravityspy(
|
|
79
|
+
o.train, o.test, image_size=o.image_size, seed=o.seed,
|
|
80
|
+
root=o.data_dir or "data/gravityspy"),
|
|
81
|
+
protocol="Gravity Spy v1.0 official train / test samples, the ten "
|
|
82
|
+
"largest glitch classes, four-window spectrograms with a Hz "
|
|
83
|
+
"ruler and time axis",
|
|
84
|
+
settings=SCIENCE, train=300, test=400, image_size=412),
|
|
85
|
+
"galaxy10": Benchmark(
|
|
86
|
+
load=lambda o: science.galaxy10(
|
|
87
|
+
o.train, o.test, image_size=o.image_size, seed=o.seed,
|
|
88
|
+
root=o.data_dir or "data/galaxy10"),
|
|
89
|
+
protocol="Galaxy10 DECaLS colour cutouts, ten Galaxy Zoo morphology "
|
|
90
|
+
"classes, class-balanced seeded draw (no official split)",
|
|
91
|
+
settings=SCIENCE, train=100, test=100, image_size=224),
|
|
92
|
+
"bloodmnist": Benchmark(
|
|
93
|
+
load=lambda o: science.bloodmnist(
|
|
94
|
+
o.train, o.test, image_size=o.image_size, seed=o.seed,
|
|
95
|
+
root=o.data_dir or "data/bloodmnist"),
|
|
96
|
+
protocol="BloodMNIST 224 px official train / test split, eight "
|
|
97
|
+
"peripheral blood cell types",
|
|
98
|
+
settings=SCIENCE, train=300, test=160, image_size=224),
|
|
99
|
+
"eclipsing": Benchmark(
|
|
100
|
+
load=lambda o: science.eclipsing(
|
|
101
|
+
o.train, o.test, seed=o.seed, root=o.data_dir or "data/ogle3"),
|
|
102
|
+
protocol="OGLE-III LMC eclipsing binaries as order-4 Fourier "
|
|
103
|
+
"coefficients of the folded I-band light curve; contact / "
|
|
104
|
+
"semidetached / detached",
|
|
105
|
+
settings=SCIENCE, train=60, test=150),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def custom(path: Path) -> Benchmark:
|
|
110
|
+
"""A directory holding ``train.jsonl`` and ``test.jsonl`` as a benchmark.
|
|
111
|
+
|
|
112
|
+
Rows need a ``label`` and ``text`` or ``image`` (paths relative to the
|
|
113
|
+
file); classes are the labels seen. ``--description`` tells the model
|
|
114
|
+
what one input is.
|
|
115
|
+
"""
|
|
116
|
+
train, test = path / "train.jsonl", path / "test.jsonl"
|
|
117
|
+
for file in (train, test):
|
|
118
|
+
if not file.is_file():
|
|
119
|
+
raise FileNotFoundError(
|
|
120
|
+
f"{file} is missing; a custom --dataset is a directory with "
|
|
121
|
+
"train.jsonl and test.jsonl")
|
|
122
|
+
with open(train) as handle:
|
|
123
|
+
first = json.loads(next(handle))
|
|
124
|
+
representation = "image" if "image" in first else "text"
|
|
125
|
+
|
|
126
|
+
def load(options):
|
|
127
|
+
return from_jsonl(train, test, limit=options.train,
|
|
128
|
+
test_limit=options.test, name=path.name,
|
|
129
|
+
representation=representation,
|
|
130
|
+
description=options.description or "",
|
|
131
|
+
**({"image_size": options.image_size}
|
|
132
|
+
if options.image_size else {}))
|
|
133
|
+
|
|
134
|
+
return Benchmark(load=load, protocol=f"JSONL rows under {path}",
|
|
135
|
+
settings={"epochs": 4})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
139
|
+
parser.add_argument("--dataset", required=True,
|
|
140
|
+
help=f"one of {', '.join(sorted(BENCHMARKS))}, or a "
|
|
141
|
+
"directory with train.jsonl and test.jsonl")
|
|
142
|
+
parser.add_argument("--description",
|
|
143
|
+
help="custom dataset: what one input is")
|
|
144
|
+
parser.add_argument("--algo", choices=tuple(ALGOS), default="sdm",
|
|
145
|
+
help="sdm: learn a document under the gate; "
|
|
146
|
+
"base: the zero-training control")
|
|
147
|
+
parser.add_argument("--format", default="markdown",
|
|
148
|
+
help="a section of format.md: markdown, text, html, "
|
|
149
|
+
"image, audio, or your own")
|
|
150
|
+
parser.add_argument("--style",
|
|
151
|
+
help="what the document should contain, in words, "
|
|
152
|
+
"e.g. \"decision tree, tables, illustrations\"")
|
|
153
|
+
parser.add_argument("--train", type=int, help="training rows")
|
|
154
|
+
parser.add_argument("--test", type=int,
|
|
155
|
+
help="test rows (PODS, DF2: per object)")
|
|
156
|
+
parser.add_argument("--image-size", type=int, help="pixels per side")
|
|
157
|
+
parser.add_argument("--batch-size", type=int, default=10)
|
|
158
|
+
parser.add_argument("--epochs", type=int, help="gated revisions")
|
|
159
|
+
parser.add_argument("--holdout-per-class", type=int,
|
|
160
|
+
help="rows per class the gate protects")
|
|
161
|
+
parser.add_argument("--gate-scoring", choices=GATE_SCORING)
|
|
162
|
+
parser.add_argument("--max-semantic-edits", type=int,
|
|
163
|
+
help="rule edits one candidate may make")
|
|
164
|
+
parser.add_argument("--no-proposal-history", action="store_true",
|
|
165
|
+
help="hide earlier candidates' verdicts from the writer")
|
|
166
|
+
parser.add_argument("--no-baseline", action="store_true",
|
|
167
|
+
help="skip the zero-training control")
|
|
168
|
+
parser.add_argument("--seed", type=int, default=0)
|
|
169
|
+
parser.add_argument("--data-dir", help="where the dataset lives")
|
|
170
|
+
parser.add_argument("--run-dir",
|
|
171
|
+
help="default: runs/<dataset>-<algo>-<format>")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def settings_for(benchmark: Benchmark, options) -> dict:
|
|
175
|
+
"""The benchmark's defaults, with explicit command-line values winning."""
|
|
176
|
+
settings = dict(benchmark.settings)
|
|
177
|
+
for key in ("epochs", "holdout_per_class", "gate_scoring",
|
|
178
|
+
"max_semantic_edits"):
|
|
179
|
+
value = getattr(options, key, None)
|
|
180
|
+
if value is not None:
|
|
181
|
+
settings[key] = value
|
|
182
|
+
if options.no_proposal_history:
|
|
183
|
+
settings.pop("proposal_history", None)
|
|
184
|
+
if options.style:
|
|
185
|
+
settings["style"] = options.style
|
|
186
|
+
return settings
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run(options) -> int:
|
|
190
|
+
if options.dataset in BENCHMARKS:
|
|
191
|
+
benchmark = BENCHMARKS[options.dataset]
|
|
192
|
+
elif Path(options.dataset).is_dir():
|
|
193
|
+
benchmark = custom(Path(options.dataset))
|
|
194
|
+
else:
|
|
195
|
+
raise SystemExit(f"sdm run: unknown dataset {options.dataset!r}; choose "
|
|
196
|
+
f"{', '.join(sorted(BENCHMARKS))} or a directory with "
|
|
197
|
+
"train.jsonl and test.jsonl")
|
|
198
|
+
name = Path(options.dataset).name
|
|
199
|
+
for key in ("train", "test", "image_size"):
|
|
200
|
+
if getattr(options, key) is None:
|
|
201
|
+
setattr(options, key, getattr(benchmark, key))
|
|
202
|
+
data = benchmark.load(options)
|
|
203
|
+
run_dir = options.run_dir or f"runs/{name}-{options.algo}-{options.format}"
|
|
204
|
+
settings = settings_for(benchmark, options)
|
|
205
|
+
print(f"{name}: {len(data.classes)} classes, {len(data.train)} "
|
|
206
|
+
f"training rows, {len(data.test)} test rows; {benchmark.protocol}")
|
|
207
|
+
print(f"algo={options.algo}; format={options.format}", flush=True)
|
|
208
|
+
run_experiment(
|
|
209
|
+
data, run_dir=run_dir, algo=options.algo, format=options.format,
|
|
210
|
+
batch_size=options.batch_size,
|
|
211
|
+
run_baseline=options.algo == "sdm" and not options.no_baseline,
|
|
212
|
+
metadata={"protocol": benchmark.protocol, "seed": options.seed},
|
|
213
|
+
seed=options.seed, **settings)
|
|
214
|
+
if benchmark.prauc:
|
|
215
|
+
skill = load(run_dir)
|
|
216
|
+
result = evaluate_prauc(skill, data, run_dir=run_dir, tag="prauc")
|
|
217
|
+
print(f"PR-AUC {100 * result['mean_prauc']:.1f}, top-1 from the same "
|
|
218
|
+
f"scores {100 * result['top1_from_scores']:.1f}% "
|
|
219
|
+
f"({result['unparsed_inputs']} unparsed inputs); saved to "
|
|
220
|
+
f"{run_dir}/prauc.json")
|
|
221
|
+
return 0
|
sdm/data.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .gateway import _png_block, image_block
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_TEXT_DESCRIPTION = (
|
|
11
|
+
"Each input is one example rendered as text. Infer the meaning of its "
|
|
12
|
+
"fields from the labeled examples you are shown."
|
|
13
|
+
)
|
|
14
|
+
DEFAULT_IMAGE_DESCRIPTION = (
|
|
15
|
+
"Each input is one image. Infer what distinguishes the classes from the "
|
|
16
|
+
"labeled examples you are shown."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TextRepresentation:
|
|
21
|
+
"""Rows are serialized into the prompt text.
|
|
22
|
+
|
|
23
|
+
``inline`` picks which half of this interface the prompts consult:
|
|
24
|
+
``serialize`` for a representation that becomes text, ``blocks`` for one
|
|
25
|
+
that becomes attachments. Never both.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name = "text"
|
|
29
|
+
inline = True
|
|
30
|
+
default_description = DEFAULT_TEXT_DESCRIPTION
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def serialize(row: dict) -> str:
|
|
34
|
+
if "text" in row:
|
|
35
|
+
return str(row["text"])
|
|
36
|
+
return "\n".join(
|
|
37
|
+
f"{key}={value}" for key, value in sorted(row.items())
|
|
38
|
+
if key not in {"id", "label", "index", "image"})
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def blocks(rows: list[dict], tag: str, image_size: int) -> list:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ImageRepresentation(TextRepresentation):
|
|
46
|
+
"""Rows are attached as images, one content block each."""
|
|
47
|
+
|
|
48
|
+
name = "image"
|
|
49
|
+
inline = False
|
|
50
|
+
default_description = DEFAULT_IMAGE_DESCRIPTION
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def blocks(rows: list[dict], tag: str, image_size: int) -> list:
|
|
54
|
+
attached = []
|
|
55
|
+
for number, row in enumerate(rows, 1):
|
|
56
|
+
attached.append({"type": "text", "text": f"{tag} P{number:02d}"})
|
|
57
|
+
attached.append(image_block(row["image"], image_size))
|
|
58
|
+
return attached
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
REPRESENTATIONS = {"text": TextRepresentation, "image": ImageRepresentation}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Dataset:
|
|
68
|
+
"""Labeled rows plus the little context the model needs to read them.
|
|
69
|
+
|
|
70
|
+
Every row needs a ``label``. Its input is ``text``, or ``image`` (a PIL
|
|
71
|
+
image, an RGB array, or a path), or any other scalar fields.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
train: list[dict] = field(default_factory=list)
|
|
75
|
+
test: list[dict] = field(default_factory=list)
|
|
76
|
+
classes: tuple[str, ...] = ()
|
|
77
|
+
name: str = "custom"
|
|
78
|
+
title: str = ""
|
|
79
|
+
description: str = ""
|
|
80
|
+
representation: str = "text"
|
|
81
|
+
image_size: int = 224
|
|
82
|
+
modality: str | None = None
|
|
83
|
+
|
|
84
|
+
def __post_init__(self):
|
|
85
|
+
self.train = [dict(row) for row in self.train]
|
|
86
|
+
self.test = [dict(row) for row in self.test]
|
|
87
|
+
if self.modality is not None:
|
|
88
|
+
self.representation = self.modality
|
|
89
|
+
self.modality = self.representation
|
|
90
|
+
if not self.classes:
|
|
91
|
+
self.classes = tuple(sorted(
|
|
92
|
+
{str(row["label"]) for row in self.train + self.test
|
|
93
|
+
if "label" in row}
|
|
94
|
+
))
|
|
95
|
+
self.classes = tuple(str(label) for label in self.classes)
|
|
96
|
+
if self.representation not in REPRESENTATIONS:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"unknown representation {self.representation!r}; choose one "
|
|
99
|
+
f"of {', '.join(sorted(REPRESENTATIONS))}")
|
|
100
|
+
if self.image_size <= 0:
|
|
101
|
+
raise ValueError("image_size must be positive")
|
|
102
|
+
if not self.title:
|
|
103
|
+
self.title = f"{self.name} skill"
|
|
104
|
+
for rows in (self.train, self.test):
|
|
105
|
+
for position, row in enumerate(rows):
|
|
106
|
+
row.setdefault("index", position)
|
|
107
|
+
|
|
108
|
+
def __len__(self):
|
|
109
|
+
return len(self.train)
|
|
110
|
+
|
|
111
|
+
def __repr__(self):
|
|
112
|
+
return (f"Dataset(name={self.name!r}, train={len(self.train)}, "
|
|
113
|
+
f"test={len(self.test)}, classes={len(self.classes)})")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Report:
|
|
119
|
+
"""The outcome of scoring a frozen skill."""
|
|
120
|
+
|
|
121
|
+
correct: int
|
|
122
|
+
total: int
|
|
123
|
+
rows: list[dict] = field(default_factory=list)
|
|
124
|
+
requests: int = 0
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def accuracy(self) -> float:
|
|
128
|
+
return self.correct / self.total if self.total else 0.0
|
|
129
|
+
|
|
130
|
+
def __repr__(self):
|
|
131
|
+
return (f"Report(accuracy={self.accuracy:.1%}, "
|
|
132
|
+
f"{self.correct}/{self.total}, requests={self.requests})")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def dataset(train, test=(), **options) -> Dataset:
|
|
137
|
+
"""Wrap in-memory labeled rows into a :class:`Dataset`."""
|
|
138
|
+
return Dataset(train=list(train), test=list(test), **options)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def from_jsonl(train, test=None, *, limit=None, test_limit=None,
|
|
142
|
+
**options) -> Dataset:
|
|
143
|
+
"""Read JSONL rows; relative ``image`` paths resolve from each file."""
|
|
144
|
+
wants_images = (options.get("representation") == "image"
|
|
145
|
+
or options.get("modality") == "image")
|
|
146
|
+
|
|
147
|
+
def read(path, cap):
|
|
148
|
+
path = Path(path)
|
|
149
|
+
if not path.exists():
|
|
150
|
+
raise FileNotFoundError(f"missing {path}")
|
|
151
|
+
with open(path) as handle:
|
|
152
|
+
rows = [json.loads(line) for line in handle]
|
|
153
|
+
rows = rows if cap is None else rows[:cap]
|
|
154
|
+
if wants_images:
|
|
155
|
+
for row in rows:
|
|
156
|
+
image = row.get("image")
|
|
157
|
+
if isinstance(image, str) and not Path(image).is_absolute():
|
|
158
|
+
row["image"] = str((path.parent / image).resolve())
|
|
159
|
+
return rows
|
|
160
|
+
|
|
161
|
+
return Dataset(train=read(train, limit),
|
|
162
|
+
test=read(test, test_limit) if test else [], **options)
|
|
163
|
+
|
|
164
|
+
def describe(data: Dataset) -> str:
|
|
165
|
+
if data.description:
|
|
166
|
+
return data.description
|
|
167
|
+
return REPRESENTATIONS[data.representation].default_description
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def labeled(label: str, pngs) -> list[tuple[str, bytes]]:
|
|
171
|
+
"""Tag each rendering of one document so the model can tell them apart."""
|
|
172
|
+
return [(label, png) for png in pngs]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def content(prompt: str, rows: list[dict], representation: str, tag: str,
|
|
176
|
+
image_size: int, views=()):
|
|
177
|
+
"""Prompt text, the skill's renderings, then the inputs.
|
|
178
|
+
|
|
179
|
+
``views`` holds ``(label, png)`` pairs; each label is numbered separately
|
|
180
|
+
so a two-document gate prompt reads A 01, A 02, B 01 rather than 01..04.
|
|
181
|
+
"""
|
|
182
|
+
attached = REPRESENTATIONS[representation].blocks(rows, tag, image_size)
|
|
183
|
+
if not attached and not views:
|
|
184
|
+
return prompt
|
|
185
|
+
blocks = [{"type": "text", "text": prompt}]
|
|
186
|
+
counts: dict[str, int] = {}
|
|
187
|
+
for label, png in views:
|
|
188
|
+
counts[label] = counts.get(label, 0) + 1
|
|
189
|
+
blocks.append({"type": "text",
|
|
190
|
+
"text": f"{label} {counts[label]:02d}"})
|
|
191
|
+
blocks.append(_png_block(png))
|
|
192
|
+
return blocks + attached
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _inputs(rows: list[dict], representation: str, tag: str = "input") -> str:
|
|
196
|
+
if not REPRESENTATIONS[representation].inline:
|
|
197
|
+
return ""
|
|
198
|
+
serialize = REPRESENTATIONS[representation].serialize
|
|
199
|
+
return "\n\n".join(
|
|
200
|
+
f'<{tag} id="P{number:02d}">\n{serialize(row)}\n</{tag}>'
|
|
201
|
+
for number, row in enumerate(rows, 1))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _revealed(rows: list[dict], predictions: list[dict], representation: str,
|
|
205
|
+
cites_rules: bool = True) -> str:
|
|
206
|
+
"""The labeled feedback block: truth, prediction, citations, evidence."""
|
|
207
|
+
def cited(prediction, attribute: bool):
|
|
208
|
+
if not cites_rules:
|
|
209
|
+
return ""
|
|
210
|
+
joined = ",".join(prediction["rules_used"]) or "none"
|
|
211
|
+
return f' rules="{joined}"' if attribute else f"rules={joined}; "
|
|
212
|
+
|
|
213
|
+
if not REPRESENTATIONS[representation].inline:
|
|
214
|
+
return "\n".join(
|
|
215
|
+
f'P{number:02d}: true={row["label"]}; '
|
|
216
|
+
f'predicted={prediction["pred"]}; '
|
|
217
|
+
f'{cited(prediction, False)}'
|
|
218
|
+
f'evidence={prediction["evidence"]}'
|
|
219
|
+
for number, (row, prediction)
|
|
220
|
+
in enumerate(zip(rows, predictions), 1))
|
|
221
|
+
serialize = REPRESENTATIONS[representation].serialize
|
|
222
|
+
return "\n\n".join(
|
|
223
|
+
f'<example id="P{number:02d}" true="{row["label"]}" '
|
|
224
|
+
f'predicted="{prediction["pred"]}"{cited(prediction, True)}>\n'
|
|
225
|
+
f'{serialize(row)}\n'
|
|
226
|
+
f'<evidence>{prediction["evidence"]}</evidence>\n</example>'
|
|
227
|
+
for number, (row, prediction) in enumerate(zip(rows, predictions), 1))
|