perdix 0.3.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.
- perdix/__init__.py +3 -0
- perdix/__main__.py +3 -0
- perdix/core/__init__.py +6 -0
- perdix/core/ai.py +496 -0
- perdix/core/analysis.py +598 -0
- perdix/core/chunked.py +202 -0
- perdix/core/codegen.py +185 -0
- perdix/core/contract.py +124 -0
- perdix/core/demo.py +58 -0
- perdix/core/evaluate.py +264 -0
- perdix/core/export_sklearn.py +185 -0
- perdix/core/export_sql.py +309 -0
- perdix/core/gp.py +425 -0
- perdix/core/io.py +161 -0
- perdix/core/params.py +234 -0
- perdix/core/recipe.py +455 -0
- perdix/core/registry.py +348 -0
- perdix/core/search.py +418 -0
- perdix/core/sklearn_export.py +128 -0
- perdix/core/stability.py +188 -0
- perdix/core/steps/__init__.py +18 -0
- perdix/core/steps/_checks.py +50 -0
- perdix/core/steps/binning.py +133 -0
- perdix/core/steps/categories.py +174 -0
- perdix/core/steps/clean.py +429 -0
- perdix/core/steps/datetime.py +282 -0
- perdix/core/steps/encode.py +608 -0
- perdix/core/steps/frame.py +15 -0
- perdix/core/steps/generate.py +421 -0
- perdix/core/steps/geo.py +186 -0
- perdix/core/steps/impute.py +296 -0
- perdix/core/steps/join.py +242 -0
- perdix/core/steps/scale.py +128 -0
- perdix/core/steps/select.py +534 -0
- perdix/core/steps/text.py +301 -0
- perdix/core/steps/timeseries.py +943 -0
- perdix/core/steps/transform.py +260 -0
- perdix/core/suggest.py +256 -0
- perdix/core/tables.py +110 -0
- perdix/docs/ai.md +109 -0
- perdix/docs/analysis.md +173 -0
- perdix/docs/cli.md +309 -0
- perdix/docs/concepts.md +204 -0
- perdix/docs/evaluation.md +260 -0
- perdix/docs/exports.md +107 -0
- perdix/docs/extending.md +265 -0
- perdix/docs/file-formats.md +194 -0
- perdix/docs/getting-started.md +284 -0
- perdix/docs/index.md +59 -0
- perdix/docs/leakage.md +214 -0
- perdix/docs/theory/binning.md +82 -0
- perdix/docs/theory/cleaning.md +191 -0
- perdix/docs/theory/datetime.md +124 -0
- perdix/docs/theory/encoding.md +254 -0
- perdix/docs/theory/generation.md +154 -0
- perdix/docs/theory/geo.md +61 -0
- perdix/docs/theory/imputation.md +106 -0
- perdix/docs/theory/joins.md +43 -0
- perdix/docs/theory/scaling.md +78 -0
- perdix/docs/theory/selection.md +245 -0
- perdix/docs/theory/text.md +109 -0
- perdix/docs/theory/time-series.md +309 -0
- perdix/docs/theory/transforms.md +133 -0
- perdix/docs/tutorials/customer-churn.md +136 -0
- perdix/docs/tutorials/energy-demand.md +110 -0
- perdix/docs/tutorials/house-prices.md +152 -0
- perdix/docs/tutorials/index.md +37 -0
- perdix/docs/tutorials/machine-failure.md +141 -0
- perdix/docs/tutorials/retail-transactions.md +138 -0
- perdix/docs/tutorials/support-tickets.md +111 -0
- perdix/docs/tutorials/turbofan-engines.md +203 -0
- perdix/docs/web.md +58 -0
- perdix/examples/__init__.py +97 -0
- perdix/examples/cmapss.py +199 -0
- perdix/examples/customer-churn/data.csv +5001 -0
- perdix/examples/customer-churn/recipe.toml +74 -0
- perdix/examples/energy-demand/data.csv +6553 -0
- perdix/examples/energy-demand/recipe.toml +54 -0
- perdix/examples/generate.py +352 -0
- perdix/examples/house-prices/data.csv +3001 -0
- perdix/examples/house-prices/recipe.toml +84 -0
- perdix/examples/machine-failure/data.csv +4321 -0
- perdix/examples/machine-failure/recipe.toml +80 -0
- perdix/examples/retail-transactions/data.csv +5241 -0
- perdix/examples/retail-transactions/recipe.toml +71 -0
- perdix/examples/retail-transactions/transactions.csv +22677 -0
- perdix/examples/support-tickets/data.csv +4001 -0
- perdix/examples/support-tickets/recipe.toml +76 -0
- perdix/examples/turbofan-engines/recipe.toml +134 -0
- perdix/gui/__init__.py +1 -0
- perdix/gui/app.py +66 -0
- perdix/gui/assets/logo.svg +1 -0
- perdix/gui/dialogs.py +467 -0
- perdix/gui/docs_view.py +364 -0
- perdix/gui/evaluate_page.py +419 -0
- perdix/gui/icons.py +163 -0
- perdix/gui/leakwatch.py +90 -0
- perdix/gui/logo.py +29 -0
- perdix/gui/main_window.py +1342 -0
- perdix/gui/memo.py +60 -0
- perdix/gui/models.py +206 -0
- perdix/gui/pages.py +1528 -0
- perdix/gui/panels.py +1177 -0
- perdix/gui/plots.py +286 -0
- perdix/gui/session.py +666 -0
- perdix/gui/settings.py +193 -0
- perdix/gui/step_editor.py +635 -0
- perdix/gui/suggestions.py +46 -0
- perdix/gui/tasks.py +119 -0
- perdix/gui/theme.py +405 -0
- perdix/gui/widgets.py +364 -0
- perdix/main.py +633 -0
- perdix/web/__init__.py +1 -0
- perdix/web/api.py +441 -0
- perdix/web/app.html +737 -0
- perdix/web/build.py +79 -0
- perdix/web/worker.js +52 -0
- perdix-0.3.0.dist-info/METADATA +261 -0
- perdix-0.3.0.dist-info/RECORD +125 -0
- perdix-0.3.0.dist-info/WHEEL +5 -0
- perdix-0.3.0.dist-info/entry_points.txt +5 -0
- perdix-0.3.0.dist-info/licenses/COPYING +674 -0
- perdix-0.3.0.dist-info/licenses/COPYING.LESSER +165 -0
- perdix-0.3.0.dist-info/licenses/LICENSE +37 -0
- perdix-0.3.0.dist-info/top_level.txt +1 -0
perdix/__init__.py
ADDED
perdix/__main__.py
ADDED
perdix/core/__init__.py
ADDED
perdix/core/ai.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Feature proposals from Claude.
|
|
2
|
+
|
|
3
|
+
We send Claude a summary of the data as the recipe leaves it (column names,
|
|
4
|
+
types, roles and summary statistics of the training rows, plus your own
|
|
5
|
+
description) and ask for new features, each written as one Perdix step.
|
|
6
|
+
No row is sent, and nothing about the target beyond its name and kind.
|
|
7
|
+
|
|
8
|
+
Every proposal is checked before it is shown: it must be one of the allowed
|
|
9
|
+
steps, its parameters must validate, it must not use the target, and it
|
|
10
|
+
must run on a sample of the training rows and add a column. With
|
|
11
|
+
``score=True`` each surviving proposal is also scored on the held-out rows
|
|
12
|
+
(perdix.core.evaluate), so its value is measured, not claimed.
|
|
13
|
+
|
|
14
|
+
With ``rounds`` above one, each later round sends Claude its earlier
|
|
15
|
+
proposals and what became of them: why one was dropped, or how much it
|
|
16
|
+
lowered a model's validation loss on the training rows
|
|
17
|
+
(perdix.core.search.Screen). Held-out scores never go back to Claude, so
|
|
18
|
+
the held-out rows stay a fair test of every round's proposals.
|
|
19
|
+
|
|
20
|
+
What the check cannot catch is a feature that leaks by meaning, for
|
|
21
|
+
example a formula over a column recorded after the outcome. The leakage
|
|
22
|
+
report still applies to the recipe you build from the proposals.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass, field, replace
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
from perdix.core.params import Column, Columns, ParamError
|
|
36
|
+
from perdix.core.recipe import Recipe, StepConfig, TrainSplit
|
|
37
|
+
from perdix.core.registry import StepError, get, is_categorical, is_datetime, is_numeric
|
|
38
|
+
|
|
39
|
+
DEFAULT_MODEL = "claude-opus-5-5"
|
|
40
|
+
ALLOWED = ("transform.formula", "transform.math", "gen.pairwise", "gen.group_agg", "datetime.parts",
|
|
41
|
+
"ts.lag", "ts.rolling")
|
|
42
|
+
TIME_STEPS = ("ts.lag", "ts.rolling")
|
|
43
|
+
MAX_CATEGORIES = 8 # top values sent per categorical column
|
|
44
|
+
MAX_CATEGORY_UNIQUE = 50 # columns with more distinct values send no values at all
|
|
45
|
+
|
|
46
|
+
SYSTEM = (
|
|
47
|
+
"You are a feature engineering assistant inside Perdix, a desktop tool that builds features "
|
|
48
|
+
"as a recipe of steps. You see a summary of a table (no rows) and propose new features that are "
|
|
49
|
+
"likely to help a model predict the target. Each feature is exactly one Perdix step from the "
|
|
50
|
+
"allowed list, with parameters that use only existing column names. Never use the target column "
|
|
51
|
+
"as an input. Prefer features with a clear domain reason: ratios, differences, rates per unit, "
|
|
52
|
+
"group-relative values, calendar effects, lagged and smoothed history when a time column exists. "
|
|
53
|
+
"Do not repeat columns that already exist. Keep each rationale to one or two sentences."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
SCHEMA = {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {
|
|
59
|
+
"features": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"items": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"name": {"type": "string", "description": "Short name for the feature"},
|
|
65
|
+
"key": {"type": "string", "enum": list(ALLOWED)},
|
|
66
|
+
"params_json": {"type": "string", "description": "The step's parameters as a JSON object"},
|
|
67
|
+
"rationale": {"type": "string"},
|
|
68
|
+
},
|
|
69
|
+
"required": ["name", "key", "params_json", "rationale"],
|
|
70
|
+
"additionalProperties": False,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"required": ["features"],
|
|
75
|
+
"additionalProperties": False,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AIError(RuntimeError):
|
|
80
|
+
"""The proposal request could not be made or answered; the message says why."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Proposal:
|
|
85
|
+
name: str
|
|
86
|
+
key: str
|
|
87
|
+
params: dict
|
|
88
|
+
rationale: str
|
|
89
|
+
error: str = "" # why the check dropped it ("" = it passed)
|
|
90
|
+
new_columns: list[str] = field(default_factory=list)
|
|
91
|
+
gain: float | None = None # held-out score change, when scored
|
|
92
|
+
round: int = 1 # the round that proposed it
|
|
93
|
+
train_gain: float | None = None # boosting gain on the training rows (rounds > 1 only)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def ok(self) -> bool:
|
|
97
|
+
return not self.error
|
|
98
|
+
|
|
99
|
+
def to_step(self) -> StepConfig:
|
|
100
|
+
return StepConfig(self.key, dict(self.params), label=self.name[:60])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------- summary
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _round(v: Any) -> Any:
|
|
107
|
+
if isinstance(v, (float, np.floating)):
|
|
108
|
+
v = float(v)
|
|
109
|
+
return None if not np.isfinite(v) else float(f"{v:.6g}")
|
|
110
|
+
if isinstance(v, (np.integer,)):
|
|
111
|
+
return int(v)
|
|
112
|
+
return v
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def summarize(recipe: Recipe, df: pd.DataFrame,
|
|
116
|
+
fitted: tuple[pd.DataFrame, TrainSplit] | None = None) -> tuple[dict, pd.DataFrame, pd.DataFrame]:
|
|
117
|
+
"""The summary sent to Claude, the recipe's output, and its training rows.
|
|
118
|
+
|
|
119
|
+
Statistics come from the training rows only. The target appears by name
|
|
120
|
+
and kind; none of its values or statistics are included. ``fitted`` is the
|
|
121
|
+
recipe's output and training split, when already at hand."""
|
|
122
|
+
from perdix.core.analysis import infer_task
|
|
123
|
+
from perdix.core.evaluate import fit_with_split
|
|
124
|
+
|
|
125
|
+
ds = recipe.dataset
|
|
126
|
+
# The output's own split: a step that makes new rows (resampling) gives
|
|
127
|
+
# them new labels, so the split of ``df`` would pick the wrong rows.
|
|
128
|
+
out, split = fitted if fitted is not None else fit_with_split(recipe, df)
|
|
129
|
+
train = out if split.labels is None else out.loc[out.index.isin(split.labels)]
|
|
130
|
+
roles = {"target": ds.target, "time": ds.time, "group": ds.group}
|
|
131
|
+
cols = []
|
|
132
|
+
for c in train.columns:
|
|
133
|
+
if c == ds.target:
|
|
134
|
+
continue
|
|
135
|
+
s = train[c]
|
|
136
|
+
info: dict[str, Any] = {"name": str(c), "dtype": str(s.dtype),
|
|
137
|
+
"missing_pct": _round(100.0 * s.isna().mean()) if len(s) else 0.0}
|
|
138
|
+
role = next((k for k, v in roles.items() if v and v == c), "")
|
|
139
|
+
if role:
|
|
140
|
+
info["role"] = role
|
|
141
|
+
if is_datetime(s):
|
|
142
|
+
info["kind"] = "datetime"
|
|
143
|
+
if s.notna().any():
|
|
144
|
+
info["min"], info["max"] = s.min().isoformat(), s.max().isoformat()
|
|
145
|
+
elif is_numeric(s):
|
|
146
|
+
x = pd.to_numeric(s, errors="coerce").astype(float)
|
|
147
|
+
x = x[np.isfinite(x)]
|
|
148
|
+
info["kind"] = "numeric"
|
|
149
|
+
info["distinct"] = int(x.nunique())
|
|
150
|
+
if len(x):
|
|
151
|
+
q = x.quantile([0.25, 0.5, 0.75])
|
|
152
|
+
info.update(mean=_round(x.mean()), std=_round(x.std()), min=_round(x.min()),
|
|
153
|
+
p25=_round(q.iloc[0]), median=_round(q.iloc[1]), p75=_round(q.iloc[2]),
|
|
154
|
+
max=_round(x.max()))
|
|
155
|
+
elif is_categorical(s):
|
|
156
|
+
vc = s.dropna().astype(str).value_counts()
|
|
157
|
+
vc = vc.iloc[np.lexsort((vc.index.to_numpy(dtype=str), -vc.to_numpy()))] # ties by text
|
|
158
|
+
words = s.dropna().astype(str).str.split().str.len()
|
|
159
|
+
if len(vc) > MAX_CATEGORY_UNIQUE or (len(words) and words.mean() > 3):
|
|
160
|
+
info["kind"] = "text"
|
|
161
|
+
info["distinct"] = int(len(vc))
|
|
162
|
+
if len(words):
|
|
163
|
+
info["mean_words"] = _round(words.mean())
|
|
164
|
+
else:
|
|
165
|
+
info["kind"] = "categorical"
|
|
166
|
+
info["distinct"] = int(len(vc))
|
|
167
|
+
info["top"] = {str(k): _round(100.0 * v / max(len(s.dropna()), 1))
|
|
168
|
+
for k, v in vc.head(MAX_CATEGORIES).items()}
|
|
169
|
+
else:
|
|
170
|
+
info["kind"] = "other"
|
|
171
|
+
cols.append(info)
|
|
172
|
+
target = {}
|
|
173
|
+
if ds.target and ds.target in train.columns:
|
|
174
|
+
target = {"name": ds.target, "task": infer_task(train[ds.target].dropna())}
|
|
175
|
+
summary = {"training_rows": int(len(train)), "target": target,
|
|
176
|
+
"time": ds.time, "group": ds.group, "columns": cols}
|
|
177
|
+
return summary, out, train
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _allowed_steps(ds) -> list[dict]:
|
|
181
|
+
keys = [k for k in ALLOWED if ds.time or k not in TIME_STEPS]
|
|
182
|
+
docs = []
|
|
183
|
+
for key in keys:
|
|
184
|
+
step = get(key)
|
|
185
|
+
params = [{"name": prm.name, "type": type(prm).__name__, "default": prm.default,
|
|
186
|
+
**({"options": list(prm.options)} if hasattr(prm, "options") and prm.options else {}),
|
|
187
|
+
**({"help": prm.help} if prm.help else {})}
|
|
188
|
+
for prm in step.params]
|
|
189
|
+
docs.append({"key": key, "title": step.title, "description": step.description, "params": params})
|
|
190
|
+
return docs
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_prompt(summary: dict, ds, n: int, description: str) -> str:
|
|
194
|
+
rules = [
|
|
195
|
+
f"Propose at most {n} features.",
|
|
196
|
+
"Use only column names that appear in the summary; never use the target column.",
|
|
197
|
+
"params_json is a JSON object with the step's parameter names; omitted parameters take their default.",
|
|
198
|
+
"transform.formula expressions are pandas DataFrame.eval expressions: arithmetic and comparisons "
|
|
199
|
+
"over column names, with functions such as log, log1p, sqrt, exp, abs; quote names with spaces in "
|
|
200
|
+
"backticks. Give each formula a new column name in 'name'.",
|
|
201
|
+
"A Columns parameter is a JSON list of column names.",
|
|
202
|
+
]
|
|
203
|
+
if not ds.time:
|
|
204
|
+
rules.append("There is no time column, so no time-series steps.")
|
|
205
|
+
payload = {
|
|
206
|
+
"dataset_summary": summary,
|
|
207
|
+
"user_description": description or "(none given)",
|
|
208
|
+
"allowed_steps": _allowed_steps(ds),
|
|
209
|
+
"rules": rules,
|
|
210
|
+
}
|
|
211
|
+
return json.dumps(payload, indent=1, default=str)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_feedback(proposals: list[Proposal], n: int) -> str:
|
|
215
|
+
"""What became of the earlier rounds' proposals, sent after the prompt in the next round."""
|
|
216
|
+
earlier = []
|
|
217
|
+
for p in proposals:
|
|
218
|
+
entry: dict[str, Any] = {"round": p.round, "name": p.name, "key": p.key, "params": p.params}
|
|
219
|
+
if not p.ok:
|
|
220
|
+
entry["outcome"] = f"dropped: {p.error}"
|
|
221
|
+
elif p.train_gain is None:
|
|
222
|
+
entry["outcome"] = "passed the checks but could not be scored"
|
|
223
|
+
else:
|
|
224
|
+
entry["outcome"] = "helps" if p.train_gain > 0 else "adds nothing"
|
|
225
|
+
entry["training_gain"] = _round(p.train_gain)
|
|
226
|
+
entry["columns"] = p.new_columns
|
|
227
|
+
earlier.append(entry)
|
|
228
|
+
rules = [
|
|
229
|
+
"These are your proposals from earlier rounds and what became of them. Each one that passed the checks "
|
|
230
|
+
"was scored on the training rows: training_gain is the share by which it lowered the validation loss of "
|
|
231
|
+
"a model that already has every column in the summary and the feature's own inputs. Above zero helps; "
|
|
232
|
+
"zero or below adds nothing.",
|
|
233
|
+
f"Propose at most {n} new features. Do not repeat an earlier proposal or reuse a column name it made.",
|
|
234
|
+
"Build on what helped (other inputs, other groupings, related forms), leave directions that added "
|
|
235
|
+
"nothing, and fix a dropped proposal only if the reason it was dropped can be fixed.",
|
|
236
|
+
]
|
|
237
|
+
return json.dumps({"earlier_proposals": earlier, "rules": rules}, indent=1, default=str)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------- calling Claude
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _client(client):
|
|
244
|
+
if client is not None:
|
|
245
|
+
return client
|
|
246
|
+
try:
|
|
247
|
+
import anthropic
|
|
248
|
+
except ImportError:
|
|
249
|
+
raise AIError("AI proposals need the anthropic package: pip install 'perdix[ai]'") from None
|
|
250
|
+
try:
|
|
251
|
+
return anthropic.Anthropic()
|
|
252
|
+
except anthropic.AnthropicError as e:
|
|
253
|
+
raise AIError("no Anthropic credentials: set ANTHROPIC_API_KEY, or sign in with 'ant auth login'") from e
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _call(client, model: str, prompt: str, feedback: str | None = None, cache: bool = False):
|
|
257
|
+
content: Any = prompt
|
|
258
|
+
if cache:
|
|
259
|
+
# Every round sends the same prompt first: cache it, and put the feedback after it.
|
|
260
|
+
content = [{"type": "text", "text": prompt, "cache_control": {"type": "ephemeral"}}]
|
|
261
|
+
if feedback:
|
|
262
|
+
content.append({"type": "text", "text": feedback})
|
|
263
|
+
kwargs = dict(
|
|
264
|
+
model=model,
|
|
265
|
+
max_tokens=16000,
|
|
266
|
+
thinking={"type": "adaptive"},
|
|
267
|
+
output_config={"effort": "high", "format": {"type": "json_schema", "schema": SCHEMA}},
|
|
268
|
+
# A declined request is re-run on Anthropic's recommended fallback model.
|
|
269
|
+
betas=["server-side-fallback-2026-07-01"],
|
|
270
|
+
fallbacks="default",
|
|
271
|
+
system=SYSTEM,
|
|
272
|
+
messages=[{"role": "user", "content": content}],
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
import anthropic
|
|
276
|
+
except ImportError: # a test double needs no SDK
|
|
277
|
+
return client.beta.messages.create(**kwargs)
|
|
278
|
+
try:
|
|
279
|
+
return client.beta.messages.create(**kwargs)
|
|
280
|
+
except anthropic.AuthenticationError as e:
|
|
281
|
+
raise AIError("Anthropic rejected the credentials (check ANTHROPIC_API_KEY)") from e
|
|
282
|
+
except anthropic.PermissionDeniedError as e:
|
|
283
|
+
raise AIError(f"the credentials may not use {model}") from e
|
|
284
|
+
except anthropic.NotFoundError as e:
|
|
285
|
+
raise AIError(f"model {model!r} not found") from e
|
|
286
|
+
except anthropic.RateLimitError as e:
|
|
287
|
+
raise AIError("rate limited by the Anthropic API; try again in a minute") from e
|
|
288
|
+
except anthropic.BadRequestError as e:
|
|
289
|
+
raise AIError(f"the Anthropic API refused the request: {e.message}") from e
|
|
290
|
+
except anthropic.APIStatusError as e:
|
|
291
|
+
raise AIError(f"Anthropic API error {e.status_code}; try again later") from e
|
|
292
|
+
except anthropic.APIConnectionError as e:
|
|
293
|
+
raise AIError("cannot reach the Anthropic API (network)") from e
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _answer(response) -> list[dict]:
|
|
297
|
+
if response.stop_reason == "refusal":
|
|
298
|
+
raise AIError("Claude declined to propose features for this data")
|
|
299
|
+
if response.stop_reason == "max_tokens":
|
|
300
|
+
raise AIError("the answer was cut off; ask for fewer features")
|
|
301
|
+
text = next((b.text for b in response.content if getattr(b, "type", "") == "text"), "")
|
|
302
|
+
try:
|
|
303
|
+
data = json.loads(text)
|
|
304
|
+
except json.JSONDecodeError as e:
|
|
305
|
+
raise AIError("the answer was not valid JSON") from e
|
|
306
|
+
items = data.get("features") if isinstance(data, dict) else None
|
|
307
|
+
if not isinstance(items, list):
|
|
308
|
+
raise AIError("the answer held no feature list")
|
|
309
|
+
return [x for x in items if isinstance(x, dict)]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------- checking
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _uses_target(cfg: StepConfig, target: str) -> bool:
|
|
316
|
+
if not target:
|
|
317
|
+
return False
|
|
318
|
+
for spec in cfg.step.params:
|
|
319
|
+
v = cfg.params.get(spec.name)
|
|
320
|
+
if isinstance(spec, Column) and v == target:
|
|
321
|
+
return True
|
|
322
|
+
if isinstance(spec, Columns) and target in (v or []):
|
|
323
|
+
return True
|
|
324
|
+
if cfg.key == "transform.formula":
|
|
325
|
+
expr = cfg.params.get("expression", "")
|
|
326
|
+
if re.search(rf"(?<![\w`]){re.escape(target)}(?![\w`])", expr) or f"`{target}`" in expr:
|
|
327
|
+
return True
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def check(item: dict, ds, train: pd.DataFrame, sample_rows: int = 2000, seed: int = 0) -> Proposal:
|
|
332
|
+
"""Build a Proposal from one answer item, with ``error`` set if it fails a check."""
|
|
333
|
+
prop = Proposal(name=str(item.get("name", ""))[:80], key=str(item.get("key", "")), params={},
|
|
334
|
+
rationale=str(item.get("rationale", "")))
|
|
335
|
+
try:
|
|
336
|
+
params = json.loads(item.get("params_json") or "{}")
|
|
337
|
+
except json.JSONDecodeError:
|
|
338
|
+
prop.error = "parameters are not valid JSON"
|
|
339
|
+
return prop
|
|
340
|
+
if not isinstance(params, dict):
|
|
341
|
+
prop.error = "parameters are not a JSON object"
|
|
342
|
+
return prop
|
|
343
|
+
prop.params = params
|
|
344
|
+
if prop.key not in ALLOWED:
|
|
345
|
+
prop.error = f"{prop.key!r} is not an allowed step"
|
|
346
|
+
return prop
|
|
347
|
+
if prop.key in TIME_STEPS and not ds.time:
|
|
348
|
+
prop.error = "time-series step without a time column"
|
|
349
|
+
return prop
|
|
350
|
+
if prop.key == "transform.formula" and not params.get("name"):
|
|
351
|
+
params["name"] = re.sub(r"\W+", "_", prop.name).strip("_") or "feature"
|
|
352
|
+
try:
|
|
353
|
+
cfg = StepConfig(prop.key, params)
|
|
354
|
+
except (ParamError, StepError) as e:
|
|
355
|
+
prop.error = f"invalid parameters: {e}"
|
|
356
|
+
return prop
|
|
357
|
+
prop.params = cfg.params
|
|
358
|
+
if _uses_target(cfg, ds.target):
|
|
359
|
+
prop.error = "uses the target"
|
|
360
|
+
return prop
|
|
361
|
+
sample = train if len(train) <= sample_rows else train.sample(sample_rows, random_state=seed).sort_index()
|
|
362
|
+
try:
|
|
363
|
+
out = Recipe(dataset=replace(ds, fit_fraction=1.0), steps=[cfg]).fit_transform(sample)
|
|
364
|
+
except Exception as e: # noqa: BLE001 - any failure drops the proposal, with the reason
|
|
365
|
+
prop.error = f"does not run: {e}"
|
|
366
|
+
return prop
|
|
367
|
+
new = [c for c in out.columns if c not in sample.columns]
|
|
368
|
+
changed = [c for c in sample.columns if c in out.columns and not out[c].equals(sample[c])]
|
|
369
|
+
if not new:
|
|
370
|
+
prop.error = "adds no column" + (" (it overwrites existing ones)" if changed else "")
|
|
371
|
+
return prop
|
|
372
|
+
if all(out[c].isna().all() for c in new):
|
|
373
|
+
prop.error = "gives only missing values"
|
|
374
|
+
return prop
|
|
375
|
+
prop.new_columns = new
|
|
376
|
+
return prop
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _scores(recipe: Recipe, df: pd.DataFrame, proposals: list[Proposal], out: pd.DataFrame,
|
|
380
|
+
split: TrainSplit) -> None:
|
|
381
|
+
"""Held-out gain per passing proposal; left None if evaluation is unavailable.
|
|
382
|
+
Each proposal adds one step, so only that step is fitted on ``out``."""
|
|
383
|
+
from perdix.core.analysis import infer_task
|
|
384
|
+
from perdix.core.evaluate import append_step, holdout_score
|
|
385
|
+
|
|
386
|
+
ds = recipe.dataset
|
|
387
|
+
try:
|
|
388
|
+
task = infer_task(df[ds.target])
|
|
389
|
+
base = holdout_score(out, split, ds, task)
|
|
390
|
+
except Exception: # noqa: BLE001 - scoring is optional
|
|
391
|
+
return
|
|
392
|
+
for p in proposals:
|
|
393
|
+
if not p.ok:
|
|
394
|
+
continue
|
|
395
|
+
try:
|
|
396
|
+
p.gain = holdout_score(*append_step(recipe, out, split, p.to_step()), ds, task) - base
|
|
397
|
+
except Exception: # noqa: BLE001
|
|
398
|
+
p.gain = None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _inputs(cfg: StepConfig, columns) -> list[str]:
|
|
402
|
+
"""The existing columns a step reads: names among its parameters, and in a formula."""
|
|
403
|
+
names = [str(c) for c in columns]
|
|
404
|
+
known = set(names)
|
|
405
|
+
found: list[str] = []
|
|
406
|
+
for v in cfg.params.values():
|
|
407
|
+
for x in v if isinstance(v, (list, tuple)) else [v]:
|
|
408
|
+
if isinstance(x, str) and x in known and x not in found:
|
|
409
|
+
found.append(x)
|
|
410
|
+
if cfg.key == "transform.formula":
|
|
411
|
+
expr = cfg.params.get("expression", "")
|
|
412
|
+
for c in names:
|
|
413
|
+
if c not in found and (f"`{c}`" in expr or re.search(rf"(?<![\w`]){re.escape(c)}(?![\w`])", expr)):
|
|
414
|
+
found.append(c)
|
|
415
|
+
return found
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _repeat(p: Proposal, earlier: list[Proposal]) -> None:
|
|
419
|
+
"""Drop a proposal that repeats one from an earlier round, or makes a column one of them made."""
|
|
420
|
+
if not p.ok:
|
|
421
|
+
return
|
|
422
|
+
for q in earlier:
|
|
423
|
+
if q.key == p.key and q.params == p.params:
|
|
424
|
+
p.error = f"repeats {q.name!r} from round {q.round}"
|
|
425
|
+
return
|
|
426
|
+
same = [c for c in p.new_columns if q.ok and c in q.new_columns]
|
|
427
|
+
if same:
|
|
428
|
+
p.error = f"makes column {same[0]!r}, as {q.name!r} from round {q.round} does"
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _train_gains(recipe: Recipe, out: pd.DataFrame, split: TrainSplit, proposals: list[Proposal], screen) -> None:
|
|
433
|
+
"""Boosting gain on the training rows per passing proposal; left None if it cannot be computed.
|
|
434
|
+
The step is fitted on the recipe's output as it would be in the recipe."""
|
|
435
|
+
from perdix.core.evaluate import append_step
|
|
436
|
+
|
|
437
|
+
for p in proposals:
|
|
438
|
+
if not p.ok:
|
|
439
|
+
continue
|
|
440
|
+
try:
|
|
441
|
+
cfg = p.to_step()
|
|
442
|
+
res, _ = append_step(recipe, out, split, cfg)
|
|
443
|
+
rows = res.loc[screen.index]
|
|
444
|
+
p.train_gain = float(screen.gain(rows[p.new_columns], rows[_inputs(cfg, out.columns)]))
|
|
445
|
+
except Exception: # noqa: BLE001 - a proposal without a gain still goes back, as unscored
|
|
446
|
+
p.train_gain = None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def propose(recipe: Recipe, df: pd.DataFrame, *, n: int = 8, description: str = "", model: str | None = None,
|
|
450
|
+
client=None, score: bool = False, sample_rows: int = 2000, rounds: int = 1, seed: int = 0,
|
|
451
|
+
fitted: tuple[pd.DataFrame, TrainSplit] | None = None) -> list[Proposal]:
|
|
452
|
+
"""Ask Claude for up to ``n`` features per round, checked; passing proposals come first.
|
|
453
|
+
|
|
454
|
+
Each round after the first sends what became of the earlier proposals
|
|
455
|
+
(``build_feedback``): Claude can build on what helped and leave what did
|
|
456
|
+
not. That needs a target, since the feedback is a gain on the training rows."""
|
|
457
|
+
if n < 1:
|
|
458
|
+
raise ValueError("ask for at least one feature")
|
|
459
|
+
if rounds < 1:
|
|
460
|
+
raise ValueError("run at least one round")
|
|
461
|
+
ds = recipe.dataset
|
|
462
|
+
if rounds > 1 and (not ds.target or ds.target not in df.columns):
|
|
463
|
+
raise ValueError("more than one round needs a target column (set it under Dataset): the feedback is "
|
|
464
|
+
"each feature's gain on the training rows")
|
|
465
|
+
from perdix.core.evaluate import fit_with_split
|
|
466
|
+
|
|
467
|
+
out, split = fitted if fitted is not None else fit_with_split(recipe, df)
|
|
468
|
+
summary, out, train = summarize(recipe, df, (out, split))
|
|
469
|
+
prompt = build_prompt(summary, ds, n, description)
|
|
470
|
+
client = _client(client)
|
|
471
|
+
screen = None
|
|
472
|
+
if rounds > 1:
|
|
473
|
+
from perdix.core.analysis import infer_task
|
|
474
|
+
from perdix.core.search import Screen, training_sample
|
|
475
|
+
|
|
476
|
+
screen = Screen(training_sample(train, ds.target, 20000, seed), ds.context(), infer_task(df[ds.target]),
|
|
477
|
+
seed=seed, groups=ds.group if ds.split == "group" else "")
|
|
478
|
+
proposals: list[Proposal] = []
|
|
479
|
+
for r in range(1, rounds + 1):
|
|
480
|
+
feedback = build_feedback(proposals, n) if proposals else None
|
|
481
|
+
items = _answer(_call(client, model or DEFAULT_MODEL, prompt, feedback, cache=rounds > 1))[:n]
|
|
482
|
+
batch = [check(it, ds, train, sample_rows) for it in items]
|
|
483
|
+
for p in batch:
|
|
484
|
+
p.round = r
|
|
485
|
+
_repeat(p, proposals)
|
|
486
|
+
if screen is not None:
|
|
487
|
+
_train_gains(recipe, out, split, batch, screen)
|
|
488
|
+
proposals += batch
|
|
489
|
+
if score:
|
|
490
|
+
_scores(recipe, df, proposals, out, split)
|
|
491
|
+
|
|
492
|
+
def rank(p: Proposal):
|
|
493
|
+
g = p.gain if p.gain is not None else p.train_gain
|
|
494
|
+
return (not p.ok, -(g or 0.0))
|
|
495
|
+
|
|
496
|
+
return sorted(proposals, key=rank)
|