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.
Files changed (59) hide show
  1. {p2predict-1.1.1/src/p2predict.egg-info → p2predict-1.2.0}/PKG-INFO +5 -1
  2. {p2predict-1.1.1 → p2predict-1.2.0}/README.md +4 -0
  3. {p2predict-1.1.1 → p2predict-1.2.0}/pyproject.toml +1 -1
  4. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cli/train.py +86 -45
  5. p2predict-1.2.0/src/p2predict/feature_selection.py +285 -0
  6. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/json_output.py +6 -0
  7. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/registry.py +30 -1
  8. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/server.py +126 -150
  9. {p2predict-1.1.1 → p2predict-1.2.0/src/p2predict.egg-info}/PKG-INFO +5 -1
  10. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_cli.py +182 -0
  11. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_feature_selection.py +21 -0
  12. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_mcp.py +141 -0
  13. p2predict-1.1.1/src/p2predict/feature_selection.py +0 -139
  14. {p2predict-1.1.1 → p2predict-1.2.0}/LICENSE +0 -0
  15. {p2predict-1.1.1 → p2predict-1.2.0}/setup.cfg +0 -0
  16. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/__init__.py +0 -0
  17. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/__main__.py +0 -0
  18. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cli/__init__.py +0 -0
  19. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cli/predict.py +0 -0
  20. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/cmdline_io.py +0 -0
  21. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/domain.py +0 -0
  22. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/explain.py +0 -0
  23. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/hpo_training.py +0 -0
  24. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/input_checks.py +0 -0
  25. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/intervals.py +0 -0
  26. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/__init__.py +0 -0
  27. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/__main__.py +0 -0
  28. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/mcp/conversions.py +0 -0
  29. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/model_evals.py +0 -0
  30. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/model_utils.py +0 -0
  31. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/outliers.py +0 -0
  32. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/plotting.py +0 -0
  33. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/prepare_data.py +0 -0
  34. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/preprocessing.py +0 -0
  35. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/quality.py +0 -0
  36. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/trained_model_io.py +0 -0
  37. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/training.py +0 -0
  38. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/ui_console.py +0 -0
  39. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict/whatif.py +0 -0
  40. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/SOURCES.txt +0 -0
  41. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/dependency_links.txt +0 -0
  42. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/entry_points.txt +0 -0
  43. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/requires.txt +0 -0
  44. {p2predict-1.1.1 → p2predict-1.2.0}/src/p2predict.egg-info/top_level.txt +0 -0
  45. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_benchmarks.py +0 -0
  46. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_domain.py +0 -0
  47. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_explain.py +0 -0
  48. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_feature_selection_leakage.py +0 -0
  49. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_input_checks.py +0 -0
  50. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_intervals.py +0 -0
  51. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_io_and_evals.py +0 -0
  52. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_json_output.py +0 -0
  53. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_outliers.py +0 -0
  54. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_plotting.py +0 -0
  55. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_prepare_data.py +0 -0
  56. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_preprocessing.py +0 -0
  57. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_quality.py +0 -0
  58. {p2predict-1.1.1 → p2predict-1.2.0}/tests/test_training.py +0 -0
  59. {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.1.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.1.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
- if interactive and (low_vars or high_vars):
349
- to_remove = questionary.checkbox(
350
- "Which features would you like to remove? ", choices=low_vars + high_vars
351
- ).ask()
352
- if to_remove:
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
- elif low_vars:
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
- if not training_features:
360
- if expert:
361
- best_features_ranked = get_most_predictable_features(feature_data, target)
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
- missing = [c for c in requested if c not in data.columns]
393
- if missing:
394
- _abort(json_mode, console, "unknown_features",
395
- f"requested features not in CSV: {missing}")
396
- selected_columns = requested
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
- return self.models_dir / f"{model_id}.model"
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."""