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.
Files changed (125) hide show
  1. perdix/__init__.py +3 -0
  2. perdix/__main__.py +3 -0
  3. perdix/core/__init__.py +6 -0
  4. perdix/core/ai.py +496 -0
  5. perdix/core/analysis.py +598 -0
  6. perdix/core/chunked.py +202 -0
  7. perdix/core/codegen.py +185 -0
  8. perdix/core/contract.py +124 -0
  9. perdix/core/demo.py +58 -0
  10. perdix/core/evaluate.py +264 -0
  11. perdix/core/export_sklearn.py +185 -0
  12. perdix/core/export_sql.py +309 -0
  13. perdix/core/gp.py +425 -0
  14. perdix/core/io.py +161 -0
  15. perdix/core/params.py +234 -0
  16. perdix/core/recipe.py +455 -0
  17. perdix/core/registry.py +348 -0
  18. perdix/core/search.py +418 -0
  19. perdix/core/sklearn_export.py +128 -0
  20. perdix/core/stability.py +188 -0
  21. perdix/core/steps/__init__.py +18 -0
  22. perdix/core/steps/_checks.py +50 -0
  23. perdix/core/steps/binning.py +133 -0
  24. perdix/core/steps/categories.py +174 -0
  25. perdix/core/steps/clean.py +429 -0
  26. perdix/core/steps/datetime.py +282 -0
  27. perdix/core/steps/encode.py +608 -0
  28. perdix/core/steps/frame.py +15 -0
  29. perdix/core/steps/generate.py +421 -0
  30. perdix/core/steps/geo.py +186 -0
  31. perdix/core/steps/impute.py +296 -0
  32. perdix/core/steps/join.py +242 -0
  33. perdix/core/steps/scale.py +128 -0
  34. perdix/core/steps/select.py +534 -0
  35. perdix/core/steps/text.py +301 -0
  36. perdix/core/steps/timeseries.py +943 -0
  37. perdix/core/steps/transform.py +260 -0
  38. perdix/core/suggest.py +256 -0
  39. perdix/core/tables.py +110 -0
  40. perdix/docs/ai.md +109 -0
  41. perdix/docs/analysis.md +173 -0
  42. perdix/docs/cli.md +309 -0
  43. perdix/docs/concepts.md +204 -0
  44. perdix/docs/evaluation.md +260 -0
  45. perdix/docs/exports.md +107 -0
  46. perdix/docs/extending.md +265 -0
  47. perdix/docs/file-formats.md +194 -0
  48. perdix/docs/getting-started.md +284 -0
  49. perdix/docs/index.md +59 -0
  50. perdix/docs/leakage.md +214 -0
  51. perdix/docs/theory/binning.md +82 -0
  52. perdix/docs/theory/cleaning.md +191 -0
  53. perdix/docs/theory/datetime.md +124 -0
  54. perdix/docs/theory/encoding.md +254 -0
  55. perdix/docs/theory/generation.md +154 -0
  56. perdix/docs/theory/geo.md +61 -0
  57. perdix/docs/theory/imputation.md +106 -0
  58. perdix/docs/theory/joins.md +43 -0
  59. perdix/docs/theory/scaling.md +78 -0
  60. perdix/docs/theory/selection.md +245 -0
  61. perdix/docs/theory/text.md +109 -0
  62. perdix/docs/theory/time-series.md +309 -0
  63. perdix/docs/theory/transforms.md +133 -0
  64. perdix/docs/tutorials/customer-churn.md +136 -0
  65. perdix/docs/tutorials/energy-demand.md +110 -0
  66. perdix/docs/tutorials/house-prices.md +152 -0
  67. perdix/docs/tutorials/index.md +37 -0
  68. perdix/docs/tutorials/machine-failure.md +141 -0
  69. perdix/docs/tutorials/retail-transactions.md +138 -0
  70. perdix/docs/tutorials/support-tickets.md +111 -0
  71. perdix/docs/tutorials/turbofan-engines.md +203 -0
  72. perdix/docs/web.md +58 -0
  73. perdix/examples/__init__.py +97 -0
  74. perdix/examples/cmapss.py +199 -0
  75. perdix/examples/customer-churn/data.csv +5001 -0
  76. perdix/examples/customer-churn/recipe.toml +74 -0
  77. perdix/examples/energy-demand/data.csv +6553 -0
  78. perdix/examples/energy-demand/recipe.toml +54 -0
  79. perdix/examples/generate.py +352 -0
  80. perdix/examples/house-prices/data.csv +3001 -0
  81. perdix/examples/house-prices/recipe.toml +84 -0
  82. perdix/examples/machine-failure/data.csv +4321 -0
  83. perdix/examples/machine-failure/recipe.toml +80 -0
  84. perdix/examples/retail-transactions/data.csv +5241 -0
  85. perdix/examples/retail-transactions/recipe.toml +71 -0
  86. perdix/examples/retail-transactions/transactions.csv +22677 -0
  87. perdix/examples/support-tickets/data.csv +4001 -0
  88. perdix/examples/support-tickets/recipe.toml +76 -0
  89. perdix/examples/turbofan-engines/recipe.toml +134 -0
  90. perdix/gui/__init__.py +1 -0
  91. perdix/gui/app.py +66 -0
  92. perdix/gui/assets/logo.svg +1 -0
  93. perdix/gui/dialogs.py +467 -0
  94. perdix/gui/docs_view.py +364 -0
  95. perdix/gui/evaluate_page.py +419 -0
  96. perdix/gui/icons.py +163 -0
  97. perdix/gui/leakwatch.py +90 -0
  98. perdix/gui/logo.py +29 -0
  99. perdix/gui/main_window.py +1342 -0
  100. perdix/gui/memo.py +60 -0
  101. perdix/gui/models.py +206 -0
  102. perdix/gui/pages.py +1528 -0
  103. perdix/gui/panels.py +1177 -0
  104. perdix/gui/plots.py +286 -0
  105. perdix/gui/session.py +666 -0
  106. perdix/gui/settings.py +193 -0
  107. perdix/gui/step_editor.py +635 -0
  108. perdix/gui/suggestions.py +46 -0
  109. perdix/gui/tasks.py +119 -0
  110. perdix/gui/theme.py +405 -0
  111. perdix/gui/widgets.py +364 -0
  112. perdix/main.py +633 -0
  113. perdix/web/__init__.py +1 -0
  114. perdix/web/api.py +441 -0
  115. perdix/web/app.html +737 -0
  116. perdix/web/build.py +79 -0
  117. perdix/web/worker.js +52 -0
  118. perdix-0.3.0.dist-info/METADATA +261 -0
  119. perdix-0.3.0.dist-info/RECORD +125 -0
  120. perdix-0.3.0.dist-info/WHEEL +5 -0
  121. perdix-0.3.0.dist-info/entry_points.txt +5 -0
  122. perdix-0.3.0.dist-info/licenses/COPYING +674 -0
  123. perdix-0.3.0.dist-info/licenses/COPYING.LESSER +165 -0
  124. perdix-0.3.0.dist-info/licenses/LICENSE +37 -0
  125. perdix-0.3.0.dist-info/top_level.txt +1 -0
perdix/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Perdix: feature engineering workbench for tabular and time-series data."""
2
+
3
+ __version__ = "0.3.0"
perdix/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from perdix.main import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Qt-free engine: steps, recipes, analysis and exporters.
2
+
3
+ Everything under ``perdix.core`` runs without a display, so the CLI,
4
+ the tests and the generated sklearn pipelines share one implementation
5
+ with the GUI.
6
+ """
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)