p2predict 1.1.0__tar.gz → 1.2.0__tar.gz
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.
- {p2predict-1.1.0/src/p2predict.egg-info → p2predict-1.2.0}/PKG-INFO +6 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/README.md +5 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/pyproject.toml +1 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cli/predict.py +13 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cli/train.py +111 -49
- p2predict-1.2.0/src/p2predict/feature_selection.py +285 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/hpo_training.py +3 -2
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/intervals.py +17 -10
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/json_output.py +8 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/registry.py +30 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/server.py +155 -142
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/outliers.py +108 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/preprocessing.py +54 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/trained_model_io.py +7 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/training.py +10 -5
- {p2predict-1.1.0 → p2predict-1.2.0/src/p2predict.egg-info}/PKG-INFO +6 -1
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_cli.py +244 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_feature_selection.py +21 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_io_and_evals.py +11 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_mcp.py +222 -0
- p2predict-1.1.0/src/p2predict/feature_selection.py +0 -139
- {p2predict-1.1.0 → p2predict-1.2.0}/LICENSE +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/setup.cfg +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/__init__.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/__main__.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cli/__init__.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cmdline_io.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/domain.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/explain.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/input_checks.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/__init__.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/__main__.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/conversions.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/model_evals.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/model_utils.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/plotting.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/prepare_data.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/quality.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/ui_console.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/whatif.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/SOURCES.txt +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/dependency_links.txt +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/entry_points.txt +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/requires.txt +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/top_level.txt +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_benchmarks.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_domain.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_explain.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_feature_selection_leakage.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_input_checks.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_intervals.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_json_output.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_outliers.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_plotting.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_prepare_data.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_preprocessing.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_quality.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_training.py +0 -0
- {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_whatif.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: p2predict
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Parametric price benchmarking for engineering and procurement trade-offs. Agentic first, runs on your data, stays on your machine.
|
|
5
5
|
Author: Ahmed Khalil Hafsi
|
|
6
6
|
License-Expression: LicenseRef-PolyForm-Noncommercial-1.0.0-with-Internal-Use-Exception
|
|
@@ -46,6 +46,7 @@ Provides-Extra: dev
|
|
|
46
46
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
47
47
|
Dynamic: license-file
|
|
48
48
|
|
|
49
|
+
<!-- mcp-name: io.github.ahmed-khalil-hafsi/p2predict -->
|
|
49
50
|
<div align="center">
|
|
50
51
|
|
|
51
52
|
<img src="./documentation/logo.svg" alt="P2Predict" width="240" />
|
|
@@ -262,6 +263,10 @@ P2Predict comes out of that work: the tools a procurement team actually needs to
|
|
|
262
263
|
- **Set it up with your agent:** see **[INSTALL.md](INSTALL.md)** to install, connect your AI assistant, and point it at your data.
|
|
263
264
|
- **How it works under the hood:** the models, the math, the full reference live in **[TECHNICAL.md](TECHNICAL.md)**.
|
|
264
265
|
|
|
266
|
+
## Related
|
|
267
|
+
|
|
268
|
+
**[P2CLPFD](https://github.com/ahmed-khalil-hafsi/P2CLPFD)** — the companion tool for the decision that comes next. P2Predict tells you what a part *should* cost; P2CLPFD decides *who gets the volume*: the lowest-TCO award across your suppliers under capacity, MOQ, share caps, dual-sourcing, and volume-discount rules, with the reasoning you can show in the room. Open source under the GPLv3. More at **[ahmedhafsi.com/p2clpfd](https://ahmedhafsi.com/p2clpfd/)**.
|
|
269
|
+
|
|
265
270
|
## Licensing
|
|
266
271
|
|
|
267
272
|
Source-available under the [PolyForm Noncommercial License 1.0.0, with an additional grant of permission for internal company use](LICENSE).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.ahmed-khalil-hafsi/p2predict -->
|
|
1
2
|
<div align="center">
|
|
2
3
|
|
|
3
4
|
<img src="./documentation/logo.svg" alt="P2Predict" width="240" />
|
|
@@ -214,6 +215,10 @@ P2Predict comes out of that work: the tools a procurement team actually needs to
|
|
|
214
215
|
- **Set it up with your agent:** see **[INSTALL.md](INSTALL.md)** to install, connect your AI assistant, and point it at your data.
|
|
215
216
|
- **How it works under the hood:** the models, the math, the full reference live in **[TECHNICAL.md](TECHNICAL.md)**.
|
|
216
217
|
|
|
218
|
+
## Related
|
|
219
|
+
|
|
220
|
+
**[P2CLPFD](https://github.com/ahmed-khalil-hafsi/P2CLPFD)** — the companion tool for the decision that comes next. P2Predict tells you what a part *should* cost; P2CLPFD decides *who gets the volume*: the lowest-TCO award across your suppliers under capacity, MOQ, share caps, dual-sourcing, and volume-discount rules, with the reasoning you can show in the room. Open source under the GPLv3. More at **[ahmedhafsi.com/p2clpfd](https://ahmedhafsi.com/p2clpfd/)**.
|
|
221
|
+
|
|
217
222
|
## Licensing
|
|
218
223
|
|
|
219
224
|
Source-available under the [PolyForm Noncommercial License 1.0.0, with an additional grant of permission for internal company use](LICENSE).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "p2predict"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Parametric price benchmarking for engineering and procurement trade-offs. Agentic first, runs on your data, stays on your machine."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "LicenseRef-PolyForm-Noncommercial-1.0.0-with-Internal-Use-Exception"
|
|
@@ -411,7 +411,19 @@ def _print_whatif(console, result: WhatIfResult, target_name: str) -> None:
|
|
|
411
411
|
)
|
|
412
412
|
|
|
413
413
|
|
|
414
|
-
|
|
414
|
+
class _PredictCommand(click.Command):
|
|
415
|
+
# `p2predict train` is the natural guess, but training is its own binary.
|
|
416
|
+
def parse_args(self, ctx, args):
|
|
417
|
+
if args and args[0] == "train":
|
|
418
|
+
raise click.UsageError(
|
|
419
|
+
"`p2predict` only predicts. Did you mean `p2predict-train`? "
|
|
420
|
+
"Run `p2predict-train --help` for its options.",
|
|
421
|
+
ctx=ctx,
|
|
422
|
+
)
|
|
423
|
+
return super().parse_args(ctx, args)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@click.command(cls=_PredictCommand)
|
|
415
427
|
@click.version_option(package_name="p2predict", message="%(prog)s %(version)s")
|
|
416
428
|
@click.option("-m", "--model", type=click.Path(exists=True),
|
|
417
429
|
help="Path to the trained model file (.model)")
|
|
@@ -34,6 +34,9 @@ from p2predict.outliers import (
|
|
|
34
34
|
apply_outlier_policy,
|
|
35
35
|
)
|
|
36
36
|
from p2predict.feature_selection import (
|
|
37
|
+
FeatureChoiceError,
|
|
38
|
+
choose_training_features,
|
|
39
|
+
find_auto_exclusions,
|
|
37
40
|
find_high_variation_features,
|
|
38
41
|
find_no_variation_features,
|
|
39
42
|
get_most_predictable_features,
|
|
@@ -53,6 +56,14 @@ if spinner is not None:
|
|
|
53
56
|
spinner.stop()
|
|
54
57
|
|
|
55
58
|
|
|
59
|
+
TIME_SERIES_INTERVAL_WARNING = (
|
|
60
|
+
"Likely ranges assume new rows resemble the holdout rows. Time-ordered data "
|
|
61
|
+
"is usually correlated and drifts, so a period unlike the holdout can see "
|
|
62
|
+
"coverage well below the stated level. Check coverage on a later period "
|
|
63
|
+
"before relying on the ranges."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
56
67
|
def _abort(json_mode: bool, console, code: str, message: str) -> None:
|
|
57
68
|
"""Same shape as predict.py — emit JSON error or red Rich abort."""
|
|
58
69
|
if json_mode:
|
|
@@ -118,6 +129,10 @@ def _feature_outlier_summary_block(summary: dict) -> dict:
|
|
|
118
129
|
"Default 6 preserves prior behaviour. Pass a higher number (or use -tf to pick "
|
|
119
130
|
"features explicitly) when ranking suggests more columns are predictive. "
|
|
120
131
|
"Ignored in expert mode (the user selects features interactively or via -tf).")
|
|
132
|
+
@click.option("--allow-leaky-features", is_flag=True, default=False,
|
|
133
|
+
help="Train on a -tf column even though it looks like target leakage "
|
|
134
|
+
"(a near-duplicate of the price, e.g. the price at another "
|
|
135
|
+
"quantity break). Without this, such a column aborts training.")
|
|
121
136
|
@click.option("--tune/--no-tune", default=None,
|
|
122
137
|
help="Expert mode only: run HPO on the chosen algorithm and save the tuned model.")
|
|
123
138
|
@click.option("--outliers", type=click.Choice(list(OUTLIER_POLICIES)), default="warn",
|
|
@@ -154,7 +169,7 @@ def _feature_outlier_summary_block(summary: dict) -> dict:
|
|
|
154
169
|
"Rich-formatted output. Useful for agents and scripts. "
|
|
155
170
|
"See p2predict.json_output for the schema.")
|
|
156
171
|
def train(input, target, expert, algorithm, verbose, interactive, training_features,
|
|
157
|
-
budget, max_features, tune, outliers, feature_outliers, time_column,
|
|
172
|
+
budget, max_features, allow_leaky_features, tune, outliers, feature_outliers, time_column,
|
|
158
173
|
log_target_mode, report, json_mode):
|
|
159
174
|
|
|
160
175
|
# Redirect Rich to /dev/null under --json so any console.print that
|
|
@@ -168,6 +183,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
168
183
|
"schema_version": JSON_SCHEMA_VERSION,
|
|
169
184
|
"command": "train",
|
|
170
185
|
}
|
|
186
|
+
warnings: list[str] = []
|
|
171
187
|
|
|
172
188
|
if not json_mode:
|
|
173
189
|
print("")
|
|
@@ -240,12 +256,14 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
240
256
|
except Exception as exc:
|
|
241
257
|
_abort(json_mode, console, "bad_time_column",
|
|
242
258
|
f"could not parse --time-column '{time_column}': {exc}")
|
|
259
|
+
warnings.append(TIME_SERIES_INTERVAL_WARNING)
|
|
243
260
|
if not json_mode:
|
|
244
261
|
console.print(
|
|
245
262
|
f"Time-aware mode: train/test split and CV will be chronological on "
|
|
246
263
|
f"'{time_column}'.",
|
|
247
264
|
style="bold blue",
|
|
248
265
|
)
|
|
266
|
+
console.print(TIME_SERIES_INTERVAL_WARNING, style="italic yellow")
|
|
249
267
|
|
|
250
268
|
if target not in data.columns:
|
|
251
269
|
_abort(json_mode, console, "unknown_target",
|
|
@@ -289,6 +307,11 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
289
307
|
feature_outlier_candidates = [
|
|
290
308
|
c for c in data.columns if c != target and c != time_column
|
|
291
309
|
]
|
|
310
|
+
# Under winsorize the model learns and applies the caps itself (from the
|
|
311
|
+
# training split only), so training runs on the uncapped rows. The capped
|
|
312
|
+
# copy still drives the diagnostics and feature ranking below.
|
|
313
|
+
winsorize_features = feature_outliers == "winsorize"
|
|
314
|
+
uncapped_data = data
|
|
292
315
|
data, feature_outlier_summary = apply_feature_outlier_policy(
|
|
293
316
|
data, feature_outlier_candidates, policy=feature_outliers
|
|
294
317
|
)
|
|
@@ -301,7 +324,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
301
324
|
"keep": "kept as-is",
|
|
302
325
|
"warn": "kept as-is — pass --feature-outliers drop or winsorize to mitigate",
|
|
303
326
|
"drop": "rows dropped",
|
|
304
|
-
"winsorize": "
|
|
327
|
+
"winsorize": "capped per column; the model applies the same caps when predicting",
|
|
305
328
|
}[feature_outliers]
|
|
306
329
|
affected = {
|
|
307
330
|
col: stats for col, stats in feature_outlier_summary["per_column"].items()
|
|
@@ -321,6 +344,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
321
344
|
feature_data = data.drop(columns=[time_column]) if time_column else data
|
|
322
345
|
high_vars = find_high_variation_features(feature_data)
|
|
323
346
|
low_vars = find_no_variation_features(feature_data)
|
|
347
|
+
exclusions = find_auto_exclusions(feature_data, target)
|
|
324
348
|
|
|
325
349
|
if not json_mode:
|
|
326
350
|
print("")
|
|
@@ -329,63 +353,98 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
329
353
|
console.print(f"High variation (potentially noisy): {high_vars}")
|
|
330
354
|
print("")
|
|
331
355
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
356
|
+
# Interactive: show the same columns auto-mode would leave out, pre-ticked
|
|
357
|
+
# with the reason, plus wide-ranging numeric columns (usually real specs)
|
|
358
|
+
# unticked. The user's final choice replaces the automatic screen.
|
|
359
|
+
auto_exclude = True
|
|
360
|
+
if interactive:
|
|
361
|
+
excluded_names = {e["column"] for e in exclusions}
|
|
362
|
+
choices = [
|
|
363
|
+
questionary.Choice(f"{e['column']} — {e['reason']}", value=e["column"], checked=True)
|
|
364
|
+
for e in exclusions
|
|
365
|
+
] + [
|
|
366
|
+
questionary.Choice(f"{c} — varies a lot, but numeric specs often do", value=c)
|
|
367
|
+
for c in high_vars
|
|
368
|
+
if c != target and c not in excluded_names
|
|
369
|
+
]
|
|
370
|
+
if choices:
|
|
371
|
+
to_remove = questionary.checkbox(
|
|
372
|
+
"Which features would you like to remove? ", choices=choices
|
|
373
|
+
).ask() or []
|
|
337
374
|
data = data.drop(to_remove, axis=1)
|
|
338
|
-
|
|
339
|
-
data = data.drop(low_vars, axis=1)
|
|
375
|
+
auto_exclude = False
|
|
340
376
|
|
|
341
377
|
feature_data = data.drop(columns=[time_column]) if time_column else data
|
|
342
378
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if not json_mode:
|
|
347
|
-
console.print("Best features detected for prediction:", style="bold white")
|
|
348
|
-
print("")
|
|
349
|
-
print_dataframe(best_features_ranked)
|
|
350
|
-
|
|
351
|
-
options_list = [c for c in feature_data.columns.tolist() if c != target]
|
|
352
|
-
selected_columns = questionary.checkbox(
|
|
353
|
-
"Select the features for training: ", choices=options_list
|
|
354
|
-
).ask()
|
|
355
|
-
if not selected_columns:
|
|
356
|
-
_abort(json_mode, console, "missing_features",
|
|
357
|
-
"You must select training features.")
|
|
358
|
-
else:
|
|
359
|
-
ranked = get_most_predictable_features(feature_data, target, output_only_headers=True)
|
|
360
|
-
n_ranked = len(ranked)
|
|
361
|
-
cap = max(2, min(n_ranked, max_features))
|
|
362
|
-
selected_columns = ranked.head(cap).tolist()
|
|
363
|
-
if not json_mode:
|
|
364
|
-
console.print(
|
|
365
|
-
f"Auto-selected features for training: {selected_columns}", style="bold blue"
|
|
366
|
-
)
|
|
367
|
-
if n_ranked > cap:
|
|
368
|
-
console.print(
|
|
369
|
-
f"Auto-selected {cap} of {n_ranked} features "
|
|
370
|
-
f"(use --max-features to override or pass -tf).",
|
|
371
|
-
style="italic",
|
|
372
|
-
)
|
|
373
|
-
print("")
|
|
374
|
-
else:
|
|
379
|
+
requested = None
|
|
380
|
+
allow_leaky = allow_leaky_features
|
|
381
|
+
if training_features:
|
|
375
382
|
requested = [c.strip() for c in training_features.split(",")]
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
383
|
+
elif expert:
|
|
384
|
+
best_features_ranked = get_most_predictable_features(feature_data, target)
|
|
385
|
+
if not json_mode:
|
|
386
|
+
console.print("Best features detected for prediction:", style="bold white")
|
|
387
|
+
print("")
|
|
388
|
+
print_dataframe(best_features_ranked)
|
|
389
|
+
|
|
390
|
+
reasons = {e["column"]: e["reason"] for e in exclusions}
|
|
391
|
+
options = [
|
|
392
|
+
questionary.Choice(
|
|
393
|
+
f"{c} — not recommended: {reasons[c]}" if c in reasons else c, value=c
|
|
394
|
+
)
|
|
395
|
+
for c in feature_data.columns.tolist() if c != target
|
|
396
|
+
]
|
|
397
|
+
requested = questionary.checkbox(
|
|
398
|
+
"Select the features for training: ", choices=options
|
|
399
|
+
).ask()
|
|
400
|
+
if not requested:
|
|
401
|
+
_abort(json_mode, console, "missing_features",
|
|
402
|
+
"You must select training features.")
|
|
403
|
+
# The list showed any leakage warning next to the column itself.
|
|
404
|
+
allow_leaky = True
|
|
405
|
+
|
|
406
|
+
# One chooser for every path (CLI auto, expert, -tf, and the MCP tools):
|
|
407
|
+
# auto-selection never picks a leakage, ID-like or single-value column.
|
|
408
|
+
try:
|
|
409
|
+
choice = choose_training_features(
|
|
410
|
+
feature_data, target, requested, max_features=max_features,
|
|
411
|
+
allow_leaky=allow_leaky, auto_exclude=auto_exclude,
|
|
412
|
+
)
|
|
413
|
+
except FeatureChoiceError as e:
|
|
414
|
+
_abort(json_mode, console, e.code, str(e))
|
|
415
|
+
if choice.leaky_requested:
|
|
416
|
+
_abort(json_mode, console, "target_leakage",
|
|
417
|
+
"; ".join(f"'{d['feature']}' {d['reason']}" for d in choice.leaky_requested)
|
|
418
|
+
+ " Remove it from -tf, or pass --allow-leaky-features to train on it anyway.")
|
|
419
|
+
for note in choice.notes:
|
|
420
|
+
warnings.append(note)
|
|
421
|
+
if not json_mode:
|
|
422
|
+
console.print(note, style="yellow")
|
|
423
|
+
selected_columns = choice.selected
|
|
424
|
+
|
|
425
|
+
if requested is None and not json_mode:
|
|
426
|
+
n_candidates = feature_data.shape[1] - 1 - len(choice.excluded)
|
|
427
|
+
console.print(
|
|
428
|
+
f"Auto-selected features for training: {selected_columns}", style="bold blue"
|
|
429
|
+
)
|
|
430
|
+
if n_candidates > len(selected_columns):
|
|
431
|
+
console.print(
|
|
432
|
+
f"Auto-selected {len(selected_columns)} of {n_candidates} features "
|
|
433
|
+
f"(use --max-features to override or pass -tf).",
|
|
434
|
+
style="italic",
|
|
435
|
+
)
|
|
436
|
+
print("")
|
|
381
437
|
|
|
382
438
|
target_column = target
|
|
383
439
|
|
|
384
440
|
if time_column is not None and time_column in selected_columns:
|
|
385
441
|
selected_columns = [c for c in selected_columns if c != time_column]
|
|
386
442
|
|
|
443
|
+
training_data = (
|
|
444
|
+
uncapped_data.loc[data.index, data.columns] if winsorize_features else data
|
|
445
|
+
)
|
|
387
446
|
X_train, X_test, y_train, y_test, numerical_cols, categorical_cols = prepare_data(
|
|
388
|
-
|
|
447
|
+
training_data, selected_columns, target_column, time_column=time_column
|
|
389
448
|
)
|
|
390
449
|
time_aware = time_column is not None
|
|
391
450
|
|
|
@@ -437,7 +496,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
437
496
|
model, feature_weights, log_target = start_training(
|
|
438
497
|
X_train, y_train, numerical_cols, categorical_cols, algorithm,
|
|
439
498
|
budget=budget, tune=tune, time_aware=time_aware,
|
|
440
|
-
log_target=log_target_override,
|
|
499
|
+
log_target=log_target_override, winsorize_features=winsorize_features,
|
|
441
500
|
)
|
|
442
501
|
if inner_spinner is not None:
|
|
443
502
|
inner_spinner.stop()
|
|
@@ -460,7 +519,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
460
519
|
model, algorithm, scores, log_target = auto_train(
|
|
461
520
|
X_train, y_train, numerical_cols, categorical_cols,
|
|
462
521
|
budget=budget, time_aware=time_aware,
|
|
463
|
-
log_target=log_target_override,
|
|
522
|
+
log_target=log_target_override, winsorize_features=winsorize_features,
|
|
464
523
|
)
|
|
465
524
|
if inner_spinner is not None:
|
|
466
525
|
inner_spinner.stop()
|
|
@@ -556,6 +615,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
556
615
|
budget=budget,
|
|
557
616
|
time_aware=time_aware,
|
|
558
617
|
log_target=log_target_override,
|
|
618
|
+
winsorize_features=winsorize_features,
|
|
559
619
|
)
|
|
560
620
|
tune_spinner.stop()
|
|
561
621
|
mae_t, r2_t, _, rmse_t = evaluate_model(X_test, y_test, tuned_model)
|
|
@@ -661,6 +721,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
661
721
|
"high_variation": list(high_vars),
|
|
662
722
|
},
|
|
663
723
|
"features_selected": list(selected_columns),
|
|
724
|
+
"excluded_features": list(choice.excluded),
|
|
664
725
|
"algorithm_selected": algorithm,
|
|
665
726
|
"log_target": bool(log_target),
|
|
666
727
|
"log_target_decision": log_target_decision,
|
|
@@ -675,6 +736,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
675
736
|
},
|
|
676
737
|
"model_path": saved_model_path,
|
|
677
738
|
"report_path": report_path,
|
|
739
|
+
"warnings": warnings,
|
|
678
740
|
})
|
|
679
741
|
emit(response)
|
|
680
742
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
6
|
+
from sklearn.pipeline import Pipeline
|
|
7
|
+
|
|
8
|
+
from p2predict.preprocessing import build_preprocessor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_high_variation_features(df):
|
|
12
|
+
high_variation = []
|
|
13
|
+
|
|
14
|
+
numeric_df = df.select_dtypes(include=["int64", "float64", "int32", "float32"])
|
|
15
|
+
if not numeric_df.empty:
|
|
16
|
+
means = numeric_df.mean()
|
|
17
|
+
stds = numeric_df.std()
|
|
18
|
+
# Use |mean| and guard against ~0 to keep CV well-defined.
|
|
19
|
+
safe_means = means.abs().where(means.abs() > 1e-9)
|
|
20
|
+
cv = (stds / safe_means).dropna()
|
|
21
|
+
high_variation.extend(cv[cv > 1].index.tolist())
|
|
22
|
+
|
|
23
|
+
categorical_df = df.select_dtypes(include=["object", "bool", "category"])
|
|
24
|
+
if not categorical_df.empty:
|
|
25
|
+
unique_ratio = categorical_df.apply(lambda x: x.nunique() / max(len(x), 1))
|
|
26
|
+
high_variation.extend(unique_ratio[unique_ratio > 0.9].index.tolist())
|
|
27
|
+
|
|
28
|
+
return high_variation
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_no_variation_features(df):
|
|
32
|
+
unique_counts = df.nunique(dropna=False)
|
|
33
|
+
return unique_counts[unique_counts <= 1].index.tolist()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_leaky_features(data, target_column, threshold=0.97):
|
|
37
|
+
"""Flag features that look like an alternate form of the target (leakage).
|
|
38
|
+
|
|
39
|
+
A numeric feature whose absolute Pearson correlation with the target
|
|
40
|
+
exceeds ``threshold`` is almost certainly **target leakage** — a
|
|
41
|
+
near-duplicate of the answer (e.g. the same price at a different quantity
|
|
42
|
+
break, or a pre-rounded copy) rather than a genuine spec. Training on it
|
|
43
|
+
inflates every metric while producing a model that is useless on real
|
|
44
|
+
parts, because at prediction time you wouldn't have the leaked column
|
|
45
|
+
(or you'd already know the price).
|
|
46
|
+
|
|
47
|
+
Only numeric columns are screened — a categorical can't be a linear
|
|
48
|
+
duplicate of a numeric target — and the target column itself is never
|
|
49
|
+
returned.
|
|
50
|
+
|
|
51
|
+
Returns a list of ``{"feature", "correlation", "reason"}`` dicts sorted
|
|
52
|
+
by absolute correlation, descending. Empty when nothing looks leaky.
|
|
53
|
+
"""
|
|
54
|
+
if target_column not in data.columns:
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
y = pd.to_numeric(data[target_column], errors="coerce")
|
|
58
|
+
leaks = []
|
|
59
|
+
for col in data.columns:
|
|
60
|
+
if col == target_column:
|
|
61
|
+
continue
|
|
62
|
+
x = pd.to_numeric(data[col], errors="coerce")
|
|
63
|
+
pair = pd.concat([x, y], axis=1).dropna()
|
|
64
|
+
if len(pair) < 3 or pair.iloc[:, 0].nunique() < 2 or pair.iloc[:, 1].nunique() < 2:
|
|
65
|
+
continue
|
|
66
|
+
corr = pair.iloc[:, 0].corr(pair.iloc[:, 1])
|
|
67
|
+
if corr is not None and not pd.isna(corr) and abs(corr) >= threshold:
|
|
68
|
+
leaks.append({
|
|
69
|
+
"feature": col,
|
|
70
|
+
"correlation": round(float(corr), 4),
|
|
71
|
+
"reason": (
|
|
72
|
+
f"correlates {corr:.2f} with the target '{target_column}' — "
|
|
73
|
+
"almost certainly an alternate form of the value being "
|
|
74
|
+
"predicted (e.g. a different quantity break), not a spec. "
|
|
75
|
+
"Training on it makes the model look near-perfect but useless "
|
|
76
|
+
"on real parts."
|
|
77
|
+
),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
leaks.sort(key=lambda d: abs(d["correlation"]), reverse=True)
|
|
81
|
+
return leaks
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_auto_exclusions(data, target_column):
|
|
85
|
+
"""Columns automatic feature selection must never pick, each with a reason.
|
|
86
|
+
|
|
87
|
+
The single source of truth for what auto-mode leaves out, shared by the
|
|
88
|
+
headless CLI, the MCP ``train`` tool and ``propose_training_plan`` so the
|
|
89
|
+
same CSV yields the same specs on every path. Three kinds, in this order:
|
|
90
|
+
|
|
91
|
+
- ``leakage``: a numeric near-duplicate of the target (see
|
|
92
|
+
:func:`find_leaky_features`).
|
|
93
|
+
- ``id_like``: a text column where almost every row is unique (part
|
|
94
|
+
numbers, descriptions). A model can't learn a spec from it, and a new
|
|
95
|
+
part never shares its value, so it only adds noise. Numeric
|
|
96
|
+
high-variation columns are *not* excluded: a spec like weight can
|
|
97
|
+
legitimately span orders of magnitude.
|
|
98
|
+
- ``constant``: the same value in every row, so it carries no information.
|
|
99
|
+
|
|
100
|
+
Explicitly requested features are the caller's decision; this only
|
|
101
|
+
governs what auto-selection may pick. Returns a list of
|
|
102
|
+
``{"column", "kind", "reason"}`` dicts.
|
|
103
|
+
"""
|
|
104
|
+
excluded = []
|
|
105
|
+
leaky = find_leaky_features(data, target_column)
|
|
106
|
+
leaky_names = {d["feature"] for d in leaky}
|
|
107
|
+
for d in leaky:
|
|
108
|
+
excluded.append({"column": d["feature"], "kind": "leakage", "reason": d["reason"]})
|
|
109
|
+
|
|
110
|
+
for col in find_high_variation_features(data):
|
|
111
|
+
if (
|
|
112
|
+
col == target_column
|
|
113
|
+
or col in leaky_names
|
|
114
|
+
or pd.api.types.is_numeric_dtype(data[col])
|
|
115
|
+
):
|
|
116
|
+
continue
|
|
117
|
+
excluded.append({
|
|
118
|
+
"column": col,
|
|
119
|
+
"kind": "id_like",
|
|
120
|
+
"reason": "Looks like an ID / free-text column (almost every row is "
|
|
121
|
+
"unique), not a spec the model can learn from.",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
for col in find_no_variation_features(data):
|
|
125
|
+
if col == target_column:
|
|
126
|
+
continue
|
|
127
|
+
excluded.append({
|
|
128
|
+
"column": col,
|
|
129
|
+
"kind": "constant",
|
|
130
|
+
"reason": "Same value in every row — carries no information.",
|
|
131
|
+
})
|
|
132
|
+
return excluded
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class FeatureChoiceError(ValueError):
|
|
136
|
+
"""Feature selection can't proceed. ``code`` is the stable error code the
|
|
137
|
+
CLI's ``--json`` output and the MCP error payload report."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, code, message):
|
|
140
|
+
super().__init__(message)
|
|
141
|
+
self.code = code
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class FeatureChoice:
|
|
146
|
+
selected: list
|
|
147
|
+
# Columns auto-mode left out, as returned by find_auto_exclusions.
|
|
148
|
+
excluded: list = field(default_factory=list)
|
|
149
|
+
# Plain-language notes on anything left out, for the caller's warnings.
|
|
150
|
+
notes: list = field(default_factory=list)
|
|
151
|
+
# Explicitly requested columns that look like target leakage, in the
|
|
152
|
+
# find_leaky_features format. Non-empty means the caller must stop and
|
|
153
|
+
# ask (or the user passed allow_leaky); `selected` is then the request.
|
|
154
|
+
leaky_requested: list = field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def choose_training_features(
|
|
158
|
+
data, target_column, requested=None, *, max_features=6,
|
|
159
|
+
allow_leaky=False, auto_exclude=True,
|
|
160
|
+
):
|
|
161
|
+
"""Decide which columns to train on. Shared by every training path.
|
|
162
|
+
|
|
163
|
+
``data`` holds the candidate columns plus the target (a time column, if
|
|
164
|
+
any, already removed). With ``requested`` (``-tf`` on the CLI, ``features``
|
|
165
|
+
over MCP) the user's list is used as given, except that single-value
|
|
166
|
+
columns are left out with a note, since they cannot move the price. A
|
|
167
|
+
requested leakage column is reported in ``leaky_requested`` unless
|
|
168
|
+
``allow_leaky``. Without ``requested``, the top ``max_features`` columns
|
|
169
|
+
by random-forest importance are chosen after leaving out everything
|
|
170
|
+
:func:`find_auto_exclusions` flags. ``auto_exclude=False`` skips that
|
|
171
|
+
screen, for when the user already chose what to drop interactively.
|
|
172
|
+
|
|
173
|
+
Raises :class:`FeatureChoiceError` when nothing usable is left or a
|
|
174
|
+
requested column doesn't exist.
|
|
175
|
+
"""
|
|
176
|
+
exclusions = find_auto_exclusions(data, target_column)
|
|
177
|
+
|
|
178
|
+
if requested:
|
|
179
|
+
requested = list(requested)
|
|
180
|
+
constant = {e["column"] for e in exclusions if e["kind"] == "constant"}
|
|
181
|
+
dropped = [c for c in requested if c in constant]
|
|
182
|
+
notes = [
|
|
183
|
+
f"Left out '{c}': same value in every row, so it carries no information."
|
|
184
|
+
for c in dropped
|
|
185
|
+
]
|
|
186
|
+
requested = [c for c in requested if c not in constant]
|
|
187
|
+
missing = [c for c in requested if c not in data.columns]
|
|
188
|
+
if missing:
|
|
189
|
+
raise FeatureChoiceError(
|
|
190
|
+
"unknown_features", f"requested features not in CSV: {missing}"
|
|
191
|
+
)
|
|
192
|
+
if not requested:
|
|
193
|
+
raise FeatureChoiceError(
|
|
194
|
+
"missing_features",
|
|
195
|
+
"every requested feature has the same value in every row.",
|
|
196
|
+
)
|
|
197
|
+
leaky = [
|
|
198
|
+
d for d in find_leaky_features(data, target_column)
|
|
199
|
+
if d["feature"] in requested
|
|
200
|
+
]
|
|
201
|
+
return FeatureChoice(
|
|
202
|
+
selected=requested,
|
|
203
|
+
notes=notes,
|
|
204
|
+
leaky_requested=[] if allow_leaky else leaky,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
excluded = exclusions if auto_exclude else []
|
|
208
|
+
drop_all = [e["column"] for e in excluded]
|
|
209
|
+
candidates = data.drop(columns=drop_all)
|
|
210
|
+
if candidates.shape[1] < 2: # the target alone
|
|
211
|
+
raise FeatureChoiceError(
|
|
212
|
+
"no_usable_features",
|
|
213
|
+
"no usable spec columns left after leaving out ID-like, "
|
|
214
|
+
"single-value and target-leakage columns: "
|
|
215
|
+
+ "; ".join(f"{e['column']} ({e['reason']})" for e in excluded),
|
|
216
|
+
)
|
|
217
|
+
ranked = get_most_predictable_features(
|
|
218
|
+
candidates, target_column, output_only_headers=True
|
|
219
|
+
)
|
|
220
|
+
cap = max(2, min(len(ranked), max_features))
|
|
221
|
+
return FeatureChoice(
|
|
222
|
+
selected=ranked.head(cap).tolist(),
|
|
223
|
+
excluded=excluded,
|
|
224
|
+
notes=[f"Left out '{e['column']}': {e['reason']}" for e in excluded],
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _column_types(X):
|
|
229
|
+
numerical_cols = X.select_dtypes(include=["int64", "float64", "int32", "float32"]).columns
|
|
230
|
+
categorical_cols = X.select_dtypes(include=["object", "bool", "category"]).columns
|
|
231
|
+
return numerical_cols, categorical_cols
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_most_predictable_features(data, target_column, output_only_headers=False):
|
|
235
|
+
X = data.drop(target_column, axis=1)
|
|
236
|
+
y = data[target_column]
|
|
237
|
+
|
|
238
|
+
numerical_cols, categorical_cols = _column_types(X)
|
|
239
|
+
|
|
240
|
+
preprocessor = build_preprocessor(numerical_cols, categorical_cols, model_family="tree")
|
|
241
|
+
model = RandomForestRegressor(random_state=0, n_jobs=-1)
|
|
242
|
+
pipeline = Pipeline(
|
|
243
|
+
steps=[("preprocessor", preprocessor), ("model", model)]
|
|
244
|
+
)
|
|
245
|
+
pipeline.fit(X, y)
|
|
246
|
+
|
|
247
|
+
# With OrdinalEncoder, each source column maps to a single transformed
|
|
248
|
+
# column — no expansion, no underscore-grouping needed.
|
|
249
|
+
raw_names = pipeline.named_steps["preprocessor"].get_feature_names_out()
|
|
250
|
+
importances = np.asarray(model.feature_importances_, dtype=float)
|
|
251
|
+
|
|
252
|
+
source_cols = list(X.columns)
|
|
253
|
+
by_source = {}
|
|
254
|
+
for name, imp in zip(raw_names, importances):
|
|
255
|
+
rest = name.split("__", 1)[1] if "__" in name else name
|
|
256
|
+
match = None
|
|
257
|
+
for col in source_cols:
|
|
258
|
+
if rest == col or rest.startswith(f"{col}_"):
|
|
259
|
+
if match is None or len(col) > len(match):
|
|
260
|
+
match = col
|
|
261
|
+
source = match if match is not None else rest
|
|
262
|
+
by_source[source] = by_source.get(source, 0.0) + float(imp)
|
|
263
|
+
|
|
264
|
+
feature_importances = pd.DataFrame(
|
|
265
|
+
sorted(by_source.items(), key=lambda kv: kv[1], reverse=True),
|
|
266
|
+
columns=["Feature", "Importance"],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if output_only_headers:
|
|
270
|
+
return feature_importances["Feature"]
|
|
271
|
+
|
|
272
|
+
total = feature_importances["Importance"].sum()
|
|
273
|
+
if total > 0:
|
|
274
|
+
feature_importances["Importance"] = (
|
|
275
|
+
feature_importances["Importance"] / total * 100
|
|
276
|
+
).round(2)
|
|
277
|
+
feature_importances.rename(columns={"Importance": "Importance (%)"}, inplace=True)
|
|
278
|
+
return feature_importances
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# Kept as a thin alias for backwards compatibility with any external callers.
|
|
282
|
+
def get_most_predictable_features_RFE(data, target_column, n_features_to_select=10):
|
|
283
|
+
return get_most_predictable_features(data, target_column, output_only_headers=True).head(
|
|
284
|
+
n_features_to_select
|
|
285
|
+
).tolist()
|
|
@@ -7,13 +7,14 @@ console = Console()
|
|
|
7
7
|
|
|
8
8
|
def hyper_parameter_tuning(
|
|
9
9
|
X_train, y_train, numerical_cols, categorical_cols, algorithm,
|
|
10
|
-
budget="fast", time_aware=False, log_target=None,
|
|
10
|
+
budget="fast", time_aware=False, log_target=None, winsorize_features=False,
|
|
11
11
|
):
|
|
12
12
|
"""Tune the given algorithm and return the refitted best pipeline."""
|
|
13
13
|
if log_target is None:
|
|
14
14
|
log_target = should_log_target(y_train)
|
|
15
15
|
pipeline = build_pipeline(
|
|
16
|
-
algorithm, numerical_cols, categorical_cols, log_target=log_target
|
|
16
|
+
algorithm, numerical_cols, categorical_cols, log_target=log_target,
|
|
17
|
+
winsorize_features=winsorize_features,
|
|
17
18
|
)
|
|
18
19
|
best_model, best_score = _tune(
|
|
19
20
|
pipeline, X_train, y_train, algorithm, budget, log_target,
|