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.
Files changed (59) hide show
  1. {p2predict-1.1.0/src/p2predict.egg-info → p2predict-1.2.0}/PKG-INFO +6 -1
  2. {p2predict-1.1.0 → p2predict-1.2.0}/README.md +5 -0
  3. {p2predict-1.1.0 → p2predict-1.2.0}/pyproject.toml +1 -1
  4. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cli/predict.py +13 -1
  5. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cli/train.py +111 -49
  6. p2predict-1.2.0/src/p2predict/feature_selection.py +285 -0
  7. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/hpo_training.py +3 -2
  8. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/intervals.py +17 -10
  9. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/json_output.py +8 -1
  10. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/registry.py +30 -1
  11. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/server.py +155 -142
  12. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/outliers.py +108 -1
  13. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/preprocessing.py +54 -1
  14. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/trained_model_io.py +7 -0
  15. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/training.py +10 -5
  16. {p2predict-1.1.0 → p2predict-1.2.0/src/p2predict.egg-info}/PKG-INFO +6 -1
  17. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_cli.py +244 -0
  18. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_feature_selection.py +21 -0
  19. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_io_and_evals.py +11 -0
  20. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_mcp.py +222 -0
  21. p2predict-1.1.0/src/p2predict/feature_selection.py +0 -139
  22. {p2predict-1.1.0 → p2predict-1.2.0}/LICENSE +0 -0
  23. {p2predict-1.1.0 → p2predict-1.2.0}/setup.cfg +0 -0
  24. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/__init__.py +0 -0
  25. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/__main__.py +0 -0
  26. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cli/__init__.py +0 -0
  27. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/cmdline_io.py +0 -0
  28. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/domain.py +0 -0
  29. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/explain.py +0 -0
  30. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/input_checks.py +0 -0
  31. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/__init__.py +0 -0
  32. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/__main__.py +0 -0
  33. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/mcp/conversions.py +0 -0
  34. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/model_evals.py +0 -0
  35. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/model_utils.py +0 -0
  36. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/plotting.py +0 -0
  37. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/prepare_data.py +0 -0
  38. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/quality.py +0 -0
  39. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/ui_console.py +0 -0
  40. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict/whatif.py +0 -0
  41. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/SOURCES.txt +0 -0
  42. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/dependency_links.txt +0 -0
  43. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/entry_points.txt +0 -0
  44. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/requires.txt +0 -0
  45. {p2predict-1.1.0 → p2predict-1.2.0}/src/p2predict.egg-info/top_level.txt +0 -0
  46. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_benchmarks.py +0 -0
  47. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_domain.py +0 -0
  48. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_explain.py +0 -0
  49. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_feature_selection_leakage.py +0 -0
  50. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_input_checks.py +0 -0
  51. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_intervals.py +0 -0
  52. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_json_output.py +0 -0
  53. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_outliers.py +0 -0
  54. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_plotting.py +0 -0
  55. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_prepare_data.py +0 -0
  56. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_preprocessing.py +0 -0
  57. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_quality.py +0 -0
  58. {p2predict-1.1.0 → p2predict-1.2.0}/tests/test_training.py +0 -0
  59. {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.1.0
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.1.0"
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
- @click.command()
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": "values winsorized per column",
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
- if interactive and (low_vars or high_vars):
333
- to_remove = questionary.checkbox(
334
- "Which features would you like to remove? ", choices=low_vars + high_vars
335
- ).ask()
336
- 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 []
337
374
  data = data.drop(to_remove, axis=1)
338
- elif low_vars:
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
- if not training_features:
344
- if expert:
345
- best_features_ranked = get_most_predictable_features(feature_data, target)
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
- missing = [c for c in requested if c not in data.columns]
377
- if missing:
378
- _abort(json_mode, console, "unknown_features",
379
- f"requested features not in CSV: {missing}")
380
- 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("")
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
- data, selected_columns, target_column, time_column=time_column
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,