mlpipe-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. mlpipe/__init__.py +43 -0
  2. mlpipe/__main__.py +6 -0
  3. mlpipe/artifacts/__init__.py +23 -0
  4. mlpipe/artifacts/manager.py +246 -0
  5. mlpipe/artifacts/serialization.py +67 -0
  6. mlpipe/cli/__init__.py +5 -0
  7. mlpipe/cli/main.py +667 -0
  8. mlpipe/core/__init__.py +35 -0
  9. mlpipe/core/config.py +76 -0
  10. mlpipe/core/exceptions.py +65 -0
  11. mlpipe/core/pipeline.py +435 -0
  12. mlpipe/core/result.py +50 -0
  13. mlpipe/data/__init__.py +20 -0
  14. mlpipe/data/ingestion.py +138 -0
  15. mlpipe/data/profiling.py +227 -0
  16. mlpipe/data/splitting.py +130 -0
  17. mlpipe/data/validation.py +248 -0
  18. mlpipe/evaluation/__init__.py +11 -0
  19. mlpipe/evaluation/evaluator.py +146 -0
  20. mlpipe/evaluation/metrics.py +53 -0
  21. mlpipe/explainability/__init__.py +5 -0
  22. mlpipe/explainability/importance.py +65 -0
  23. mlpipe/models/__init__.py +13 -0
  24. mlpipe/models/classification.py +156 -0
  25. mlpipe/models/registry.py +32 -0
  26. mlpipe/models/regression.py +126 -0
  27. mlpipe/models/selection.py +24 -0
  28. mlpipe/preprocessing/__init__.py +21 -0
  29. mlpipe/preprocessing/builder.py +163 -0
  30. mlpipe/preprocessing/categorical.py +17 -0
  31. mlpipe/preprocessing/datetime.py +55 -0
  32. mlpipe/preprocessing/numeric.py +17 -0
  33. mlpipe/tuning/__init__.py +10 -0
  34. mlpipe/tuning/search.py +140 -0
  35. mlpipe/tuning/spaces.py +11 -0
  36. mlpipe/utils/__init__.py +13 -0
  37. mlpipe/utils/hashing.py +15 -0
  38. mlpipe/utils/logging.py +37 -0
  39. mlpipe/utils/timing.py +33 -0
  40. mlpipe/version.py +3 -0
  41. mlpipe_cli-0.1.0.dist-info/METADATA +264 -0
  42. mlpipe_cli-0.1.0.dist-info/RECORD +46 -0
  43. mlpipe_cli-0.1.0.dist-info/WHEEL +5 -0
  44. mlpipe_cli-0.1.0.dist-info/entry_points.txt +2 -0
  45. mlpipe_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. mlpipe_cli-0.1.0.dist-info/top_level.txt +1 -0
mlpipe/cli/main.py ADDED
@@ -0,0 +1,667 @@
1
+ """
2
+ Terminal Command Line Interface for MLPipe.
3
+
4
+ Built with Typer and Rich to deliver a polished, developer-focused terminal experience.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ import sys
10
+ from typing import Optional
11
+
12
+ import pandas as pd
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+ import typer
17
+
18
+ from mlpipe.artifacts.manager import inspect_run_directory
19
+ from mlpipe.core.config import TaskType, TrainingMode
20
+ from mlpipe.core.exceptions import MLPipeError
21
+ from mlpipe.core.pipeline import Pipeline
22
+ from mlpipe.data.ingestion import load_dataset
23
+ from mlpipe.data.profiling import profile_dataset
24
+ from mlpipe.data.splitting import split_data
25
+ from mlpipe.data.validation import detect_task, validate_dataset
26
+ from mlpipe.version import __version__
27
+
28
+ # Cross-platform encoding-safe glyphs
29
+ def _can_encode(char: str) -> bool:
30
+ try:
31
+ char.encode(sys.stdout.encoding or "utf-8")
32
+ return True
33
+ except Exception:
34
+ return False
35
+
36
+ _USE_UNICODE = _can_encode("✓") and _can_encode("—")
37
+
38
+ CHECK = "✓" if _USE_UNICODE else "[OK]"
39
+ CROSS = "✗" if _USE_UNICODE else "[X]"
40
+ WARN = "⚠" if _USE_UNICODE else "[!]"
41
+ DASH = "—" if _USE_UNICODE else "-"
42
+ ARROW = "→" if _USE_UNICODE else "->"
43
+ BULLET = "•" if _USE_UNICODE else "*"
44
+ RULE = "─" if _USE_UNICODE else "-"
45
+
46
+ app = typer.Typer(
47
+ name="mlpipe",
48
+ help="MLPipe: Production-ready tabular ML automation library and terminal CLI.",
49
+ add_completion=False,
50
+ no_args_is_help=True,
51
+ )
52
+ console = Console()
53
+ err_console = Console(stderr=True)
54
+
55
+
56
+ def _version_callback(value: bool):
57
+ if value:
58
+ console.print(f"[bold cyan]MLPipe[/bold cyan] version [green]{__version__}[/green]")
59
+ raise typer.Exit()
60
+
61
+
62
+ @app.callback()
63
+ def main(
64
+ version: Optional[bool] = typer.Option(
65
+ None,
66
+ "--version",
67
+ "-v",
68
+ help="Show MLPipe version and exit.",
69
+ callback=_version_callback,
70
+ is_eager=True,
71
+ ),
72
+ ):
73
+ """Automate the machine learning lifecycle directly from the terminal."""
74
+ pass
75
+
76
+
77
+ @app.command("version")
78
+ def version_cmd():
79
+ """Display the installed MLPipe version."""
80
+ console.print(f"[bold cyan]MLPipe[/bold cyan] v[green]{__version__}[/green]")
81
+
82
+
83
+ # ─── 1. Profile Command ───────────────────────────────────────────────────────
84
+
85
+ @app.command("profile")
86
+ def profile_cmd(
87
+ dataset_path: Path = typer.Argument(
88
+ ...,
89
+ help="Path to the CSV dataset.",
90
+ exists=True,
91
+ dir_okay=False,
92
+ readable=True,
93
+ ),
94
+ format: str = typer.Option(
95
+ "human",
96
+ "--format",
97
+ "-f",
98
+ help="Output format: 'human' or 'json'.",
99
+ ),
100
+ ):
101
+ """Profile a dataset and view structure, data types, missingness, and distributions."""
102
+ try:
103
+ ds = load_dataset(dataset_path)
104
+ profile = profile_dataset(ds)
105
+
106
+ if format.lower() == "json":
107
+ print(profile.to_json())
108
+ return
109
+
110
+ # Human-readable output
111
+ console.print(f"\n[bold cyan]MLPipe Dataset Profile[/bold cyan] {DASH} [bold]{ds.filename}[/bold]\n")
112
+
113
+ # Overview Table
114
+ ov_table = Table(title="Dataset Overview", show_header=False, border_style="dim")
115
+ ov_table.add_row("Rows", f"{profile.num_rows:,}")
116
+ ov_table.add_row("Columns", f"{profile.num_cols:,}")
117
+ ov_table.add_row("Memory Usage", f"{profile.memory_mb} MB")
118
+ ov_table.add_row("Duplicate Rows", f"{profile.duplicate_rows:,}")
119
+ ov_table.add_row("Total Missing Values", f"{profile.total_missing_values:,}")
120
+ console.print(ov_table)
121
+ console.print()
122
+
123
+ # Columns Table
124
+ col_table = Table(title="Column Details", header_style="bold magenta")
125
+ col_table.add_column("Column", style="cyan")
126
+ col_table.add_column("Type", style="yellow")
127
+ col_table.add_column("Missing", justify="right")
128
+ col_table.add_column("Missing %", justify="right")
129
+ col_table.add_column("Unique", justify="right")
130
+ col_table.add_column("Stats / Top Values", style="dim")
131
+ col_table.add_column("Flags", style="red")
132
+
133
+ for c in profile.columns:
134
+ stats_str = ""
135
+ if c.detected_type == "numeric":
136
+ stats_str = f"min: {c.min_val}, max: {c.max_val}, mean: {c.mean_val}"
137
+ elif c.top_values:
138
+ stats_str = ", ".join(f"{v['value']} ({v['count']})" for v in c.top_values[:3])
139
+ elif c.min_date:
140
+ stats_str = f"{c.min_date} {ARROW} {c.max_date}"
141
+
142
+ flags_str = "; ".join(c.flags) if c.flags else DASH
143
+
144
+ col_table.add_row(
145
+ c.name,
146
+ c.detected_type,
147
+ f"{c.missing_count:,}",
148
+ f"{c.missing_pct:.1f}%",
149
+ f"{c.unique_count:,}",
150
+ stats_str,
151
+ flags_str,
152
+ )
153
+
154
+ console.print(col_table)
155
+
156
+ if profile.warnings:
157
+ console.print("\n[bold yellow]Dataset Warnings:[/bold yellow]")
158
+ for w in profile.warnings:
159
+ console.print(f" [yellow]{WARN}[/yellow] {w}")
160
+ console.print()
161
+
162
+ except MLPipeError as e:
163
+ err_console.print(f"[bold red]Error:[/bold red] {e}")
164
+ raise typer.Exit(code=1)
165
+ except Exception as e:
166
+ err_console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
167
+ raise typer.Exit(code=1)
168
+
169
+
170
+ # ─── 2. Validate Command ──────────────────────────────────────────────────────
171
+
172
+ @app.command("validate")
173
+ def validate_cmd(
174
+ dataset_path: Path = typer.Argument(
175
+ ...,
176
+ help="Path to the CSV dataset.",
177
+ exists=True,
178
+ dir_okay=False,
179
+ readable=True,
180
+ ),
181
+ target: str = typer.Option(
182
+ ...,
183
+ "--target",
184
+ "-t",
185
+ help="Name of the target column to predict.",
186
+ ),
187
+ task: str = typer.Option(
188
+ "auto",
189
+ "--task",
190
+ help="Task type override ('auto', 'classification', 'regression').",
191
+ ),
192
+ format: str = typer.Option(
193
+ "human",
194
+ "--format",
195
+ "-f",
196
+ help="Output format: 'human' or 'json'.",
197
+ ),
198
+ ):
199
+ """Validate dataset suitability and verify pre-training feasibility."""
200
+ try:
201
+ ds = load_dataset(dataset_path)
202
+ report = validate_dataset(ds, target_column=target, task_override=task)
203
+
204
+ if format.lower() == "json":
205
+ print(report.to_json())
206
+ if not report.is_valid:
207
+ raise typer.Exit(code=1)
208
+ return
209
+
210
+ console.print(f"\n[bold cyan]MLPipe Data Validation[/bold cyan] {DASH} [bold]{ds.filename}[/bold]\n")
211
+
212
+ # Status
213
+ status_color = "green" if report.is_valid else "red"
214
+ status_text = "PASSED" if report.is_valid else "FAILED"
215
+ console.print(f"Status: [bold {status_color}]{status_text}[/bold {status_color}]")
216
+ console.print(f"Detected Task: [bold]{report.detected_task.capitalize()}[/bold]")
217
+ console.print(f"Target Column: [bold]{target}[/bold]\n")
218
+
219
+ if report.errors:
220
+ console.print("[bold red]Fatal Errors:[/bold red]")
221
+ for err in report.errors:
222
+ console.print(f" [bold red]{CROSS}[/bold red] {err}")
223
+ console.print()
224
+
225
+ if report.warnings:
226
+ console.print("[bold yellow]Warnings (Non-blocking):[/bold yellow]")
227
+ for warn in report.warnings:
228
+ console.print(f" [bold yellow]{WARN}[/bold yellow] {warn}")
229
+ console.print()
230
+
231
+ if report.is_valid:
232
+ console.print(f"[bold green]{CHECK} Dataset is ready for automated training.[/bold green]\n")
233
+ else:
234
+ raise typer.Exit(code=1)
235
+
236
+ except typer.Exit:
237
+ raise
238
+ except MLPipeError as e:
239
+ err_console.print(f"[bold red]Error:[/bold red] {e}")
240
+ raise typer.Exit(code=1)
241
+ except Exception as e:
242
+ err_console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
243
+ raise typer.Exit(code=1)
244
+
245
+
246
+ # ─── 3. Train Command ─────────────────────────────────────────────────────────
247
+
248
+ @app.command("train")
249
+ def train_cmd(
250
+ dataset_path: Path = typer.Argument(
251
+ ...,
252
+ help="Path to the CSV dataset to train on.",
253
+ exists=True,
254
+ dir_okay=False,
255
+ readable=True,
256
+ ),
257
+ target: str = typer.Option(
258
+ ...,
259
+ "--target",
260
+ "-t",
261
+ help="Name of the target column to predict.",
262
+ ),
263
+ task: str = typer.Option(
264
+ "auto",
265
+ "--task",
266
+ help="Task type: 'auto', 'classification', or 'regression'.",
267
+ ),
268
+ mode: str = typer.Option(
269
+ "balanced",
270
+ "--mode",
271
+ "-m",
272
+ help="Training mode budget: 'fast', 'balanced', or 'thorough'.",
273
+ ),
274
+ output: Path = typer.Option(
275
+ Path("./mlpipe_runs"),
276
+ "--output",
277
+ "-o",
278
+ help="Base directory to save run artifacts.",
279
+ ),
280
+ format: str = typer.Option(
281
+ "human",
282
+ "--format",
283
+ "-f",
284
+ help="Output format: 'human' or 'json'.",
285
+ ),
286
+ verbose: bool = typer.Option(
287
+ False,
288
+ "--verbose",
289
+ help="Enable detailed logging.",
290
+ ),
291
+ export_splits: Optional[Path] = typer.Option(
292
+ None,
293
+ "--export-splits",
294
+ "-s",
295
+ help="Optional destination directory to copy train.csv, test.csv, and test_predictions.csv.",
296
+ ),
297
+ ):
298
+ """Train multiple candidate ML models, tune hyperparameters, evaluate, and save artifacts."""
299
+ try:
300
+ pipeline = Pipeline(
301
+ target=target,
302
+ task=task,
303
+ mode=mode,
304
+ output_dir=output,
305
+ verbose=verbose,
306
+ )
307
+
308
+ if format.lower() != "json":
309
+ console.print(f"\n[bold cyan]MLPipe[/bold cyan] v[green]{__version__}[/green]\n")
310
+
311
+ # Load dataset for display header
312
+ ds = load_dataset(dataset_path)
313
+
314
+ console.print("[bold]Dataset[/bold]")
315
+ console.print(RULE * 36)
316
+ console.print(f"File {ds.filename}")
317
+ console.print(f"Rows {ds.num_rows:,}")
318
+ console.print(f"Columns {ds.num_cols:,}")
319
+ console.print(f"Target {target}\n")
320
+
321
+ def progress_hook(stage: str, msg: str):
322
+ if stage == "ingestion":
323
+ console.print(f"[green]{CHECK}[/green] Dataset loaded")
324
+ elif stage == "profiling":
325
+ console.print(f"[green]{CHECK}[/green] Dataset profiled")
326
+ elif stage == "validation":
327
+ console.print(f"[green]{CHECK}[/green] Validation passed")
328
+ elif stage == "task_detection":
329
+ console.print(f"[green]{CHECK}[/green] {msg}")
330
+ elif stage == "splitting":
331
+ console.print(f"[green]{CHECK}[/green] Data split into Training & Testing sets")
332
+ elif stage == "preprocessing":
333
+ console.print("\n[bold]Preprocessing[/bold]")
334
+ console.print(f"[green]{CHECK}[/green] Numeric features")
335
+ console.print(f"[green]{CHECK}[/green] Categorical features")
336
+ console.print(f"[green]{CHECK}[/green] Missing-value handling\n")
337
+ console.print("[bold]Training[/bold]")
338
+ console.print(RULE * 36)
339
+ elif stage == "training":
340
+ model_part = msg.replace("Training & tuning candidate: ", "").replace("...", "")
341
+ console.print(f"[green]{CHECK}[/green] {model_part}")
342
+
343
+ result = pipeline.fit(ds, on_progress=progress_hook)
344
+
345
+ # Leaderboard Table
346
+ console.print("\n[bold]Model Evaluation[/bold]")
347
+ console.print(RULE * 36)
348
+
349
+ lb_table = Table(header_style="bold cyan", border_style="dim")
350
+ lb_table.add_column("Model", style="bold")
351
+ lb_table.add_column(f"CV ({result.primary_metric})", justify="right", style="yellow")
352
+ lb_table.add_column(f"Test ({result.primary_metric})", justify="right", style="green")
353
+ lb_table.add_column("Time", justify="right", style="dim")
354
+ lb_table.add_column("Status")
355
+
356
+ for row in result.leaderboard:
357
+ cv_val = f"{row['cv_score']:.4f}" if row['cv_score'] is not None else DASH
358
+ test_val = f"{row['test_score']:.4f}" if row['test_score'] is not None else DASH
359
+ time_val = f"{row['training_time_s']:.1f}s"
360
+ status_val = f"[green]{CHECK}[/green]" if row['status'] == "success" else f"[red]{CROSS} ({row.get('error', 'fail')})[/red]"
361
+
362
+ lb_table.add_row(row["model"], cv_val, test_val, time_val, status_val)
363
+
364
+ console.print(lb_table)
365
+ console.print(f"\n[green]{CHECK} Best model selected[/green]\n")
366
+
367
+ # Best Model Panel
368
+ panel_content = (
369
+ f"[bold green]{result.best_model_name}[/bold green]\n"
370
+ f"Primary metric: [bold]{result.primary_metric}[/bold]\n"
371
+ f"CV Score: [bold]{result.best_cv_score:.4f}[/bold]\n"
372
+ f"Test Score: [bold]{result.test_score:.4f}[/bold]"
373
+ )
374
+ console.print(Panel(panel_content, title="Best Model", border_style="green"))
375
+
376
+ # Hold-Out Test Set Verification Preview (Results displayed in front of the user)
377
+ if result.test_preview:
378
+ console.print(f"\n[bold]Hold-Out Test Set Verification Preview[/bold] (First {len(result.test_preview)} rows)")
379
+ console.print(RULE * 44)
380
+
381
+ test_table = Table(header_style="bold cyan", border_style="dim")
382
+ test_table.add_column("Row", style="dim", justify="right")
383
+ test_table.add_column("Sample Features", style="cyan")
384
+ test_table.add_column(f"Actual ({target})", justify="right", style="bold yellow")
385
+ test_table.add_column(f"Predicted ({target})", justify="right", style="bold green")
386
+ test_table.add_column("Evaluation", justify="center")
387
+
388
+ for row_data in result.test_preview:
389
+ feat_str = ", ".join(f"{k}={v}" for k, v in row_data.get("features", {}).items())
390
+ act_val = row_data["actual"]
391
+ pred_val = row_data["predicted"]
392
+
393
+ if result.task_type == "classification":
394
+ match = row_data.get("match", False)
395
+ eval_str = f"[bold green]{CHECK} Match[/bold green]" if match else f"[bold red]{CROSS} Mismatch[/bold red]"
396
+ test_table.add_row(f"#{row_data['row_idx']}", feat_str, str(act_val), str(pred_val), eval_str)
397
+ else:
398
+ err = row_data.get("error", 0.0)
399
+ eval_str = f"Diff: {err:,.2f}"
400
+ test_table.add_row(
401
+ f"#{row_data['row_idx']}",
402
+ feat_str,
403
+ f"{act_val:,.2f}" if isinstance(act_val, (int, float)) else str(act_val),
404
+ f"{pred_val:,.2f}" if isinstance(pred_val, (int, float)) else str(pred_val),
405
+ eval_str,
406
+ )
407
+
408
+ console.print(test_table)
409
+
410
+ # Generated Datasets and Artifacts
411
+ console.print("\n[bold]Generated Datasets & Artifacts[/bold]")
412
+ console.print(RULE * 44)
413
+ if result.train_path and result.train_path.exists():
414
+ console.print(f"[green]{CHECK}[/green] Training dataset: [bold cyan]{result.train_path.name}[/bold cyan] ({result.metadata.get('train_rows', 0):,} rows)")
415
+ if result.test_path and result.test_path.exists():
416
+ console.print(f"[green]{CHECK}[/green] Testing dataset: [bold cyan]{result.test_path.name}[/bold cyan] ({result.metadata.get('test_rows', 0):,} rows)")
417
+ if result.test_predictions_path and result.test_predictions_path.exists():
418
+ console.print(f"[green]{CHECK}[/green] Test predictions: [bold cyan]{result.test_predictions_path.name}[/bold cyan] (features + actual + predicted)")
419
+ console.print(f"[green]{CHECK}[/green] Pipeline saved: [bold cyan]pipeline.joblib[/bold cyan]")
420
+ console.print(f"[green]{CHECK}[/green] Model saved: [bold cyan]model.joblib[/bold cyan]")
421
+ console.print(f"[green]{CHECK}[/green] Metrics saved: [bold cyan]metrics.json[/bold cyan]")
422
+ console.print(f"[green]{CHECK}[/green] Run report: [bold cyan]report.txt[/bold cyan]\n")
423
+
424
+ if export_splits:
425
+ import shutil
426
+ export_splits.mkdir(parents=True, exist_ok=True)
427
+ if result.train_path and result.train_path.exists():
428
+ shutil.copy(result.train_path, export_splits / "train.csv")
429
+ if result.test_path and result.test_path.exists():
430
+ shutil.copy(result.test_path, export_splits / "test.csv")
431
+ if result.test_predictions_path and result.test_predictions_path.exists():
432
+ shutil.copy(result.test_predictions_path, export_splits / "test_predictions.csv")
433
+ console.print(f"[green]{CHECK}[/green] Exported splits copied to: [bold]{export_splits}[/bold]\n")
434
+
435
+ console.print(f"[bold]Output Directory:[/bold]\n{result.artifacts_dir}\n")
436
+
437
+ else:
438
+ # JSON format
439
+ result = pipeline.fit(dataset_path)
440
+ print(json.dumps(result.to_dict(), indent=2))
441
+
442
+ except MLPipeError as e:
443
+ err_console.print(f"\n[bold red]MLPipe Error:[/bold red]\n{e}")
444
+ raise typer.Exit(code=1)
445
+ except Exception as e:
446
+ err_console.print(f"\n[bold red]Unexpected Error:[/bold red] {e}")
447
+ if verbose:
448
+ err_console.print_exception()
449
+ raise typer.Exit(code=1)
450
+
451
+
452
+ # ─── 4. Predict Command ───────────────────────────────────────────────────────
453
+
454
+ @app.command("predict")
455
+ def predict_cmd(
456
+ model_path: Path = typer.Argument(
457
+ ...,
458
+ help="Path to pipeline.joblib or run directory.",
459
+ exists=True,
460
+ ),
461
+ data_path: Path = typer.Argument(
462
+ ...,
463
+ help="Path to the new CSV data for predictions.",
464
+ exists=True,
465
+ dir_okay=False,
466
+ ),
467
+ output: Optional[Path] = typer.Option(
468
+ None,
469
+ "--output",
470
+ "-o",
471
+ help="Optional path to save predictions as a CSV.",
472
+ ),
473
+ ):
474
+ """Generate predictions on new data using a trained MLPipe pipeline."""
475
+ try:
476
+ pipeline = Pipeline.load(model_path)
477
+ predictions = pipeline.predict(data_path)
478
+
479
+ console.print("\n[bold cyan]Predictions[/bold cyan]")
480
+ console.print(RULE * 24)
481
+
482
+ # Show up to first 10 predictions
483
+ sample_size = min(10, len(predictions))
484
+ for i in range(sample_size):
485
+ console.print(f"Row {i+1:<4} [bold]{predictions[i]}[/bold]")
486
+
487
+ if len(predictions) > sample_size:
488
+ console.print(f"[dim]... and {len(predictions) - sample_size:,} more rows[/dim]")
489
+
490
+ console.print(f"\n[green]{CHECK}[/green] [bold]{len(predictions):,}[/bold] predictions generated.\n")
491
+
492
+ if output:
493
+ out_df = pd.DataFrame({"prediction": predictions})
494
+ out_df.to_csv(output, index=False)
495
+ console.print(f"[green]{CHECK}[/green] Saved predictions to [bold]{output}[/bold]\n")
496
+
497
+ except MLPipeError as e:
498
+ err_console.print(f"[bold red]Error:[/bold red] {e}")
499
+ raise typer.Exit(code=1)
500
+ except Exception as e:
501
+ err_console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
502
+ raise typer.Exit(code=1)
503
+
504
+
505
+ # ─── 5. Inspect Command ───────────────────────────────────────────────────────
506
+
507
+ @app.command("inspect")
508
+ def inspect_cmd(
509
+ run_dir: Path = typer.Argument(
510
+ ...,
511
+ help="Path to the MLPipe run directory (e.g. ./mlpipe_runs/<run_id>).",
512
+ exists=True,
513
+ file_okay=False,
514
+ ),
515
+ format: str = typer.Option(
516
+ "human",
517
+ "--format",
518
+ "-f",
519
+ help="Output format: 'human' or 'json'.",
520
+ ),
521
+ ):
522
+ """Inspect previous run metadata, leaderboard, and artifacts."""
523
+ try:
524
+ meta = inspect_run_directory(run_dir)
525
+
526
+ if format.lower() == "json":
527
+ print(json.dumps(meta, indent=2))
528
+ return
529
+
530
+ console.print(f"\n[bold cyan]MLPipe Run Inspection[/bold cyan] {DASH} [bold]{meta.get('run_id')}[/bold]\n")
531
+
532
+ table = Table(title="Run Summary", show_header=False, border_style="dim")
533
+ table.add_row("Run ID", str(meta.get("run_id")))
534
+ table.add_row("Timestamp", str(meta.get("timestamp")))
535
+ table.add_row("Dataset", f"{meta.get('dataset', {}).get('filename')} ({meta.get('dataset', {}).get('rows')} rows)")
536
+ table.add_row("Target", str(meta.get("target_column")))
537
+ table.add_row("Task", str(meta.get("task_type")).capitalize())
538
+ table.add_row("Best Model", f"[bold green]{meta.get('best_model')}[/bold green]")
539
+ table.add_row("Primary Metric", str(meta.get("primary_metric")))
540
+ table.add_row("CV Score", str(meta.get("best_cv_score")))
541
+ table.add_row("Test Score", str(meta.get("test_score")))
542
+ table.add_row("Training Mode", str(meta.get("training_mode")))
543
+ table.add_row("Seed", str(meta.get("random_seed")))
544
+ table.add_row("Elapsed Time", f"{meta.get('elapsed_time_s', 0):.2f}s")
545
+
546
+ if "splits" in meta and meta["splits"].get("train_samples") is not None:
547
+ sp = meta["splits"]
548
+ table.add_row("Training Split", f"{sp['train_samples']:,} rows ({sp.get('train_file')})")
549
+ table.add_row("Testing Split", f"{sp['test_samples']:,} rows ({sp.get('test_file')})")
550
+ table.add_row("Predictions File", str(sp.get("predictions_file")))
551
+
552
+ console.print(table)
553
+ console.print()
554
+
555
+ # Available Artifacts
556
+ artifacts = meta.get("artifacts", [])
557
+ console.print("[bold]Generated Artifacts:[/bold]")
558
+ for art in artifacts:
559
+ console.print(f" [green]{BULLET}[/green] {art}")
560
+ console.print()
561
+
562
+ except MLPipeError as e:
563
+ err_console.print(f"[bold red]Error:[/bold red] {e}")
564
+ raise typer.Exit(code=1)
565
+ except Exception as e:
566
+ err_console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
567
+ raise typer.Exit(code=1)
568
+
569
+
570
+ # ─── 6. Split Command ─────────────────────────────────────────────────────────
571
+
572
+ @app.command("split")
573
+ def split_cmd(
574
+ dataset_path: Path = typer.Argument(
575
+ ...,
576
+ help="Path to the CSV dataset to split.",
577
+ exists=True,
578
+ dir_okay=False,
579
+ readable=True,
580
+ ),
581
+ target: str = typer.Option(
582
+ ...,
583
+ "--target",
584
+ "-t",
585
+ help="Target column to stratify (for classification) and preserve in splits.",
586
+ ),
587
+ test_size: float = typer.Option(
588
+ 0.20,
589
+ "--test-size",
590
+ "-s",
591
+ help="Fraction of data for the holdout test set (default: 0.20).",
592
+ ),
593
+ output_dir: Path = typer.Option(
594
+ Path("./splits"),
595
+ "--output-dir",
596
+ "-o",
597
+ help="Directory to save train.csv and test.csv.",
598
+ ),
599
+ seed: int = typer.Option(
600
+ 42,
601
+ "--seed",
602
+ help="Random seed for reproducible splitting.",
603
+ ),
604
+ ):
605
+ """Explicitly split a dataset into train.csv and test.csv without data leakage."""
606
+ try:
607
+ ds = load_dataset(dataset_path)
608
+ if target not in ds.data.columns:
609
+ raise MLPipeError(f"Target column '{target}' not found in dataset columns: {list(ds.data.columns)}")
610
+
611
+ task_type = detect_task(ds.data[target])
612
+ split_res = split_data(
613
+ df=ds.data,
614
+ target_column=target,
615
+ task_type=task_type,
616
+ test_size=test_size,
617
+ random_seed=seed,
618
+ )
619
+
620
+ train_file, test_file = split_res.export(output_dir)
621
+
622
+ console.print(f"\n[bold cyan]MLPipe Dataset Split[/bold cyan] {DASH} [bold]{ds.filename}[/bold]\n")
623
+
624
+ table = Table(title="Data Splits Summary", header_style="bold cyan", border_style="dim")
625
+ table.add_column("Set", style="bold")
626
+ table.add_column("Rows", justify="right")
627
+ table.add_column("Percentage", justify="right")
628
+ table.add_column("Stratified", justify="center")
629
+ table.add_column("Saved Path", style="dim")
630
+
631
+ table.add_row(
632
+ "Training Set",
633
+ f"{split_res.train_size:,}",
634
+ f"{100*(1-test_size):.1f}%",
635
+ f"[green]{CHECK}[/green]" if split_res.is_stratified else DASH,
636
+ str(train_file.resolve()),
637
+ )
638
+ table.add_row(
639
+ "Testing Set",
640
+ f"{split_res.test_size:,}",
641
+ f"{100*test_size:.1f}%",
642
+ f"[green]{CHECK}[/green]" if split_res.is_stratified else DASH,
643
+ str(test_file.resolve()),
644
+ )
645
+
646
+ console.print(table)
647
+
648
+ # Show preview of test set rows right in front of user
649
+ console.print(f"\n[bold]Testing Set Preview[/bold] (First 5 hold-out rows)")
650
+ console.print(RULE * 36)
651
+ preview_cols = [c for c in split_res.test_df.columns if c != target][:3] + [target]
652
+ prev_table = Table(header_style="bold magenta", border_style="dim")
653
+ for c in preview_cols:
654
+ prev_table.add_column(c, style="bold yellow" if c == target else "white")
655
+ for _, row in split_res.test_df.head(5).iterrows():
656
+ prev_table.add_row(*[str(row[c]) for c in preview_cols])
657
+ console.print(prev_table)
658
+
659
+ console.print(f"\n[green]{CHECK}[/green] Datasets successfully exported to [bold]{output_dir}[/bold]\n")
660
+
661
+ except MLPipeError as e:
662
+ err_console.print(f"[bold red]Error:[/bold red] {e}")
663
+ raise typer.Exit(code=1)
664
+ except Exception as e:
665
+ err_console.print(f"[bold red]Unexpected Error:[/bold red] {e}")
666
+ raise typer.Exit(code=1)
667
+