paperlab-space 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.
- paperlab_space/__init__.py +51 -0
- paperlab_space/_cli.py +230 -0
- paperlab_space/cli.py +22 -0
- paperlab_space/config.py +196 -0
- paperlab_space/data/__init__.py +17 -0
- paperlab_space/data/load.py +260 -0
- paperlab_space/data/mapping.py +149 -0
- paperlab_space/data/validate.py +157 -0
- paperlab_space/errors.py +17 -0
- paperlab_space/export/__init__.py +34 -0
- paperlab_space/export/excel.py +147 -0
- paperlab_space/export/latex.py +117 -0
- paperlab_space/export/repro.py +52 -0
- paperlab_space/metrics/__init__.py +66 -0
- paperlab_space/metrics/base.py +71 -0
- paperlab_space/metrics/builtin.py +231 -0
- paperlab_space/metrics/normalize.py +103 -0
- paperlab_space/prompt.py +94 -0
- paperlab_space/py.typed +0 -0
- paperlab_space/results.py +179 -0
- paperlab_space/runner/__init__.py +26 -0
- paperlab_space/runner/backend.py +199 -0
- paperlab_space/runner/estimate.py +174 -0
- paperlab_space/runner/ratelimit.py +70 -0
- paperlab_space/runner/runner.py +224 -0
- paperlab_space-0.1.0.dist-info/METADATA +155 -0
- paperlab_space-0.1.0.dist-info/RECORD +30 -0
- paperlab_space-0.1.0.dist-info/WHEEL +4 -0
- paperlab_space-0.1.0.dist-info/entry_points.txt +2 -0
- paperlab_space-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""paperlab-space: evaluate LLMs on spreadsheet data and produce paper-ready results.
|
|
2
|
+
|
|
3
|
+
>>> from paperlab_space import RunConfig, load_dataset, run, export
|
|
4
|
+
>>> data = load_dataset("data.xlsx") # doctest: +SKIP
|
|
5
|
+
>>> config = RunConfig.from_yaml("run.yaml") # doctest: +SKIP
|
|
6
|
+
>>> results = run(config, data) # doctest: +SKIP
|
|
7
|
+
>>> export.write_all(results, "results/") # doctest: +SKIP
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from paperlab_space import export
|
|
13
|
+
from paperlab_space.config import DatasetSpec, MetricSpec, ModelSpec, RetrySettings, RunConfig
|
|
14
|
+
from paperlab_space.data import ColumnMapping, Dataset, MappedDataset, list_sheets, load_dataset
|
|
15
|
+
from paperlab_space.errors import ConfigError, DataError, PaperLabError, TemplateError
|
|
16
|
+
from paperlab_space.metrics import available_metrics, register, score
|
|
17
|
+
from paperlab_space.prompt import PromptTemplate
|
|
18
|
+
from paperlab_space.results import MetricResult, ModelRun, RowOutput, RunResults
|
|
19
|
+
from paperlab_space.runner import LiteLLMBackend, Progress, arun, estimate, run
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ColumnMapping",
|
|
23
|
+
"ConfigError",
|
|
24
|
+
"DataError",
|
|
25
|
+
"Dataset",
|
|
26
|
+
"DatasetSpec",
|
|
27
|
+
"LiteLLMBackend",
|
|
28
|
+
"MappedDataset",
|
|
29
|
+
"MetricResult",
|
|
30
|
+
"MetricSpec",
|
|
31
|
+
"ModelRun",
|
|
32
|
+
"ModelSpec",
|
|
33
|
+
"PaperLabError",
|
|
34
|
+
"Progress",
|
|
35
|
+
"PromptTemplate",
|
|
36
|
+
"RetrySettings",
|
|
37
|
+
"RowOutput",
|
|
38
|
+
"RunConfig",
|
|
39
|
+
"RunResults",
|
|
40
|
+
"TemplateError",
|
|
41
|
+
"__version__",
|
|
42
|
+
"arun",
|
|
43
|
+
"available_metrics",
|
|
44
|
+
"estimate",
|
|
45
|
+
"export",
|
|
46
|
+
"list_sheets",
|
|
47
|
+
"load_dataset",
|
|
48
|
+
"register",
|
|
49
|
+
"run",
|
|
50
|
+
"score",
|
|
51
|
+
]
|
paperlab_space/_cli.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""The ``paperlab-space`` command (needs the extra: ``pip install 'paperlab-space[cli]'``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import yaml
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeRemainingColumn
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from paperlab_space import __version__
|
|
15
|
+
from paperlab_space.config import RunConfig
|
|
16
|
+
from paperlab_space.data import ColumnMapping, list_sheets, load_dataset
|
|
17
|
+
from paperlab_space.errors import PaperLabError
|
|
18
|
+
from paperlab_space.export import write_all
|
|
19
|
+
from paperlab_space.metrics import available_metrics
|
|
20
|
+
from paperlab_space.results import RunResults
|
|
21
|
+
from paperlab_space.runner import Progress as RunProgress
|
|
22
|
+
from paperlab_space.runner import estimate, prepare_data, run
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
help="Evaluate LLMs on spreadsheet data and get paper-ready results.",
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
# emoji=False: model IDs like `x:free:` must not turn into emoji.
|
|
30
|
+
console = Console(emoji=False)
|
|
31
|
+
err = Console(stderr=True, emoji=False)
|
|
32
|
+
|
|
33
|
+
_EXPECTED_HINTS = ("expected", "gold", "answer", "label", "reference", "target", "output", "ref")
|
|
34
|
+
_ID_HINTS = ("id", "row_id", "index", "no", "no.", "#")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fail(exc: Exception) -> typer.Exit:
|
|
38
|
+
err.print(f"[bold red]Error:[/] {exc}")
|
|
39
|
+
return typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback(invoke_without_command=True)
|
|
43
|
+
def _main(
|
|
44
|
+
version: Annotated[bool, typer.Option("--version", help="Show the version and exit.")] = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
if version:
|
|
47
|
+
console.print(f"paperlab-space {__version__}")
|
|
48
|
+
raise typer.Exit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def init(
|
|
53
|
+
data: Annotated[Path, typer.Argument(help="Excel or CSV file to evaluate on.")],
|
|
54
|
+
output: Annotated[Path, typer.Option("--output", "-o", help="Config file to write.")] = Path(
|
|
55
|
+
"run.yaml"
|
|
56
|
+
),
|
|
57
|
+
sheet: Annotated[str | None, typer.Option(help="Sheet name for Excel files.")] = None,
|
|
58
|
+
force: Annotated[bool, typer.Option(help="Overwrite an existing config.")] = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Write a starter run config for DATA, guessing the column roles."""
|
|
61
|
+
try:
|
|
62
|
+
dataset = load_dataset(data, sheet)
|
|
63
|
+
except PaperLabError as exc:
|
|
64
|
+
raise _fail(exc) from exc
|
|
65
|
+
if output.exists() and not force:
|
|
66
|
+
raise _fail(FileExistsError(f"{output} exists; use --force to overwrite."))
|
|
67
|
+
|
|
68
|
+
columns = dataset.columns
|
|
69
|
+
lower = {c.lower(): c for c in columns}
|
|
70
|
+
id_col = next((lower[h] for h in _ID_HINTS if h in lower), None)
|
|
71
|
+
expected = next(
|
|
72
|
+
(c for c in columns if any(h in c.lower() for h in _EXPECTED_HINTS) and c != id_col), None
|
|
73
|
+
)
|
|
74
|
+
inputs = [c for c in columns if c not in (id_col, expected)][:1] or columns[:1]
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
relative = data.resolve().relative_to(output.resolve().parent)
|
|
78
|
+
except ValueError:
|
|
79
|
+
relative = data.resolve()
|
|
80
|
+
config: dict[str, object] = {
|
|
81
|
+
"dataset": {"path": str(relative), "sheet": dataset.sheet},
|
|
82
|
+
"mapping": {"input": inputs, "context": [], "expected": expected, "id": id_col},
|
|
83
|
+
"prompt": f"{{{inputs[0]}}}",
|
|
84
|
+
"models": ["openrouter/nvidia/nemotron-3-super-120b-a12b:free", "gemini/gemini-2.5-flash"],
|
|
85
|
+
"metrics": ["exact_match"] if expected else [],
|
|
86
|
+
"seed": 42,
|
|
87
|
+
}
|
|
88
|
+
header = (
|
|
89
|
+
f"# PaperLab run config for {data.name}\n"
|
|
90
|
+
f"# Columns: {', '.join(columns)}\n"
|
|
91
|
+
"# Edit the mapping, prompt, models, and metrics, then run:\n"
|
|
92
|
+
f"# paperlab-space estimate {output.name}\n"
|
|
93
|
+
f"# paperlab-space run {output.name}\n"
|
|
94
|
+
f"# Metrics: {', '.join(sorted(available_metrics()))}\n"
|
|
95
|
+
"# API keys are read from OPENROUTER_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, ...\n"
|
|
96
|
+
)
|
|
97
|
+
output.write_text(
|
|
98
|
+
header + yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8"
|
|
99
|
+
)
|
|
100
|
+
console.print(f"Wrote [bold]{output}[/] (input: {inputs}, expected: {expected}, id: {id_col})")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def validate(
|
|
105
|
+
data: Annotated[Path, typer.Argument(help="Excel or CSV file.")],
|
|
106
|
+
sheet: Annotated[str | None, typer.Option(help="Sheet name for Excel files.")] = None,
|
|
107
|
+
input: Annotated[
|
|
108
|
+
list[str] | None, typer.Option("--input", "-i", help="Input column(s).")
|
|
109
|
+
] = None,
|
|
110
|
+
expected: Annotated[str | None, typer.Option("--expected", "-e")] = None,
|
|
111
|
+
id: Annotated[str | None, typer.Option("--id")] = None,
|
|
112
|
+
delimiter: Annotated[str | None, typer.Option(help="Separator for multiple answers.")] = None,
|
|
113
|
+
preview: Annotated[int, typer.Option(help="Rows to preview as JSONL.")] = 3,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Check a data file for problems and preview the converted JSONL."""
|
|
116
|
+
try:
|
|
117
|
+
dataset = load_dataset(data, sheet)
|
|
118
|
+
console.print(
|
|
119
|
+
f"[bold]{data.name}[/]: {len(dataset)} rows, sheet {dataset.sheet!r}"
|
|
120
|
+
if dataset.sheet
|
|
121
|
+
else f"[bold]{data.name}[/]: {len(dataset)} rows"
|
|
122
|
+
)
|
|
123
|
+
if data.suffix.lower() in (".xlsx", ".xlsm"):
|
|
124
|
+
console.print(f"Sheets: {', '.join(list_sheets(data))}")
|
|
125
|
+
console.print(f"Columns: {', '.join(dataset.columns)}")
|
|
126
|
+
if not input:
|
|
127
|
+
console.print("Pass --input (and --expected) to check a column mapping.")
|
|
128
|
+
return
|
|
129
|
+
mapping = ColumnMapping(input=input, expected=expected, id=id, answer_delimiter=delimiter)
|
|
130
|
+
mapped = dataset.map(mapping)
|
|
131
|
+
except (PaperLabError, ValueError) as exc:
|
|
132
|
+
raise _fail(exc) from exc
|
|
133
|
+
report = mapped.validate()
|
|
134
|
+
console.print(str(report), markup=False, style="red" if not report.ok else None)
|
|
135
|
+
if preview:
|
|
136
|
+
console.print(f"\nFirst {min(preview, len(mapped))} records:")
|
|
137
|
+
for line in mapped.to_jsonl().splitlines()[:preview]:
|
|
138
|
+
console.print(line, markup=False)
|
|
139
|
+
if not report.ok:
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command("estimate")
|
|
144
|
+
def estimate_cmd(
|
|
145
|
+
config: Annotated[Path, typer.Argument(help="Run config (YAML).")],
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Estimate tokens, cost, and time for a run without calling any model."""
|
|
148
|
+
try:
|
|
149
|
+
cfg = RunConfig.from_yaml(config)
|
|
150
|
+
console.print(str(estimate(cfg, prepare_data(cfg, None))), markup=False)
|
|
151
|
+
except PaperLabError as exc:
|
|
152
|
+
raise _fail(exc) from exc
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command("run")
|
|
156
|
+
def run_cmd(
|
|
157
|
+
config: Annotated[Path, typer.Argument(help="Run config (YAML).")],
|
|
158
|
+
out: Annotated[Path | None, typer.Option("--out", "-o", help="Output folder.")] = None,
|
|
159
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation.")] = False,
|
|
160
|
+
resume: Annotated[
|
|
161
|
+
Path | None, typer.Option(help="results.json of an interrupted run to continue.")
|
|
162
|
+
] = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Run an evaluation and write Excel, LaTeX, YAML, and JSON results."""
|
|
165
|
+
out = out or config.parent / f"results-{config.stem}"
|
|
166
|
+
try:
|
|
167
|
+
cfg = RunConfig.from_yaml(config)
|
|
168
|
+
data = prepare_data(cfg, None)
|
|
169
|
+
previous = RunResults.load_json(resume) if resume else None
|
|
170
|
+
except PaperLabError as exc:
|
|
171
|
+
raise _fail(exc) from exc
|
|
172
|
+
|
|
173
|
+
console.print(str(estimate(cfg, data)), markup=False)
|
|
174
|
+
if not yes and not typer.confirm("Start the run?", default=True):
|
|
175
|
+
raise typer.Exit()
|
|
176
|
+
|
|
177
|
+
with Progress(
|
|
178
|
+
TextColumn("{task.description}"),
|
|
179
|
+
BarColumn(),
|
|
180
|
+
MofNCompleteColumn(),
|
|
181
|
+
TextColumn("[red]{task.fields[failed]} failed"),
|
|
182
|
+
TimeRemainingColumn(),
|
|
183
|
+
console=console,
|
|
184
|
+
) as bar:
|
|
185
|
+
tasks = {m.name: bar.add_task(m.name, total=len(data), failed=0) for m in cfg.models}
|
|
186
|
+
|
|
187
|
+
def on_progress(p: RunProgress) -> None:
|
|
188
|
+
bar.update(tasks[p.model], completed=p.done, failed=p.failed)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
results = run(cfg, data, on_progress=on_progress, previous=previous)
|
|
192
|
+
except PaperLabError as exc:
|
|
193
|
+
raise _fail(exc) from exc
|
|
194
|
+
|
|
195
|
+
paths = write_all(results, out)
|
|
196
|
+
_print_scores(results)
|
|
197
|
+
failed = sum(r.n_failed for r in results.runs.values())
|
|
198
|
+
if failed:
|
|
199
|
+
console.print(
|
|
200
|
+
f"[yellow]{failed} request(s) failed.[/] Re-run with "
|
|
201
|
+
f"--resume {paths['json']} to retry only those rows."
|
|
202
|
+
)
|
|
203
|
+
console.print(
|
|
204
|
+
f"Results written to [bold]{out}[/]: " + ", ".join(p.name for p in paths.values())
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def metrics() -> None:
|
|
210
|
+
"""List the available metrics."""
|
|
211
|
+
table = Table("Metric", "Needs answer key", "Description")
|
|
212
|
+
for name, cls in sorted(available_metrics().items()):
|
|
213
|
+
table.add_row(name, "yes" if cls.requires_reference else "no", cls.description)
|
|
214
|
+
console.print(table)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _print_scores(results: RunResults) -> None:
|
|
218
|
+
keys = [s.key for s in results.config.metrics]
|
|
219
|
+
table = Table("Model", *keys, "Failed", "Cost (USD)")
|
|
220
|
+
for name, model_run in results.runs.items():
|
|
221
|
+
scores = results.scores.get(name, {})
|
|
222
|
+
cells = [
|
|
223
|
+
f"{scores[k].value:.4f}" if k in scores and scores[k].value is not None else "--"
|
|
224
|
+
for k in keys
|
|
225
|
+
]
|
|
226
|
+
cost = model_run.cost_usd
|
|
227
|
+
table.add_row(
|
|
228
|
+
name, *cells, str(model_run.n_failed), "--" if cost is None else f"{cost:.4f}"
|
|
229
|
+
)
|
|
230
|
+
console.print(table)
|
paperlab_space/cli.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Entry point for the ``paperlab-space`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
try:
|
|
10
|
+
from paperlab_space._cli import app
|
|
11
|
+
except ImportError as exc:
|
|
12
|
+
if getattr(exc, "name", None) not in ("typer", "rich"):
|
|
13
|
+
raise
|
|
14
|
+
sys.stderr.write(
|
|
15
|
+
"The paperlab-space command needs the CLI extra:\n pip install 'paperlab-space[cli]'\n"
|
|
16
|
+
)
|
|
17
|
+
raise SystemExit(1) from exc
|
|
18
|
+
app()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
main()
|
paperlab_space/config.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The run configuration: one object that fully describes an evaluation.
|
|
2
|
+
|
|
3
|
+
The same :class:`RunConfig` drives a run and is written out as the reproducibility
|
|
4
|
+
config, so a run can be repeated from its exported config alone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
from paperlab_space.data.mapping import ColumnMapping
|
|
16
|
+
from paperlab_space.errors import ConfigError
|
|
17
|
+
from paperlab_space.prompt import PromptTemplate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Strict(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ModelSpec(_Strict):
|
|
25
|
+
"""A model to evaluate, addressed by its LiteLLM ID (``provider/model``)."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
label: str | None = None
|
|
29
|
+
"""Display name in tables. Defaults to the model ID without the provider prefix."""
|
|
30
|
+
temperature: float = Field(0.0, ge=0)
|
|
31
|
+
max_tokens: int | None = Field(None, gt=0)
|
|
32
|
+
top_p: float | None = Field(None, gt=0, le=1)
|
|
33
|
+
seed: int | None = None
|
|
34
|
+
requests_per_minute: float | None = Field(None, gt=0)
|
|
35
|
+
"""Override the default rate limit for this model."""
|
|
36
|
+
concurrency: int | None = Field(None, gt=0)
|
|
37
|
+
"""Override the run-level number of parallel requests for this model."""
|
|
38
|
+
params: dict[str, Any] = {}
|
|
39
|
+
"""Extra provider parameters passed through to LiteLLM."""
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="before")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _from_string(cls, value: Any) -> Any:
|
|
44
|
+
return {"id": value} if isinstance(value, str) else value
|
|
45
|
+
|
|
46
|
+
@field_validator("id")
|
|
47
|
+
@classmethod
|
|
48
|
+
def _check_id(cls, value: str) -> str:
|
|
49
|
+
value = value.strip()
|
|
50
|
+
if "/" not in value:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Model ID {value!r} needs a provider prefix, e.g. 'openrouter/{value}' or "
|
|
53
|
+
f"'gemini/{value}'."
|
|
54
|
+
)
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def provider(self) -> str:
|
|
59
|
+
return self.id.split("/", 1)[0]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def name(self) -> str:
|
|
63
|
+
"""The label used in outputs."""
|
|
64
|
+
return self.label or self.id.split("/", 1)[1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MetricSpec(_Strict):
|
|
68
|
+
"""A metric name plus its options, e.g. ``{"name": "exact_match", "ignore_case": false}``."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
label: str | None = None
|
|
72
|
+
"""Column name in outputs; needed to use the same metric twice with different options."""
|
|
73
|
+
params: dict[str, Any] = {}
|
|
74
|
+
|
|
75
|
+
@model_validator(mode="before")
|
|
76
|
+
@classmethod
|
|
77
|
+
def _from_string(cls, value: Any) -> Any:
|
|
78
|
+
if isinstance(value, str):
|
|
79
|
+
return {"name": value}
|
|
80
|
+
if isinstance(value, dict) and "name" in value and "params" not in value:
|
|
81
|
+
# Allow the flat form ``{name: f1, average: macro}``.
|
|
82
|
+
keys = {"name", "label"}
|
|
83
|
+
return {
|
|
84
|
+
**{k: v for k, v in value.items() if k in keys},
|
|
85
|
+
"params": {k: v for k, v in value.items() if k not in keys},
|
|
86
|
+
}
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def key(self) -> str:
|
|
91
|
+
return self.label or self.name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DatasetSpec(_Strict):
|
|
95
|
+
"""Where the data comes from. Optional when the dataset is passed in directly."""
|
|
96
|
+
|
|
97
|
+
path: str
|
|
98
|
+
sheet: str | int | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RetrySettings(_Strict):
|
|
102
|
+
max_attempts: int = Field(5, ge=1)
|
|
103
|
+
base_delay: float = Field(2.0, ge=0)
|
|
104
|
+
"""Seconds before the first retry; doubles on each attempt."""
|
|
105
|
+
max_delay: float = Field(60.0, ge=0)
|
|
106
|
+
timeout: float = Field(120.0, gt=0)
|
|
107
|
+
"""Seconds to wait for one model response."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RunConfig(_Strict):
|
|
111
|
+
"""Everything needed to run (and re-run) an evaluation."""
|
|
112
|
+
|
|
113
|
+
dataset: DatasetSpec | None = None
|
|
114
|
+
mapping: ColumnMapping
|
|
115
|
+
prompt: PromptTemplate
|
|
116
|
+
models: list[ModelSpec]
|
|
117
|
+
metrics: list[MetricSpec] = []
|
|
118
|
+
seed: int | None = None
|
|
119
|
+
"""Default seed for models that do not set their own."""
|
|
120
|
+
concurrency: int = Field(4, gt=0)
|
|
121
|
+
"""Parallel requests per model (rate limits still apply)."""
|
|
122
|
+
retry: RetrySettings = RetrySettings()
|
|
123
|
+
|
|
124
|
+
@model_validator(mode="after")
|
|
125
|
+
def _check(self) -> RunConfig:
|
|
126
|
+
if not self.models:
|
|
127
|
+
raise ValueError("Pick at least one model.")
|
|
128
|
+
names = [m.name for m in self.models]
|
|
129
|
+
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
130
|
+
if dupes:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Models must have distinct labels; duplicated: {', '.join(dupes)}. "
|
|
133
|
+
"Set `label` to tell them apart (e.g. the same model at two temperatures)."
|
|
134
|
+
)
|
|
135
|
+
keys = [m.key for m in self.metrics]
|
|
136
|
+
dupes = sorted({k for k in keys if keys.count(k) > 1})
|
|
137
|
+
if dupes:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Metrics must have distinct labels; duplicated: {', '.join(dupes)}. "
|
|
140
|
+
"Set `label` to use the same metric with different options."
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
self.prompt.check(self.mapping.fields)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
raise ValueError(str(exc)) from exc
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def model_seed(self, model: ModelSpec) -> int | None:
|
|
149
|
+
return model.seed if model.seed is not None else self.seed
|
|
150
|
+
|
|
151
|
+
# --- YAML -------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_dict(cls, data: dict[str, Any]) -> RunConfig:
|
|
155
|
+
"""Build from a dict; also accepts a reproducibility file (config under ``config``)."""
|
|
156
|
+
if "config" in data and "paperlab_space" in data:
|
|
157
|
+
data = data["config"]
|
|
158
|
+
try:
|
|
159
|
+
return cls.model_validate(data)
|
|
160
|
+
except ValidationError as exc:
|
|
161
|
+
raise ConfigError(_format_errors(exc)) from exc
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def from_yaml(cls, path: str | Path) -> RunConfig:
|
|
165
|
+
"""Load a YAML config. A relative dataset path is resolved against the file's folder."""
|
|
166
|
+
path = Path(path)
|
|
167
|
+
try:
|
|
168
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
169
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
170
|
+
raise ConfigError(f"Could not read {path}: {exc}") from exc
|
|
171
|
+
if not isinstance(data, dict):
|
|
172
|
+
raise ConfigError(f"{path} does not contain a YAML mapping.")
|
|
173
|
+
config = cls.from_dict(data)
|
|
174
|
+
if config.dataset is not None and not Path(config.dataset.path).is_absolute():
|
|
175
|
+
resolved = (path.parent / config.dataset.path).resolve()
|
|
176
|
+
config = config.model_copy(
|
|
177
|
+
update={"dataset": config.dataset.model_copy(update={"path": str(resolved)})}
|
|
178
|
+
)
|
|
179
|
+
return config
|
|
180
|
+
|
|
181
|
+
def to_dict(self) -> dict[str, Any]:
|
|
182
|
+
return self.model_dump(mode="json", exclude_defaults=False)
|
|
183
|
+
|
|
184
|
+
def to_yaml(self, path: str | Path | None = None) -> str:
|
|
185
|
+
text = yaml.safe_dump(self.to_dict(), allow_unicode=True, sort_keys=False)
|
|
186
|
+
if path is not None:
|
|
187
|
+
Path(path).write_text(text, encoding="utf-8")
|
|
188
|
+
return text
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _format_errors(exc: ValidationError) -> str:
|
|
192
|
+
lines = ["Invalid run configuration:"]
|
|
193
|
+
for error in exc.errors():
|
|
194
|
+
where = ".".join(str(part) for part in error["loc"]) or "config"
|
|
195
|
+
lines.append(f" {where}: {error['msg']}")
|
|
196
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Loading, mapping, and validating evaluation data."""
|
|
2
|
+
|
|
3
|
+
from paperlab_space.data.load import Dataset, list_sheets, load_dataset
|
|
4
|
+
from paperlab_space.data.mapping import ColumnMapping, MappedDataset, Record
|
|
5
|
+
from paperlab_space.data.validate import Issue, ValidationReport, validate
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ColumnMapping",
|
|
9
|
+
"Dataset",
|
|
10
|
+
"Issue",
|
|
11
|
+
"MappedDataset",
|
|
12
|
+
"Record",
|
|
13
|
+
"ValidationReport",
|
|
14
|
+
"list_sheets",
|
|
15
|
+
"load_dataset",
|
|
16
|
+
"validate",
|
|
17
|
+
]
|