p2predict 1.1.1__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.1/src/p2predict.egg-info → p2predict-1.2.0}/PKG-INFO +5 -1
- {p2predict-1.1.1 → p2predict-1.2.0}/README.md +4 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/pyproject.toml +1 -1
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cli/train.py +86 -45
- p2predict-1.2.0/src/p2predict/feature_selection.py +285 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/json_output.py +6 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/registry.py +30 -1
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/server.py +126 -150
- {p2predict-1.1.1 → p2predict-1.2.0/src/p2predict.egg-info}/PKG-INFO +5 -1
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_cli.py +182 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_feature_selection.py +21 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_mcp.py +141 -0
- p2predict-1.1.1/src/p2predict/feature_selection.py +0 -139
- {p2predict-1.1.1 → p2predict-1.2.0}/LICENSE +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/setup.cfg +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/__init__.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/__main__.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cli/__init__.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cli/predict.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cmdline_io.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/domain.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/explain.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/hpo_training.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/input_checks.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/intervals.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/__init__.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/__main__.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/conversions.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/model_evals.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/model_utils.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/outliers.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/plotting.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/prepare_data.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/preprocessing.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/quality.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/trained_model_io.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/training.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/ui_console.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/whatif.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/SOURCES.txt +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/dependency_links.txt +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/entry_points.txt +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/requires.txt +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/top_level.txt +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_benchmarks.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_domain.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_explain.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_feature_selection_leakage.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_input_checks.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_intervals.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_io_and_evals.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_json_output.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_outliers.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_plotting.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_prepare_data.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_preprocessing.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_quality.py +0 -0
- {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_training.py +0 -0
- {p2predict-1.1.1 → 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
|
|
@@ -263,6 +263,10 @@ P2Predict comes out of that work: the tools a procurement team actually needs to
|
|
|
263
263
|
- **Set it up with your agent:** see **[INSTALL.md](INSTALL.md)** to install, connect your AI assistant, and point it at your data.
|
|
264
264
|
- **How it works under the hood:** the models, the math, the full reference live in **[TECHNICAL.md](TECHNICAL.md)**.
|
|
265
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
|
+
|
|
266
270
|
## Licensing
|
|
267
271
|
|
|
268
272
|
Source-available under the [PolyForm Noncommercial License 1.0.0, with an additional grant of permission for internal company use](LICENSE).
|
|
@@ -215,6 +215,10 @@ P2Predict comes out of that work: the tools a procurement team actually needs to
|
|
|
215
215
|
- **Set it up with your agent:** see **[INSTALL.md](INSTALL.md)** to install, connect your AI assistant, and point it at your data.
|
|
216
216
|
- **How it works under the hood:** the models, the math, the full reference live in **[TECHNICAL.md](TECHNICAL.md)**.
|
|
217
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
|
+
|
|
218
222
|
## Licensing
|
|
219
223
|
|
|
220
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"
|
|
@@ -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,
|
|
@@ -126,6 +129,10 @@ def _feature_outlier_summary_block(summary: dict) -> dict:
|
|
|
126
129
|
"Default 6 preserves prior behaviour. Pass a higher number (or use -tf to pick "
|
|
127
130
|
"features explicitly) when ranking suggests more columns are predictive. "
|
|
128
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.")
|
|
129
136
|
@click.option("--tune/--no-tune", default=None,
|
|
130
137
|
help="Expert mode only: run HPO on the chosen algorithm and save the tuned model.")
|
|
131
138
|
@click.option("--outliers", type=click.Choice(list(OUTLIER_POLICIES)), default="warn",
|
|
@@ -162,7 +169,7 @@ def _feature_outlier_summary_block(summary: dict) -> dict:
|
|
|
162
169
|
"Rich-formatted output. Useful for agents and scripts. "
|
|
163
170
|
"See p2predict.json_output for the schema.")
|
|
164
171
|
def train(input, target, expert, algorithm, verbose, interactive, training_features,
|
|
165
|
-
budget, max_features, tune, outliers, feature_outliers, time_column,
|
|
172
|
+
budget, max_features, allow_leaky_features, tune, outliers, feature_outliers, time_column,
|
|
166
173
|
log_target_mode, report, json_mode):
|
|
167
174
|
|
|
168
175
|
# Redirect Rich to /dev/null under --json so any console.print that
|
|
@@ -337,6 +344,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
337
344
|
feature_data = data.drop(columns=[time_column]) if time_column else data
|
|
338
345
|
high_vars = find_high_variation_features(feature_data)
|
|
339
346
|
low_vars = find_no_variation_features(feature_data)
|
|
347
|
+
exclusions = find_auto_exclusions(feature_data, target)
|
|
340
348
|
|
|
341
349
|
if not json_mode:
|
|
342
350
|
print("")
|
|
@@ -345,55 +353,87 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
345
353
|
console.print(f"High variation (potentially noisy): {high_vars}")
|
|
346
354
|
print("")
|
|
347
355
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 []
|
|
353
374
|
data = data.drop(to_remove, axis=1)
|
|
354
|
-
|
|
355
|
-
data = data.drop(low_vars, axis=1)
|
|
375
|
+
auto_exclude = False
|
|
356
376
|
|
|
357
377
|
feature_data = data.drop(columns=[time_column]) if time_column else data
|
|
358
378
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if not json_mode:
|
|
363
|
-
console.print("Best features detected for prediction:", style="bold white")
|
|
364
|
-
print("")
|
|
365
|
-
print_dataframe(best_features_ranked)
|
|
366
|
-
|
|
367
|
-
options_list = [c for c in feature_data.columns.tolist() if c != target]
|
|
368
|
-
selected_columns = questionary.checkbox(
|
|
369
|
-
"Select the features for training: ", choices=options_list
|
|
370
|
-
).ask()
|
|
371
|
-
if not selected_columns:
|
|
372
|
-
_abort(json_mode, console, "missing_features",
|
|
373
|
-
"You must select training features.")
|
|
374
|
-
else:
|
|
375
|
-
ranked = get_most_predictable_features(feature_data, target, output_only_headers=True)
|
|
376
|
-
n_ranked = len(ranked)
|
|
377
|
-
cap = max(2, min(n_ranked, max_features))
|
|
378
|
-
selected_columns = ranked.head(cap).tolist()
|
|
379
|
-
if not json_mode:
|
|
380
|
-
console.print(
|
|
381
|
-
f"Auto-selected features for training: {selected_columns}", style="bold blue"
|
|
382
|
-
)
|
|
383
|
-
if n_ranked > cap:
|
|
384
|
-
console.print(
|
|
385
|
-
f"Auto-selected {cap} of {n_ranked} features "
|
|
386
|
-
f"(use --max-features to override or pass -tf).",
|
|
387
|
-
style="italic",
|
|
388
|
-
)
|
|
389
|
-
print("")
|
|
390
|
-
else:
|
|
379
|
+
requested = None
|
|
380
|
+
allow_leaky = allow_leaky_features
|
|
381
|
+
if training_features:
|
|
391
382
|
requested = [c.strip() for c in training_features.split(",")]
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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("")
|
|
397
437
|
|
|
398
438
|
target_column = target
|
|
399
439
|
|
|
@@ -681,6 +721,7 @@ def train(input, target, expert, algorithm, verbose, interactive, training_featu
|
|
|
681
721
|
"high_variation": list(high_vars),
|
|
682
722
|
},
|
|
683
723
|
"features_selected": list(selected_columns),
|
|
724
|
+
"excluded_features": list(choice.excluded),
|
|
684
725
|
"algorithm_selected": algorithm,
|
|
685
726
|
"log_target": bool(log_target),
|
|
686
727
|
"log_target_decision": log_target_decision,
|
|
@@ -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()
|
|
@@ -119,6 +119,12 @@ Train (``p2predict-train ... --json``)
|
|
|
119
119
|
"high_variation": [str, ...]
|
|
120
120
|
},
|
|
121
121
|
"features_selected": [str, ...],
|
|
122
|
+
"excluded_features": [ # auto-mode only; empty with -tf/expert
|
|
123
|
+
{"column": str,
|
|
124
|
+
"kind": "leakage" | "id_like" | "constant",
|
|
125
|
+
"reason": str},
|
|
126
|
+
...
|
|
127
|
+
],
|
|
122
128
|
"algorithm_selected": str,
|
|
123
129
|
"log_target": bool,
|
|
124
130
|
"log_target_decision": str, # "auto:skew=<value>" | "manual:on" | "manual:off"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -7,6 +8,19 @@ from p2predict.model_utils import extract_feature_info, inner_pipeline
|
|
|
7
8
|
from p2predict.trained_model_io import LoadModel
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
_PATH_UNSAFE = re.compile(r"[\\/\x00]+")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe_filename_part(text: str) -> str:
|
|
15
|
+
"""Make ``text`` safe to embed in a file name (no separators, no NUL).
|
|
16
|
+
|
|
17
|
+
Used for values that come from user data, e.g. a CSV column name that ends
|
|
18
|
+
up in a saved model's file name. Spaces and other ordinary characters are
|
|
19
|
+
left alone so existing model IDs stay stable.
|
|
20
|
+
"""
|
|
21
|
+
return _PATH_UNSAFE.sub("_", text)
|
|
22
|
+
|
|
23
|
+
|
|
10
24
|
@dataclass
|
|
11
25
|
class ModelInfo:
|
|
12
26
|
model_id: str
|
|
@@ -105,7 +119,22 @@ class ModelRegistry:
|
|
|
105
119
|
self._cache: dict[str, dict] = {}
|
|
106
120
|
|
|
107
121
|
def _model_path(self, model_id: str) -> Path:
|
|
108
|
-
|
|
122
|
+
"""Resolve a model ID to its file, refusing anything outside models_dir.
|
|
123
|
+
|
|
124
|
+
``model_id`` arrives from an MCP tool call, and ``.model`` files are
|
|
125
|
+
pickles, so an ID like ``../../elsewhere/x`` must never reach a file
|
|
126
|
+
outside the models folder. Raised as FileNotFoundError so every tool's
|
|
127
|
+
existing ``model_not_found`` handling covers it.
|
|
128
|
+
"""
|
|
129
|
+
not_found = FileNotFoundError(
|
|
130
|
+
f"No model '{model_id}' found in {self.models_dir}"
|
|
131
|
+
)
|
|
132
|
+
if not model_id or _PATH_UNSAFE.search(model_id):
|
|
133
|
+
raise not_found
|
|
134
|
+
path = self.models_dir / f"{model_id}.model"
|
|
135
|
+
if path.parent.resolve() != self.models_dir.resolve():
|
|
136
|
+
raise not_found
|
|
137
|
+
return path
|
|
109
138
|
|
|
110
139
|
def scan(self) -> list[ModelInfo]:
|
|
111
140
|
"""List all models in the directory with their metadata."""
|