pycurate 0.4.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.
- pycurate/__init__.py +612 -0
- pycurate/compute.py +272 -0
- pycurate/core/blocks.py +271 -0
- pycurate/core/cluster.py +148 -0
- pycurate/core/diagnostics.py +292 -0
- pycurate/core/embed.py +132 -0
- pycurate/core/neighbors.py +120 -0
- pycurate/core/scores.py +112 -0
- pycurate/core/select.py +71 -0
- pycurate/data.py +343 -0
- pycurate/domains/_base.py +290 -0
- pycurate/domains/_dense.py +382 -0
- pycurate/domains/_posttrain.py +111 -0
- pycurate/domains/detection/__init__.py +729 -0
- pycurate/domains/image_text/__init__.py +457 -0
- pycurate/domains/image_text/models.py +427 -0
- pycurate/domains/labels/__init__.py +365 -0
- pycurate/domains/preference/__init__.py +123 -0
- pycurate/domains/rl/__init__.py +161 -0
- pycurate/domains/robotics/__init__.py +1251 -0
- pycurate/domains/segmentation/__init__.py +454 -0
- pycurate/domains/sft/__init__.py +240 -0
- pycurate/domains/text/__init__.py +217 -0
- pycurate/domains/text/models.py +121 -0
- pycurate/harness.py +329 -0
- pycurate/io/lance_knn.py +172 -0
- pycurate/recipe.py +335 -0
- pycurate/research/__init__.py +12 -0
- pycurate/research/fidelity.py +241 -0
- pycurate/research/loop.py +519 -0
- pycurate/research/multiplier.py +114 -0
- pycurate/research/proposers.py +351 -0
- pycurate/research/racing.py +123 -0
- pycurate/research/simulate.py +123 -0
- pycurate/research/trials.py +116 -0
- pycurate/router.py +105 -0
- pycurate/signals/__init__.py +274 -0
- pycurate/signals/adoption.py +167 -0
- pycurate/signals/discover.py +458 -0
- pycurate/signals/gate.py +253 -0
- pycurate/signals/sandbox.py +94 -0
- pycurate/signals/spec.py +449 -0
- pycurate/signals/store.py +217 -0
- pycurate-0.4.0.dist-info/METADATA +118 -0
- pycurate-0.4.0.dist-info/RECORD +48 -0
- pycurate-0.4.0.dist-info/WHEEL +5 -0
- pycurate-0.4.0.dist-info/licenses/LICENSE +202 -0
- pycurate-0.4.0.dist-info/top_level.txt +1 -0
pycurate/__init__.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""pycurate: data curation and curation auto-research. One verb that escalates:
|
|
2
|
+
|
|
3
|
+
import pycurate as pc
|
|
4
|
+
|
|
5
|
+
ds = pc.load("data/*.parquet") # any tabular source -> a Lance-backed Dataset
|
|
6
|
+
cur = pc.curate(ds) # 1. routes to the right domain, applies its default
|
|
7
|
+
cur = pc.curate(ds, eval=train_eval, budget=20) # 2. + TPE tuning on your eval, guarded by the default
|
|
8
|
+
cur = pc.curate(ds, eval=train_eval, budget=20, agent=True) # 3. + agent: literature signals, LLM-written recipes
|
|
9
|
+
cur.report # level, guard decision (pick vs default), trials
|
|
10
|
+
loader = cur.to_torch(columns=["text"], batch_size=256) # zero-copy, globally shuffled training batches
|
|
11
|
+
|
|
12
|
+
``pc.diagnose(ds)`` runs pre-flight checks; ``target_samples=`` makes curation compute-aware. ``agent=True`` is the
|
|
13
|
+
only path that calls an LLM. Building blocks: ``pc.search`` (returns a ``SearchResult``), ``pc.prepare`` (compute /
|
|
14
|
+
discover / register signals), ``pc.discover_signals``, ``pc.signals`` (catalog, provenance, protected columns),
|
|
15
|
+
``pc.harness``, ``pc.research``. Domain modules load lazily: ``pc.text``, ``pc.labels``, ``pc.image_text``,
|
|
16
|
+
``pc.sft``, ``pc.preference``, ``pc.rl``, ``pc.robotics``, ``pc.detection``, ``pc.segmentation``. Every curation decision is a :class:`Recipe` over signal
|
|
17
|
+
columns. Docs: docs/index.md (``mkdocs serve``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
from .data import Dataset
|
|
27
|
+
from .recipe import Recipe, RecipeError
|
|
28
|
+
from .router import DOMAINS, detect
|
|
29
|
+
|
|
30
|
+
__version__ = "0.4.0"
|
|
31
|
+
__all__ = ["Dataset", "Recipe", "RecipeError", "Curated", "load", "curate", "diagnose", "search", "detect", "DOMAINS",
|
|
32
|
+
"default_knob_space", "prepare", "discover_signals", "Prepared", "AgentUnavailable", "LiteratureRequired",
|
|
33
|
+
"AGENT_STRATEGY"]
|
|
34
|
+
|
|
35
|
+
_LAZY = {*DOMAINS, "core", "research", "compute", "io", "signals", "harness"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __getattr__(name: str):
|
|
39
|
+
if name == "discover_signals":
|
|
40
|
+
from .signals.discover import discover_signals
|
|
41
|
+
|
|
42
|
+
return discover_signals
|
|
43
|
+
if name == "LiteratureRequired":
|
|
44
|
+
from .signals.discover import LiteratureRequired
|
|
45
|
+
|
|
46
|
+
return LiteratureRequired
|
|
47
|
+
if name in DOMAINS:
|
|
48
|
+
return importlib.import_module(f"pycurate.domains.{name}")
|
|
49
|
+
if name in _LAZY:
|
|
50
|
+
return importlib.import_module(f"pycurate.{name}")
|
|
51
|
+
raise AttributeError(f"module 'pycurate' has no attribute {name!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load(source: Any, name: str | None = None, uri: str | None = None, overwrite: bool = False) -> Dataset:
|
|
55
|
+
"""Open or ingest a dataset.
|
|
56
|
+
|
|
57
|
+
``source``: an existing ``Dataset`` / lancedb table, a table name in ``uri``, parquet path(s) or glob,
|
|
58
|
+
a pandas / pyarrow table, a dict of arrays, or ``"hf://<repo>[:<split>]"`` for a Hugging Face dataset.
|
|
59
|
+
Ingesting into a ``name`` that already exists in ``uri`` raises unless ``overwrite=True``.
|
|
60
|
+
"""
|
|
61
|
+
import glob as _glob
|
|
62
|
+
import os
|
|
63
|
+
|
|
64
|
+
from .data import DEFAULT_URI
|
|
65
|
+
|
|
66
|
+
if isinstance(source, Dataset):
|
|
67
|
+
return source
|
|
68
|
+
if isinstance(source, str) and source.startswith("hf://"):
|
|
69
|
+
from datasets import load_dataset
|
|
70
|
+
|
|
71
|
+
repo, _, split = source[5:].partition(":")
|
|
72
|
+
hf = load_dataset(repo, split=split or "train")
|
|
73
|
+
return Dataset.create(name or repo.split("/")[-1].replace("-", "_"), hf.data.table, uri=uri or DEFAULT_URI,
|
|
74
|
+
mode="overwrite" if overwrite else "create")
|
|
75
|
+
if isinstance(source, str) and not any(ch in source for ch in "*/.") and uri is not None:
|
|
76
|
+
return Dataset.open(source, uri)
|
|
77
|
+
if isinstance(source, str) and any(ch in source for ch in "*?"):
|
|
78
|
+
pattern, source = source, sorted(_glob.glob(source))
|
|
79
|
+
if not source:
|
|
80
|
+
raise FileNotFoundError(f"pc.load: no files match {pattern!r} (relative to {os.getcwd()})")
|
|
81
|
+
elif isinstance(source, str) and "://" not in source and not os.path.exists(source):
|
|
82
|
+
raise FileNotFoundError(f"pc.load: {source!r} is not a file, a glob, a table name in uri=, or an hf:// / "
|
|
83
|
+
"object-store path")
|
|
84
|
+
if name is None and uri is None:
|
|
85
|
+
return Dataset.wrap(source)
|
|
86
|
+
try:
|
|
87
|
+
return Dataset.create(name or "dataset", source, uri=uri or DEFAULT_URI,
|
|
88
|
+
mode="overwrite" if overwrite else "create")
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
if "already exists" not in str(e):
|
|
91
|
+
raise
|
|
92
|
+
raise ValueError(f"pc.load: table {name or 'dataset'!r} already exists in {uri or DEFAULT_URI!r}. Reopen it "
|
|
93
|
+
f"with pc.load({name or 'dataset'!r}, uri={uri or DEFAULT_URI!r}), or pass overwrite=True to "
|
|
94
|
+
"ingest again") from e
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _domain(ds: Dataset, domain: str | None):
|
|
98
|
+
return importlib.import_module(f"pycurate.domains.{domain or detect(ds.schema)}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Curated:
|
|
103
|
+
"""The result of :func:`curate`: the ids to train on, the recipe that produced them, a lazy view, and ``report``
|
|
104
|
+
(``level``, ``search``, ``guard``, ``discovery``, ...: what ran and why this recipe was applied)."""
|
|
105
|
+
|
|
106
|
+
dataset: Dataset
|
|
107
|
+
recipe: Recipe
|
|
108
|
+
ids: Any
|
|
109
|
+
domain: str
|
|
110
|
+
report: dict = field(default_factory=dict)
|
|
111
|
+
|
|
112
|
+
def __len__(self) -> int:
|
|
113
|
+
return len(self.ids)
|
|
114
|
+
|
|
115
|
+
def view(self, columns: list[str] | None = None):
|
|
116
|
+
"""Zero-copy ``Permutation`` over the base table (iterate, index, or hand to a torch DataLoader)."""
|
|
117
|
+
if len(self.ids) == 0:
|
|
118
|
+
raise ValueError(f"{self!r} selected no rows; nothing to view (loosen the recipe's where / keep)")
|
|
119
|
+
return self.dataset.permutation(self.ids, columns=columns)
|
|
120
|
+
|
|
121
|
+
def to_torch(self, columns: list[str] | None = None, batch_size: int = 256, num_workers: int = 0, **loader_kw):
|
|
122
|
+
"""A ``torch.utils.data.DataLoader`` over :meth:`view`: batches are dicts of column -> values, read straight
|
|
123
|
+
from the base table in the curated (globally shuffled) order; nothing is copied. ``columns``: what to read
|
|
124
|
+
(default: all; name only what you train on). Worker processes use ``spawn`` (Lance is not fork-safe)."""
|
|
125
|
+
try:
|
|
126
|
+
from torch.utils.data import DataLoader
|
|
127
|
+
except ImportError as e:
|
|
128
|
+
raise ImportError("Curated.to_torch needs PyTorch: pip install torch (or iterate cur.view() yourself)") from e
|
|
129
|
+
if num_workers and "multiprocessing_context" not in loader_kw:
|
|
130
|
+
loader_kw["multiprocessing_context"] = "spawn"
|
|
131
|
+
return DataLoader(self.view(columns=columns), batch_size=batch_size, num_workers=num_workers, **loader_kw)
|
|
132
|
+
|
|
133
|
+
def to_arrow(self, columns: list[str] | None = None):
|
|
134
|
+
"""Materialise the curated rows in training order (a copy; prefer :meth:`view` for training)."""
|
|
135
|
+
return self.dataset.lance().take(_offsets(self.dataset, self.ids), columns=columns)
|
|
136
|
+
|
|
137
|
+
def to_pandas(self, columns: list[str] | None = None):
|
|
138
|
+
"""Materialise the curated rows as a pandas DataFrame (a copy; for inspection)."""
|
|
139
|
+
return self.to_arrow(columns).to_pandas()
|
|
140
|
+
|
|
141
|
+
def mask(self):
|
|
142
|
+
"""Boolean array over ``id`` (``mask()[i]`` is True if row ``i`` was kept at least once)."""
|
|
143
|
+
import numpy as np
|
|
144
|
+
|
|
145
|
+
m = np.zeros(int(self.dataset.ids().max()) + 1, bool)
|
|
146
|
+
m[np.asarray(self.ids, dtype=np.int64)] = True
|
|
147
|
+
return m
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"Curated({len(self):,} of {len(self.dataset):,} rows, domain={self.domain!r}, {self.recipe})"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _offsets(ds: Dataset, ids):
|
|
154
|
+
"""Row offsets (positions in scan order) for ``id`` values, for ``LanceDataset.take``."""
|
|
155
|
+
import numpy as np
|
|
156
|
+
|
|
157
|
+
all_ids = ds.lance().to_table(columns=["id"]).column("id").to_numpy()
|
|
158
|
+
pos = np.empty(all_ids.max() + 1, np.int64)
|
|
159
|
+
pos[all_ids] = np.arange(len(all_ids))
|
|
160
|
+
return pos[np.asarray(ids)]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
AGENT_STRATEGY = "agent+diag" # agent=True's recipe proposer: the LLM agent + the diagnose report (benchmark headline)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def curate(data: Any, keep: float | int | None = None, recipe: Recipe | dict | str | None = None,
|
|
167
|
+
domain: str | None = None, signals: bool | list[str] | tuple = True, seed: int = 0,
|
|
168
|
+
target_samples: float | None = None, cap_passes: float | None = None, *, eval: Callable | None = None,
|
|
169
|
+
budget: int = 20, agent: bool = False, strategy: str | None = None, trials: str | None = None,
|
|
170
|
+
guard: str | None = "auto", guard_margin: float = 0.0, subpool: int | None = None, goal: str = "",
|
|
171
|
+
harness: Any = None, offline: bool = False, allow_code: bool = False, **opts) -> Curated:
|
|
172
|
+
"""Curate ``data`` in one call and return a :class:`Curated` (ids + recipe + lazy view). One verb, three levels:
|
|
173
|
+
|
|
174
|
+
cur = pc.curate(ds) # 1. default: domain recipe, no LLM, no training
|
|
175
|
+
cur = pc.curate(ds, eval=train_eval, budget=20) # 2. tuned: TPE on your eval, guarded by the default
|
|
176
|
+
cur = pc.curate(ds, eval=train_eval, budget=20, agent=True) # 3. agent: literature signals + LLM recipes, guarded
|
|
177
|
+
cur.report # level, guard decision, trials, discovery
|
|
178
|
+
|
|
179
|
+
**Level 1 (default).** Route to a domain (``domain=`` overrides :func:`detect`), compute the domain's default
|
|
180
|
+
signals as columns (existing columns are reused; ``signals=False`` skips this, ``signals=["clip_score"]`` computes
|
|
181
|
+
only those), and apply ``recipe`` (a :class:`Recipe`, its dict form, or a SQL ``where`` string) or the domain's
|
|
182
|
+
default recipe at ``keep``. The default is the strongest established public method for the domain (image_text:
|
|
183
|
+
DFN top 30% + SemDeDup; text: DCLM fastText top 10% + SemDeDup; sft: reward-model top 50%; ...). Model-backed
|
|
184
|
+
scores run only where they can (DFN needs a GPU; nothing downloads under ``HF_HUB_OFFLINE``); otherwise the
|
|
185
|
+
domain falls back to the next-best signal, logs why, and records it in ``recipe.notes`` / ``report["default"]``.
|
|
186
|
+
|
|
187
|
+
**Level 2 (tuned): ``eval=``.** Runs :func:`search` (``strategy="tpe"`` unless given; ``budget`` trials) with the
|
|
188
|
+
guard on: the domain default (at ``keep``) is evaluated as a trial and the search's pick is applied only if it
|
|
189
|
+
beats the default on the validation metric your eval returns (``heldout`` / ``val``; never shown to the search) by
|
|
190
|
+
more than ``guard_margin``; otherwise the default is applied. ``eval`` has :func:`search`'s objective contract:
|
|
191
|
+
``eval(recipe, ids)`` or ``eval(recipe, ids, device)`` returns a float or a dict with ``score`` (higher is better)
|
|
192
|
+
and ideally ``heldout``. Without a validation key the pick is unchecked: one warning, and
|
|
193
|
+
``report["guard"]["decision"] == "unavailable"``. ``guard=None`` turns the guard off. ``subpool=N`` runs every trial
|
|
194
|
+
on the ``id % N = 0`` sub-pool (ratio-matched proxy; ``pc.research.matched_subpool``) and applies the pick to the
|
|
195
|
+
full pool. ``trials``: where every trial is recorded (default ``<uri>/_trials/<table>-<time>-<tag>.jsonl``).
|
|
196
|
+
|
|
197
|
+
**Level 3 (agent): ``eval=`` + ``agent=True``** (opt-in; calls an LLM). Before the search an agent surveys the
|
|
198
|
+
literature and proposes new signals (:func:`prepare` with ``discover=True``; written for review, run only with
|
|
199
|
+
``allow_code=True``); then the agent proposes recipes (``strategy="agent+diag"``: it also sees :func:`diagnose`),
|
|
200
|
+
guarded by the default as in level 2. ``harness`` / ``offline`` / ``goal``: see :func:`search`.
|
|
201
|
+
``agent=True`` **without** ``eval=`` runs the discovery step only and applies the default recipe: no recipe search
|
|
202
|
+
runs (there is nothing to compare recipes on); ``report["search"]`` is None and ``report["note"]`` says so.
|
|
203
|
+
|
|
204
|
+
``cur.report``: ``level`` ("default" / "tuned" / "agent"), ``search`` (strategy, name, trials location, trial
|
|
205
|
+
count, pick and default search scores, applied = "pick" or "default"), ``guard`` (the decision dict),
|
|
206
|
+
``discovery`` (agent mode), ``default`` (fallback notes), ``budget`` (with ``target_samples``).
|
|
207
|
+
|
|
208
|
+
``keep``: fraction in (0, 1] or a row count; ``None`` lets the domain choose. ``opts`` go to the domain's
|
|
209
|
+
``prepare`` / ``default_recipe`` (e.g. ``devices="cpu"``, ``k=20``, ``score="clip_l14"``, ``dfn=False``) and, with
|
|
210
|
+
``eval=``, to :func:`search` (e.g. ``devices=[0, 1]``, ``batch``, ``task``, ``warm_start``).
|
|
211
|
+
``target_samples`` (opt-in): the training budget in samples seen; ``report["budget"]`` = passes over the unique
|
|
212
|
+
kept rows, with a ``UserWarning`` above 8 passes. ``cap_passes``: raise ``keep`` to
|
|
213
|
+
``ceil(target_samples / cap_passes)`` rows when the recipe keeps fewer (never lowers it).
|
|
214
|
+
|
|
215
|
+
cur = pc.curate(ds, keep=0.3)
|
|
216
|
+
cur = pc.curate(ds, recipe="lang = 'en'", keep=0.5, signals=False)
|
|
217
|
+
cur = pc.curate(ds, target_samples=128e6, cap_passes=6)
|
|
218
|
+
"""
|
|
219
|
+
import dataclasses
|
|
220
|
+
import warnings
|
|
221
|
+
|
|
222
|
+
ds = load(data)
|
|
223
|
+
mod = _domain(ds, domain)
|
|
224
|
+
report: dict = {}
|
|
225
|
+
if eval is not None:
|
|
226
|
+
if recipe is not None:
|
|
227
|
+
raise ValueError("pc.curate: pass recipe= (apply it) or eval= (search for one), not both; to check your "
|
|
228
|
+
"recipe against a search, use pc.search(..., guard='auto', incumbent=recipe)")
|
|
229
|
+
res, recipe, report = _curate_search(ds, mod, eval, keep, budget, agent, strategy, signals, trials, guard,
|
|
230
|
+
guard_margin, subpool, goal, harness, offline, allow_code, seed, opts)
|
|
231
|
+
else:
|
|
232
|
+
if strategy is not None:
|
|
233
|
+
raise ValueError("pc.curate: strategy= picks a recipe search, which needs eval=")
|
|
234
|
+
report["level"] = "agent" if agent else "default"
|
|
235
|
+
if agent:
|
|
236
|
+
prep, _ = _agent_prepare(ds, mod, signals, goal, harness, offline, allow_code, opts)
|
|
237
|
+
report["discovery"] = prep.discovery
|
|
238
|
+
report["note"] = ("agent=True without eval=: literature discovery ran and the default recipe was applied; "
|
|
239
|
+
"no recipe search ran. Pass eval= (and budget=) to search recipes against your eval.")
|
|
240
|
+
else:
|
|
241
|
+
_prepare(mod, ds, signals, opts)
|
|
242
|
+
report["search"] = None
|
|
243
|
+
default = recipe is None
|
|
244
|
+
if default:
|
|
245
|
+
recipe = mod.default_recipe(ds, keep=keep, **opts)
|
|
246
|
+
elif isinstance(recipe, str):
|
|
247
|
+
recipe = Recipe(where=recipe)
|
|
248
|
+
elif isinstance(recipe, dict):
|
|
249
|
+
recipe = Recipe.from_dict(recipe)
|
|
250
|
+
else:
|
|
251
|
+
recipe = dataclasses.replace(recipe) # never mutate the caller's recipe
|
|
252
|
+
if keep is not None and recipe.keep is None:
|
|
253
|
+
recipe.keep = keep
|
|
254
|
+
if default and recipe.notes:
|
|
255
|
+
report["default"] = recipe.notes
|
|
256
|
+
recipe.validate(ds) # fail fast with the missing columns named, before any rows are read
|
|
257
|
+
if target_samples:
|
|
258
|
+
from .domains._base import compute_aware
|
|
259
|
+
|
|
260
|
+
recipe, ids, report["budget"] = compute_aware(recipe, ds, target_samples, cap_passes, seed=seed)
|
|
261
|
+
else:
|
|
262
|
+
ids = recipe.select(ds, seed=seed)
|
|
263
|
+
if len(ids) == 0:
|
|
264
|
+
warnings.warn(f"{recipe} selected 0 of {len(ds):,} rows", stacklevel=2)
|
|
265
|
+
return Curated(ds, recipe, ids, mod.__name__.rsplit(".", 1)[-1], report)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _default_trials(ds) -> str:
|
|
269
|
+
"""``<uri>/_trials/<table>-<time>.jsonl`` (next to ``_discovery``): curate's searches leave a record."""
|
|
270
|
+
import os
|
|
271
|
+
import time
|
|
272
|
+
import uuid
|
|
273
|
+
|
|
274
|
+
from .signals import store
|
|
275
|
+
|
|
276
|
+
uri, name = store._location(ds)
|
|
277
|
+
root = os.path.join(uri if "://" not in uri else os.path.join(os.getcwd(), "autocurate_trials"), "_trials")
|
|
278
|
+
os.makedirs(root, exist_ok=True)
|
|
279
|
+
return os.path.join(root, f"{name}-{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}.jsonl")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _curate_search(ds, mod, eval, keep, budget, agent, strategy, signals, trials, guard, guard_margin, subpool, goal,
|
|
283
|
+
harness, offline, allow_code, seed, opts):
|
|
284
|
+
"""Levels 2 / 3 of :func:`curate`: a guarded search; returns (SearchResult, recipe to apply, report)."""
|
|
285
|
+
import warnings
|
|
286
|
+
|
|
287
|
+
strategy = strategy or (AGENT_STRATEGY if agent else "tpe")
|
|
288
|
+
trials = trials or _default_trials(ds)
|
|
289
|
+
res = search(ds, eval, budget=budget, strategy=strategy, signals=signals, trials=trials, agent=agent, goal=goal,
|
|
290
|
+
harness=harness, offline=offline, allow_code=allow_code, seed=seed, subpool=subpool,
|
|
291
|
+
guard=guard, guard_margin=guard_margin, incumbent="default", _default_keep=keep, **opts)
|
|
292
|
+
default = res.default_recipe
|
|
293
|
+
g = res.guard or {"decision": "off" if not guard else "no_trials"}
|
|
294
|
+
use_default = res.best is None or g.get("decision") in ("default", "incumbent_best")
|
|
295
|
+
if res.best is None:
|
|
296
|
+
warnings.warn(f"pc.curate: no search trial completed (see {res.trials_path}); applying the default recipe",
|
|
297
|
+
stacklevel=3)
|
|
298
|
+
recipe = default if use_default else res.best_recipe_full_pool
|
|
299
|
+
sb = res.search_best
|
|
300
|
+
report = {"level": "agent" if agent else "tuned", "guard": g,
|
|
301
|
+
"search": {"strategy": res.strategy, "name": res.name, "trials": res.trials_path,
|
|
302
|
+
"n_trials": len(res.trials), "budget": budget, "subpool": res.subpool,
|
|
303
|
+
"applied": "default" if use_default else "pick",
|
|
304
|
+
"pick_score": sb and sb["score"], "default_score": g.get("default_score")}}
|
|
305
|
+
if use_default and default.notes:
|
|
306
|
+
report["default"] = default.notes
|
|
307
|
+
if res.discovery is not None:
|
|
308
|
+
report["discovery"] = res.discovery
|
|
309
|
+
if g.get("decision") == "unavailable":
|
|
310
|
+
report["search"]["note"] = ("the eval returned no validation metric (heldout / val), so the pick was not "
|
|
311
|
+
"checked against the default")
|
|
312
|
+
report["search_result"] = res # the full SearchResult (trials, proposer log, adoption)
|
|
313
|
+
return res, recipe, report
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _prepare(mod, ds, signals, opts):
|
|
317
|
+
if signals is True:
|
|
318
|
+
mod.prepare(ds, **opts)
|
|
319
|
+
elif signals:
|
|
320
|
+
mod.prepare(ds, signals=tuple([signals] if isinstance(signals, str) else signals), **opts)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class AgentUnavailable(RuntimeError):
|
|
324
|
+
"""Agent mode was requested but no usable LLM agent harness was found (the message says how to set one up)."""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
_NO_AGENT_HINT = ("Without an agent, drop agent=True: pc.search(ds, train_eval) runs TPE (no LLM, no API key) and "
|
|
328
|
+
"pc.curate(ds) applies the established default recipe.")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _agent_harness(harness: Any, offline: bool):
|
|
332
|
+
"""Resolve the harness for agent mode, failing early with what to do next."""
|
|
333
|
+
from . import harness as H
|
|
334
|
+
from .signals.discover import LiteratureRequired
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
h = H.get_harness(harness)
|
|
338
|
+
except RuntimeError as e:
|
|
339
|
+
raise AgentUnavailable(
|
|
340
|
+
"agent=True needs an LLM agent, and none was found. Do one of: install and log in to Claude Code "
|
|
341
|
+
"(the `claude` CLI), set ANTHROPIC_API_KEY or OPENAI_API_KEY (+ PYCURATE_OPENAI_MODEL), install the "
|
|
342
|
+
"Codex CLI (`codex`), or pass harness=<name or Harness>. " + _NO_AGENT_HINT) from e
|
|
343
|
+
if not h.has_web and not offline:
|
|
344
|
+
raise LiteratureRequired(
|
|
345
|
+
f"agent=True starts with a literature survey (new signals must be grounded in published methods), and "
|
|
346
|
+
f"the agent harness {h.name!r} has no web access. Use a web-capable harness (claude-code, codex, "
|
|
347
|
+
"anthropic or openai; set PYCURATE_HARNESS or pass harness=), or pass offline=True to skip the survey "
|
|
348
|
+
"(recorded in provenance and flagged in the report). " + _NO_AGENT_HINT)
|
|
349
|
+
return h
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _agent_prepare(ds, mod, signals, goal, harness, offline, allow_code, opts, discover_opts=None):
|
|
353
|
+
"""Agent mode's preparation: the domain's signals + literature-first discovery (propose-only by default)."""
|
|
354
|
+
import logging
|
|
355
|
+
import subprocess
|
|
356
|
+
|
|
357
|
+
h = _agent_harness(harness, offline)
|
|
358
|
+
names = "recommended" if signals is True else (([signals] if isinstance(signals, str) else list(signals))
|
|
359
|
+
if signals else None)
|
|
360
|
+
dom = mod.__name__.rsplit(".", 1)[-1]
|
|
361
|
+
try:
|
|
362
|
+
prep = prepare(ds, domain=dom, discover=True, goal=goal, harness=h, offline=offline, allow_code=allow_code,
|
|
363
|
+
signals=names, discover_opts=discover_opts, **opts)
|
|
364
|
+
except (subprocess.SubprocessError, TimeoutError, RuntimeError) as e:
|
|
365
|
+
if isinstance(e, AgentUnavailable):
|
|
366
|
+
raise
|
|
367
|
+
raise AgentUnavailable(f"signal discovery failed on the agent harness {h.name!r}: {e}. Check that it "
|
|
368
|
+
"works on its own (for claude-code: run `claude -p hi`; for API harnesses: the key and "
|
|
369
|
+
"network), or pass harness= to use another one. " + _NO_AGENT_HINT) from e
|
|
370
|
+
rep = prep.discovery
|
|
371
|
+
if rep is not None and not rep.allow_code and rep.candidates:
|
|
372
|
+
logging.getLogger("pycurate").warning(
|
|
373
|
+
"pycurate agent mode: %d signal proposal(s) written to %s for review and not run; pass "
|
|
374
|
+
"allow_code=True (or call pc.discover_signals(..., approve=fn)) to validate and add them",
|
|
375
|
+
len(rep.candidates), rep.out_dir)
|
|
376
|
+
return prep, h
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def diagnose(data: Any, domain: str | None = None, signals: bool | list[str] | tuple = True, **opts):
|
|
380
|
+
"""Pre-flight checks for ``data``: will curation pay off, roughly how much to keep, what could go wrong.
|
|
381
|
+
|
|
382
|
+
Domain-specific (see each domain's ``diagnose``). The checks read signal columns, so the domain's default
|
|
383
|
+
signals are computed first, exactly as :func:`curate` would (existing columns are reused; ``signals=False``
|
|
384
|
+
diagnoses only what is already there). Returns a ``Report``: ``print`` it, or ``report.to_dict()`` for what a
|
|
385
|
+
search agent sees.
|
|
386
|
+
"""
|
|
387
|
+
ds = load(data)
|
|
388
|
+
mod = _domain(ds, domain)
|
|
389
|
+
_prepare(mod, ds, signals, opts)
|
|
390
|
+
return mod.diagnose(ds, **opts)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def default_knob_space(space: dict) -> dict:
|
|
394
|
+
"""A random / TPE knob space from a domain ``search_space``: which column to rank by (either direction),
|
|
395
|
+
the keep fraction (log scale), and the group columns offered for ``balance_by`` / ``dedup_by``."""
|
|
396
|
+
numeric = [c for c in space.get("stats", {}) if not c.startswith("_")]
|
|
397
|
+
lo, hi = space.get("keep", [0.05, 1.0])
|
|
398
|
+
knobs: dict = {"score": [None, *numeric], "keep": ("float", float(lo), float(hi), "log")}
|
|
399
|
+
if numeric:
|
|
400
|
+
knobs["ascending"] = [False, True]
|
|
401
|
+
knobs["band"] = [None, [0.0, 0.9], [0.1, 1.0], [0.25, 0.75]] # score-percentile band (dropped when score=None)
|
|
402
|
+
if space.get("balance_by"):
|
|
403
|
+
knobs["balance_by"] = [None, *space["balance_by"]]
|
|
404
|
+
knobs["balance"] = ["proportional", "sqrt", "uniform"]
|
|
405
|
+
if space.get("dedup_by"):
|
|
406
|
+
knobs["dedup_by"] = [None, *space["dedup_by"]]
|
|
407
|
+
return knobs
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _accepts_device(fn: Callable) -> bool:
|
|
411
|
+
import inspect
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
params = inspect.signature(fn).parameters.values()
|
|
415
|
+
except (TypeError, ValueError):
|
|
416
|
+
return True
|
|
417
|
+
if any(p.kind in (p.VAR_POSITIONAL,) for p in params):
|
|
418
|
+
return True
|
|
419
|
+
return len([p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]) >= 3
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def search(data: Any, eval: Callable | None = None, budget: int = 20, strategy: str = "tpe",
|
|
423
|
+
domain: str | None = None, space: dict | None = None, diagnostics: bool = True, trials: str | None = None,
|
|
424
|
+
knob_space: dict | None = None, signals: bool | list[str] | tuple = True,
|
|
425
|
+
allow_unregistered: bool = False, *, agent: bool = False, goal: str = "", harness: Any = None,
|
|
426
|
+
offline: bool = False, allow_code: bool = False, objective: Callable | None = None, **opts):
|
|
427
|
+
"""Search curation recipes against your eval; returns a ``SearchResult`` (``.best_recipe``, ``.guard``, ``.trials``).
|
|
428
|
+
|
|
429
|
+
The building block under ``pc.curate(ds, eval=...)``, which runs this with the guard on and applies the pick. Use
|
|
430
|
+
it directly to inspect trials, run several searches, or choose your own strategy / incumbent / guard:
|
|
431
|
+
|
|
432
|
+
res = pc.search(ds, train_eval, budget=20) # TPE: no LLM, no API key
|
|
433
|
+
res = pc.search(ds, train_eval, budget=20, guard="auto", incumbent="default") # + the guard, as pc.curate does
|
|
434
|
+
cur = pc.curate(ds, recipe=res.best_recipe)
|
|
435
|
+
|
|
436
|
+
``eval(recipe, ids)`` or ``eval(recipe, ids, device)`` returns a float or a dict with ``score`` (higher is better)
|
|
437
|
+
and optional metrics / ``cost``; return held-out scores as ``heldout`` / ``val`` (never shown to a proposer; the
|
|
438
|
+
guard compares on them). ``objective=`` is the deprecated keyword name of ``eval``.
|
|
439
|
+
|
|
440
|
+
``strategy``: ``"tpe"`` (default; Bayesian optimisation with optuna, no LLM), ``"random"``, or (advanced; prefer
|
|
441
|
+
``agent=True``) the LLM proposers ``"agent"`` (writes recipes from the columns, statistics and every previous
|
|
442
|
+
trial) and ``"agent+diag"`` (also sees :func:`diagnose`). A bare ``strategy="agent"`` runs no discovery, no
|
|
443
|
+
default warm start and no guard.
|
|
444
|
+
|
|
445
|
+
``agent=True`` is agent mode in one switch: (1) the domain's recommended signals are computed; (2) an agent
|
|
446
|
+
surveys the literature (web access required, else pass ``offline=True``) and proposes new signals for ``goal``,
|
|
447
|
+
written to disk for review and run only with ``allow_code=True``; (3) the agent proposes recipes
|
|
448
|
+
(``strategy="agent+diag"`` unless you pass ``"agent"``), starting from the domain's default recipe as the first
|
|
449
|
+
trial. The harness is ``harness=`` (a name or a :class:`~pycurate.harness.Harness`), else
|
|
450
|
+
``PYCURATE_HARNESS``, else auto-detected (``ANTHROPIC_API_KEY``, ``claude`` CLI, ``OPENAI_API_KEY``, ``codex``
|
|
451
|
+
CLI); the same harness runs discovery and the recipe agent. ``res.discovery`` is the discovery report.
|
|
452
|
+
|
|
453
|
+
The guard (on by default in agent mode; ``guard="auto", incumbent="default"`` turns it on for any strategy): the
|
|
454
|
+
pick is returned only if it beats the incumbent (``"default"`` = the domain default recipe, or a recipe / dict) on
|
|
455
|
+
a validation metric the proposer never sees, else ``res.best`` is the incumbent; ``res.guard`` records the
|
|
456
|
+
decision and both scores, ``res.search_best`` is the best by search score. Without such a metric the pick stands
|
|
457
|
+
and one warning says so. ``guard=None`` turns it off, ``guard="<metric>"`` / ``guard_margin=`` tune it.
|
|
458
|
+
``subpool=N`` runs every trial on the ``id % N = 0`` sub-pool (a ratio-matched proxy; ``N`` from
|
|
459
|
+
``pc.research.matched_subpool``); apply ``res.best_recipe_full_pool`` to the full pool.
|
|
460
|
+
``knob_space`` (random / TPE) defaults to :func:`default_knob_space` of the domain's search space. ``trials``:
|
|
461
|
+
a ``.jsonl`` path or ``"<lancedb uri>::<table>"`` shared by concurrent workers (default: a temporary file;
|
|
462
|
+
``res.trials_path``). Domain signals are computed first, as in :func:`curate` (``signals=False`` to skip). Other
|
|
463
|
+
keyword arguments go to :func:`pycurate.research.run_search` (e.g. ``devices=[0, 1]``, ``seed``,
|
|
464
|
+
``warm_start``, ``task``) or, if it does not take them, to the domain's ``prepare`` / ``diagnose``.
|
|
465
|
+
|
|
466
|
+
The search only sees registered signal columns (catalog spec + provenance, or raw ingested data) and never a
|
|
467
|
+
protected one (:mod:`pycurate.signals`); an unregistered column raises ``SignalGateError`` unless
|
|
468
|
+
``allow_unregistered=True``, which is recorded with every trial and flagged in the result.
|
|
469
|
+
"""
|
|
470
|
+
import inspect
|
|
471
|
+
|
|
472
|
+
from .research import run_search
|
|
473
|
+
|
|
474
|
+
from .research.proposers import AgentProposer, make_proposer
|
|
475
|
+
|
|
476
|
+
if objective is not None:
|
|
477
|
+
import warnings
|
|
478
|
+
|
|
479
|
+
warnings.warn("pc.search(objective=) is deprecated; pass eval= (or the eval as the second argument)",
|
|
480
|
+
DeprecationWarning, stacklevel=2)
|
|
481
|
+
eval = eval or objective
|
|
482
|
+
if eval is None:
|
|
483
|
+
raise TypeError("pc.search needs eval: a function (recipe, ids) -> score, see help(pc.search)")
|
|
484
|
+
if agent: # the default (or "tpe") gives way to the agent; another explicit non-agent strategy is a conflict
|
|
485
|
+
if strategy not in (None, "tpe", "agent", "agent+diag", "agent_diag"):
|
|
486
|
+
raise ValueError(f"agent=True runs the LLM recipe agent, but strategy={strategy!r} was also given; drop "
|
|
487
|
+
"one of them (strategy='agent' / 'agent+diag' is allowed with agent=True)")
|
|
488
|
+
strategy = AGENT_STRATEGY if strategy in (None, "tpe") else strategy
|
|
489
|
+
strategy = strategy or "tpe" # None (from pc.curate) = the default
|
|
490
|
+
default_keep = opts.pop("_default_keep", None) # pc.curate(keep=): the incumbent default's keep
|
|
491
|
+
|
|
492
|
+
accepted = {*inspect.signature(run_search).parameters, *inspect.signature(make_proposer).parameters,
|
|
493
|
+
*inspect.signature(AgentProposer).parameters} - {"devices", "kw", "proposer_kw", "space"}
|
|
494
|
+
run_opts = {k: v for k, v in opts.items() if k in accepted}
|
|
495
|
+
dom_opts = {k: v for k, v in opts.items() if k not in run_opts}
|
|
496
|
+
devices = opts.get("devices")
|
|
497
|
+
if isinstance(devices, (str, int)): # "cpu" / "auto" / one GPU index: one worker
|
|
498
|
+
devices = [devices if devices != "auto" else None]
|
|
499
|
+
ds = load(data)
|
|
500
|
+
mod = _domain(ds, domain)
|
|
501
|
+
prep = default = None
|
|
502
|
+
if agent:
|
|
503
|
+
prep, harness = _agent_prepare(ds, mod, signals, goal, harness, offline, allow_code, dom_opts,
|
|
504
|
+
discover_opts=run_opts.get("discover_opts"))
|
|
505
|
+
else:
|
|
506
|
+
_prepare(mod, ds, signals, dom_opts)
|
|
507
|
+
inc = run_opts.get("incumbent")
|
|
508
|
+
if isinstance(inc, str) and inc != "default":
|
|
509
|
+
raise ValueError(f"incumbent={inc!r}: pass 'default' (the domain default recipe), a Recipe or a recipe dict")
|
|
510
|
+
if agent or inc == "default":
|
|
511
|
+
default = mod.default_recipe(ds, keep=default_keep, **dom_opts)
|
|
512
|
+
if default_keep is not None and default.keep is None:
|
|
513
|
+
default.keep = default_keep
|
|
514
|
+
if inc is None or inc == "default":
|
|
515
|
+
run_opts["incumbent"] = default.to_dict() # the pick must beat it on held-out (guard=)
|
|
516
|
+
if agent:
|
|
517
|
+
if "warm_start" not in run_opts: # the established default is the first trial: the search is checked against it
|
|
518
|
+
run_opts["warm_start"] = [{**default.to_dict(), "rationale": "domain default"}]
|
|
519
|
+
run_opts.setdefault("guard", "auto")
|
|
520
|
+
if harness is not None:
|
|
521
|
+
run_opts["harness"] = harness
|
|
522
|
+
from .signals.gate import gate, gate_knobs, gate_space
|
|
523
|
+
|
|
524
|
+
from .signals.adoption import extend_space
|
|
525
|
+
|
|
526
|
+
g = gate(ds, allow_unregistered, opts.get("allowed_columns"), opts.get("exclude_columns"))
|
|
527
|
+
space = gate_space(ds, space or mod.search_space(ds), allow_unregistered, g=g)
|
|
528
|
+
if opts.get("extend_signals", True): # survey-new / discovered signal columns the domain space does not list
|
|
529
|
+
space = extend_space(ds, space, g)
|
|
530
|
+
if strategy in ("random", "tpe") and knob_space is None:
|
|
531
|
+
knob_space = default_knob_space(space)
|
|
532
|
+
elif knob_space is not None:
|
|
533
|
+
knob_space = gate_knobs(knob_space, g)
|
|
534
|
+
context = mod.diagnose(ds, **dom_opts).to_dict() if diagnostics and strategy in ("agent+diag", "agent_diag") \
|
|
535
|
+
else None
|
|
536
|
+
fn = eval if _accepts_device(eval) else (lambda recipe, ids, device: eval(recipe, ids))
|
|
537
|
+
res = run_search(ds, fn, budget=budget, strategy=strategy, space=space, knob_space=knob_space, context=context,
|
|
538
|
+
trials=trials, devices=devices, allow_unregistered=allow_unregistered, **run_opts)
|
|
539
|
+
if prep is not None:
|
|
540
|
+
res.discovery = prep.discovery
|
|
541
|
+
res.default_recipe = default # the domain default Recipe when it was the incumbent, else None
|
|
542
|
+
return res
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@dataclass
|
|
546
|
+
class Prepared:
|
|
547
|
+
"""The result of :func:`prepare`: what was computed, discovered and registered on the dataset."""
|
|
548
|
+
|
|
549
|
+
dataset: Dataset
|
|
550
|
+
domain: str
|
|
551
|
+
computed: list
|
|
552
|
+
registered_base: list
|
|
553
|
+
discovery: Any = None
|
|
554
|
+
protected: list = field(default_factory=list)
|
|
555
|
+
skipped: dict = field(default_factory=dict) # recommended survey signals not computed here, with the reason
|
|
556
|
+
|
|
557
|
+
def columns(self):
|
|
558
|
+
"""Every column with its search status and source (``pc.signals.describe``)."""
|
|
559
|
+
from .signals import describe
|
|
560
|
+
|
|
561
|
+
return describe(self.dataset)
|
|
562
|
+
|
|
563
|
+
def __repr__(self) -> str:
|
|
564
|
+
disc = "" if self.discovery is None else f", discovered={self.discovery.accepted}" + \
|
|
565
|
+
(" [OFFLINE]" if self.discovery.offline else "")
|
|
566
|
+
skip = f", skipped={sorted(self.skipped)}" if self.skipped else ""
|
|
567
|
+
return (f"Prepared({self.dataset.name!r}, domain={self.domain!r}, computed={self.computed}{disc}, "
|
|
568
|
+
f"protected={self.protected}{skip})")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def prepare(data: Any, domain: str | None = None, discover: bool = False, goal: str = "", harness: Any = None,
|
|
572
|
+
offline: bool = False, allow_code: bool = False, approve: Callable | None = None, budget: int = 3,
|
|
573
|
+
signals: Any = "recommended", protect: list[str] | tuple = (), discover_opts: dict | None = None,
|
|
574
|
+
**opts) -> Prepared:
|
|
575
|
+
"""The standard pipeline step: compute the domain's recommended catalog signals (zero-copy columns with
|
|
576
|
+
provenance), optionally discover new ones, and register everything so a search can use it.
|
|
577
|
+
|
|
578
|
+
1. ``signals``: ``"recommended"`` (default; what :func:`curate` computes), a list of catalog names, or ``None``;
|
|
579
|
+
2. ``protect``: oracle / eval / label columns to protect (in addition to the defaults, e.g. ``clean_label``);
|
|
580
|
+
3. ``discover=True``: :func:`discover_signals` with the mandatory literature step on the configured harness
|
|
581
|
+
(``harness=`` / ``PYCURATE_HARNESS``; ``offline=True`` skips the survey and is recorded); running the
|
|
582
|
+
proposed code needs ``allow_code=True`` or ``approve=fn``;
|
|
583
|
+
4. raw ingested columns are registered as base data.
|
|
584
|
+
|
|
585
|
+
prep = pc.prepare(ds) # recommended signals only
|
|
586
|
+
prep = pc.prepare(ds, discover=True, goal="...", allow_code=True) # benchmarks: + agentic discovery
|
|
587
|
+
"""
|
|
588
|
+
from . import signals as S
|
|
589
|
+
|
|
590
|
+
ds = load(data)
|
|
591
|
+
dom = domain or detect(ds.schema)
|
|
592
|
+
if protect:
|
|
593
|
+
S.protect(ds, list(protect))
|
|
594
|
+
rep: dict = {}
|
|
595
|
+
computed = S.add_signals(ds, signals, domain=dom, report=rep, **opts) if signals else []
|
|
596
|
+
disc = None
|
|
597
|
+
if discover:
|
|
598
|
+
from .signals.discover import discover_signals
|
|
599
|
+
|
|
600
|
+
disc = discover_signals(ds, domain=dom, goal=goal, budget=budget, harness=harness, offline=offline,
|
|
601
|
+
allow_code=allow_code, approve=approve, **(discover_opts or {}))
|
|
602
|
+
try:
|
|
603
|
+
base = S.register_base(ds)
|
|
604
|
+
except ValueError as e: # version 1 cleaned up: raw columns must be declared explicitly
|
|
605
|
+
import logging
|
|
606
|
+
|
|
607
|
+
logging.getLogger("pycurate").warning("pycurate.prepare: %s", e)
|
|
608
|
+
base = []
|
|
609
|
+
return Prepared(ds, dom, computed, base, disc, sorted(S.protected_columns(ds)), rep.get("skipped", {}))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
curate.prepare = prepare # ``pc.curate.prepare(ds, ...)``: the same entry point, next to the call it prepares for
|