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.
- mlpipe/__init__.py +43 -0
- mlpipe/__main__.py +6 -0
- mlpipe/artifacts/__init__.py +23 -0
- mlpipe/artifacts/manager.py +246 -0
- mlpipe/artifacts/serialization.py +67 -0
- mlpipe/cli/__init__.py +5 -0
- mlpipe/cli/main.py +667 -0
- mlpipe/core/__init__.py +35 -0
- mlpipe/core/config.py +76 -0
- mlpipe/core/exceptions.py +65 -0
- mlpipe/core/pipeline.py +435 -0
- mlpipe/core/result.py +50 -0
- mlpipe/data/__init__.py +20 -0
- mlpipe/data/ingestion.py +138 -0
- mlpipe/data/profiling.py +227 -0
- mlpipe/data/splitting.py +130 -0
- mlpipe/data/validation.py +248 -0
- mlpipe/evaluation/__init__.py +11 -0
- mlpipe/evaluation/evaluator.py +146 -0
- mlpipe/evaluation/metrics.py +53 -0
- mlpipe/explainability/__init__.py +5 -0
- mlpipe/explainability/importance.py +65 -0
- mlpipe/models/__init__.py +13 -0
- mlpipe/models/classification.py +156 -0
- mlpipe/models/registry.py +32 -0
- mlpipe/models/regression.py +126 -0
- mlpipe/models/selection.py +24 -0
- mlpipe/preprocessing/__init__.py +21 -0
- mlpipe/preprocessing/builder.py +163 -0
- mlpipe/preprocessing/categorical.py +17 -0
- mlpipe/preprocessing/datetime.py +55 -0
- mlpipe/preprocessing/numeric.py +17 -0
- mlpipe/tuning/__init__.py +10 -0
- mlpipe/tuning/search.py +140 -0
- mlpipe/tuning/spaces.py +11 -0
- mlpipe/utils/__init__.py +13 -0
- mlpipe/utils/hashing.py +15 -0
- mlpipe/utils/logging.py +37 -0
- mlpipe/utils/timing.py +33 -0
- mlpipe/version.py +3 -0
- mlpipe_cli-0.1.0.dist-info/METADATA +264 -0
- mlpipe_cli-0.1.0.dist-info/RECORD +46 -0
- mlpipe_cli-0.1.0.dist-info/WHEEL +5 -0
- mlpipe_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mlpipe_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- mlpipe_cli-0.1.0.dist-info/top_level.txt +1 -0
mlpipe/core/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Core modules for MLPipe."""
|
|
2
|
+
|
|
3
|
+
from mlpipe.core.config import PipelineConfig, TaskType, TrainingMode
|
|
4
|
+
from mlpipe.core.exceptions import (
|
|
5
|
+
ArtifactError,
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
DatasetError,
|
|
8
|
+
EvaluationError,
|
|
9
|
+
MLPipeError,
|
|
10
|
+
PipelineError,
|
|
11
|
+
PredictionError,
|
|
12
|
+
PreprocessingError,
|
|
13
|
+
TrainingError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from mlpipe.core.pipeline import Pipeline
|
|
17
|
+
from mlpipe.core.result import PipelineResult
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Pipeline",
|
|
21
|
+
"PipelineResult",
|
|
22
|
+
"PipelineConfig",
|
|
23
|
+
"TaskType",
|
|
24
|
+
"TrainingMode",
|
|
25
|
+
"MLPipeError",
|
|
26
|
+
"DatasetError",
|
|
27
|
+
"ValidationError",
|
|
28
|
+
"PreprocessingError",
|
|
29
|
+
"TrainingError",
|
|
30
|
+
"EvaluationError",
|
|
31
|
+
"ArtifactError",
|
|
32
|
+
"ConfigurationError",
|
|
33
|
+
"PredictionError",
|
|
34
|
+
"PipelineError",
|
|
35
|
+
]
|
mlpipe/core/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration models and defaults for MLPipe.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Union
|
|
9
|
+
|
|
10
|
+
from mlpipe.core.exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskType(str, Enum):
|
|
14
|
+
AUTO = "auto"
|
|
15
|
+
CLASSIFICATION = "classification"
|
|
16
|
+
REGRESSION = "regression"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TrainingMode(str, Enum):
|
|
20
|
+
FAST = "fast"
|
|
21
|
+
BALANCED = "balanced"
|
|
22
|
+
THOROUGH = "thorough"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PipelineConfig:
|
|
27
|
+
"""Configuration settings for an MLPipe run."""
|
|
28
|
+
|
|
29
|
+
target: str
|
|
30
|
+
task: Union[TaskType, str] = TaskType.AUTO
|
|
31
|
+
mode: Union[TrainingMode, str] = TrainingMode.BALANCED
|
|
32
|
+
test_size: float = 0.20
|
|
33
|
+
random_seed: int = 42
|
|
34
|
+
cv_folds: int = 5
|
|
35
|
+
output_dir: Path = field(default_factory=lambda: Path("./mlpipe_runs"))
|
|
36
|
+
verbose: bool = False
|
|
37
|
+
|
|
38
|
+
def __post_init__(self):
|
|
39
|
+
# Normalize and validate task
|
|
40
|
+
if isinstance(self.task, str):
|
|
41
|
+
try:
|
|
42
|
+
self.task = TaskType(self.task.lower())
|
|
43
|
+
except ValueError:
|
|
44
|
+
valid = [t.value for t in TaskType]
|
|
45
|
+
raise ConfigurationError(
|
|
46
|
+
f"Invalid task '{self.task}'. Must be one of {valid}.",
|
|
47
|
+
"Use 'auto', 'classification', or 'regression'."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Normalize and validate mode
|
|
51
|
+
if isinstance(self.mode, str):
|
|
52
|
+
try:
|
|
53
|
+
self.mode = TrainingMode(self.mode.lower())
|
|
54
|
+
except ValueError:
|
|
55
|
+
valid = [m.value for m in TrainingMode]
|
|
56
|
+
raise ConfigurationError(
|
|
57
|
+
f"Invalid training mode '{self.mode}'. Must be one of {valid}.",
|
|
58
|
+
"Use 'fast', 'balanced', or 'thorough'."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Validate test_size
|
|
62
|
+
if not (0.05 <= self.test_size <= 0.5):
|
|
63
|
+
raise ConfigurationError(
|
|
64
|
+
f"Invalid test_size '{self.test_size}'. Must be between 0.05 and 0.50.",
|
|
65
|
+
"Choose a reasonable test split ratio, such as 0.20 (20%)."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Validate cv_folds
|
|
69
|
+
if self.cv_folds < 2:
|
|
70
|
+
raise ConfigurationError(
|
|
71
|
+
f"cv_folds must be at least 2, got {self.cv_folds}.",
|
|
72
|
+
"Set cv_folds to at least 2, typically 3 or 5."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if not isinstance(self.output_dir, Path):
|
|
76
|
+
self.output_dir = Path(self.output_dir)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for MLPipe.
|
|
3
|
+
|
|
4
|
+
Provides domain-specific errors with user-friendly descriptions and suggested actions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MLPipeError(Exception):
|
|
11
|
+
"""Base exception for all MLPipe errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, suggested_action: Optional[str] = None):
|
|
14
|
+
self.message = message
|
|
15
|
+
self.suggested_action = suggested_action
|
|
16
|
+
full_msg = message
|
|
17
|
+
if suggested_action:
|
|
18
|
+
full_msg += f"\n\nSuggested action:\n{suggested_action}"
|
|
19
|
+
super().__init__(full_msg)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatasetError(MLPipeError):
|
|
23
|
+
"""Raised when there is an issue loading, reading, or parsing a dataset."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ValidationError(MLPipeError):
|
|
28
|
+
"""Raised when dataset pre-training validation fails fatal checks."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PreprocessingError(MLPipeError):
|
|
33
|
+
"""Raised when feature preprocessing or transformation fails."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TrainingError(MLPipeError):
|
|
38
|
+
"""Raised when model training or hyperparameter tuning fails."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EvaluationError(MLPipeError):
|
|
43
|
+
"""Raised when model evaluation fails."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ArtifactError(MLPipeError):
|
|
48
|
+
"""Raised when saving, loading, or managing artifacts fails."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConfigurationError(MLPipeError):
|
|
53
|
+
"""Raised when invalid options or configurations are provided."""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PredictionError(MLPipeError):
|
|
58
|
+
"""Raised when generating predictions fails."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PipelineError(MLPipeError):
|
|
63
|
+
"""Raised when general pipeline execution fails."""
|
|
64
|
+
pass
|
|
65
|
+
|
mlpipe/core/pipeline.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Pipeline orchestrator for MLPipe.
|
|
3
|
+
|
|
4
|
+
Provides the primary user-facing Python API for automated tabular machine learning:
|
|
5
|
+
data ingestion, validation, splitting, preprocessing, multi-model training,
|
|
6
|
+
cross-validation, hyperparameter tuning, model evaluation, explainability,
|
|
7
|
+
and artifact management.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from sklearn.pipeline import Pipeline as SklearnPipeline
|
|
17
|
+
|
|
18
|
+
from mlpipe.artifacts.manager import ArtifactManager, load_pipeline_artifact
|
|
19
|
+
from mlpipe.artifacts.serialization import save_joblib
|
|
20
|
+
from mlpipe.core.config import PipelineConfig, TaskType, TrainingMode
|
|
21
|
+
from mlpipe.core.exceptions import (
|
|
22
|
+
ConfigurationError,
|
|
23
|
+
DatasetError,
|
|
24
|
+
PipelineError,
|
|
25
|
+
PredictionError,
|
|
26
|
+
TrainingError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
from mlpipe.core.result import PipelineResult
|
|
30
|
+
from mlpipe.data.ingestion import Dataset, load_dataset
|
|
31
|
+
from mlpipe.data.profiling import DatasetProfile, profile_dataset
|
|
32
|
+
from mlpipe.data.splitting import SplitData, split_data
|
|
33
|
+
from mlpipe.data.validation import ValidationReport, validate_dataset
|
|
34
|
+
from mlpipe.evaluation.evaluator import build_leaderboard, evaluate_pipeline_on_test
|
|
35
|
+
from mlpipe.evaluation.metrics import select_primary_metric
|
|
36
|
+
from mlpipe.explainability.importance import extract_feature_importance
|
|
37
|
+
from mlpipe.models.selection import get_candidates_for_task
|
|
38
|
+
from mlpipe.preprocessing.builder import classify_columns, build_preprocessor
|
|
39
|
+
from mlpipe.tuning.search import TuningResult, tune_candidate
|
|
40
|
+
from mlpipe.utils.logging import configure_logging, get_logger
|
|
41
|
+
|
|
42
|
+
logger = get_logger("pipeline")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Pipeline:
|
|
46
|
+
"""
|
|
47
|
+
MLPipe Automated Machine Learning Pipeline.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
```python
|
|
51
|
+
from mlpipe import Pipeline
|
|
52
|
+
|
|
53
|
+
pipe = Pipeline(target="churn", task="auto", mode="balanced")
|
|
54
|
+
result = pipe.fit("customer_churn.csv")
|
|
55
|
+
|
|
56
|
+
print(result.best_model)
|
|
57
|
+
print(result.metrics)
|
|
58
|
+
|
|
59
|
+
predictions = pipe.predict("new_data.csv")
|
|
60
|
+
pipe.save("./model")
|
|
61
|
+
```
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
target: str,
|
|
67
|
+
task: Union[TaskType, str] = TaskType.AUTO,
|
|
68
|
+
mode: Union[TrainingMode, str] = TrainingMode.BALANCED,
|
|
69
|
+
test_size: float = 0.20,
|
|
70
|
+
random_seed: int = 42,
|
|
71
|
+
cv_folds: int = 5,
|
|
72
|
+
output_dir: Union[str, Path] = "./mlpipe_runs",
|
|
73
|
+
verbose: bool = False,
|
|
74
|
+
):
|
|
75
|
+
self.config = PipelineConfig(
|
|
76
|
+
target=target,
|
|
77
|
+
task=task,
|
|
78
|
+
mode=mode,
|
|
79
|
+
test_size=test_size,
|
|
80
|
+
random_seed=random_seed,
|
|
81
|
+
cv_folds=cv_folds,
|
|
82
|
+
output_dir=Path(output_dir),
|
|
83
|
+
verbose=verbose,
|
|
84
|
+
)
|
|
85
|
+
configure_logging(verbose=verbose)
|
|
86
|
+
|
|
87
|
+
self._fitted_pipeline: Optional[SklearnPipeline] = None
|
|
88
|
+
self._result: Optional[PipelineResult] = None
|
|
89
|
+
self._profile: Optional[DatasetProfile] = None
|
|
90
|
+
self._validation: Optional[ValidationReport] = None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_fitted(self) -> bool:
|
|
94
|
+
"""Whether the pipeline has been fitted."""
|
|
95
|
+
return self._fitted_pipeline is not None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def result(self) -> Optional[PipelineResult]:
|
|
99
|
+
"""Outcome of the latest fit operation."""
|
|
100
|
+
return self._result
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def profile(self) -> Optional[DatasetProfile]:
|
|
104
|
+
"""Dataset profile calculated during fit."""
|
|
105
|
+
return self._profile
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def validation(self) -> Optional[ValidationReport]:
|
|
109
|
+
"""Validation report generated during fit."""
|
|
110
|
+
return self._validation
|
|
111
|
+
|
|
112
|
+
def fit(
|
|
113
|
+
self,
|
|
114
|
+
data: Union[str, Path, pd.DataFrame, Dataset],
|
|
115
|
+
on_progress: Optional[Callable[[str, str], None]] = None,
|
|
116
|
+
) -> PipelineResult:
|
|
117
|
+
"""
|
|
118
|
+
Execute the complete automated machine learning lifecycle on the input dataset.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
data: Filepath to CSV, pandas DataFrame, or MLPipe Dataset.
|
|
122
|
+
on_progress: Optional callback invoked with (stage, message).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
PipelineResult with metrics, leaderboard, and artifact locations.
|
|
126
|
+
"""
|
|
127
|
+
def _notify(stage: str, msg: str):
|
|
128
|
+
logger.info("[%s] %s", stage, msg)
|
|
129
|
+
if on_progress:
|
|
130
|
+
on_progress(stage, msg)
|
|
131
|
+
|
|
132
|
+
t_start = time.perf_counter()
|
|
133
|
+
|
|
134
|
+
# ── 1. Data Ingestion ──────────────────────────────────────────────
|
|
135
|
+
_notify("ingestion", "Loading dataset...")
|
|
136
|
+
if isinstance(data, (str, Path)):
|
|
137
|
+
dataset = load_dataset(data)
|
|
138
|
+
elif isinstance(data, Dataset):
|
|
139
|
+
dataset = data
|
|
140
|
+
elif isinstance(data, pd.DataFrame):
|
|
141
|
+
dataset = Dataset(
|
|
142
|
+
filepath=Path("in_memory.csv"),
|
|
143
|
+
df=data.copy(),
|
|
144
|
+
num_rows=len(data),
|
|
145
|
+
num_cols=len(data.columns),
|
|
146
|
+
memory_bytes=int(data.memory_usage(deep=True).sum()),
|
|
147
|
+
sha256_hash="in_memory_dataframe",
|
|
148
|
+
column_names=list(data.columns),
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
raise DatasetError(f"Unsupported data type '{type(data)}'. Provide a CSV path or DataFrame.")
|
|
152
|
+
|
|
153
|
+
# ── 2. Data Profiling ──────────────────────────────────────────────
|
|
154
|
+
_notify("profiling", "Profiling dataset structure and distributions...")
|
|
155
|
+
self._profile = profile_dataset(dataset)
|
|
156
|
+
|
|
157
|
+
# ── 3. Data Validation ─────────────────────────────────────────────
|
|
158
|
+
_notify("validation", "Validating dataset and task compatibility...")
|
|
159
|
+
self._validation = validate_dataset(
|
|
160
|
+
dataset=dataset,
|
|
161
|
+
target_column=self.config.target,
|
|
162
|
+
task_override=self.config.task.value,
|
|
163
|
+
test_size=self.config.test_size,
|
|
164
|
+
cv_folds=self.config.cv_folds,
|
|
165
|
+
)
|
|
166
|
+
self._validation.raise_if_invalid()
|
|
167
|
+
|
|
168
|
+
task_type = self._validation.detected_task
|
|
169
|
+
_notify("task_detection", f"Task detected: {task_type.capitalize()}")
|
|
170
|
+
|
|
171
|
+
# ── 4. Train / Test Splitting ──────────────────────────────────────
|
|
172
|
+
_notify("splitting", f"Splitting data ({int((1-self.config.test_size)*100)}% train / {int(self.config.test_size*100)}% test)...")
|
|
173
|
+
split_res: SplitData = split_data(
|
|
174
|
+
df=dataset.df,
|
|
175
|
+
target_column=self.config.target,
|
|
176
|
+
task_type=task_type,
|
|
177
|
+
test_size=self.config.test_size,
|
|
178
|
+
random_seed=self.config.random_seed,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# ── 5. Preprocessing Pipeline Construction ─────────────────────────
|
|
182
|
+
_notify("preprocessing", "Building feature preprocessing pipeline...")
|
|
183
|
+
# Classify columns strictly from training features to avoid leakage
|
|
184
|
+
column_assignments = classify_columns(split_res.X_train)
|
|
185
|
+
preprocessor = build_preprocessor(column_assignments, with_scaling=True)
|
|
186
|
+
|
|
187
|
+
# ── 6. Metric Selection ────────────────────────────────────────────
|
|
188
|
+
primary_metric = select_primary_metric(task_type, split_res.y_train)
|
|
189
|
+
_notify("metric_selection", f"Primary evaluation metric: {primary_metric}")
|
|
190
|
+
|
|
191
|
+
# ── 7. Model Selection & Tuning ────────────────────────────────────
|
|
192
|
+
candidates = get_candidates_for_task(task_type, mode=self.config.mode.value)
|
|
193
|
+
tuning_results: List[TuningResult] = []
|
|
194
|
+
|
|
195
|
+
for candidate in candidates:
|
|
196
|
+
_notify("training", f"Training & tuning candidate: {candidate.name}...")
|
|
197
|
+
res = tune_candidate(
|
|
198
|
+
candidate=candidate,
|
|
199
|
+
preprocessor=preprocessor,
|
|
200
|
+
X_train=split_res.X_train,
|
|
201
|
+
y_train=split_res.y_train,
|
|
202
|
+
primary_metric=primary_metric,
|
|
203
|
+
cv_folds=self.config.cv_folds,
|
|
204
|
+
mode=self.config.mode.value,
|
|
205
|
+
random_seed=self.config.random_seed,
|
|
206
|
+
)
|
|
207
|
+
tuning_results.append(res)
|
|
208
|
+
|
|
209
|
+
# Check if all models failed
|
|
210
|
+
successful_runs = [r for r in tuning_results if r.best_pipeline is not None and not r.error]
|
|
211
|
+
if not successful_runs:
|
|
212
|
+
error_details = "\n".join(f"- {r.candidate_name}: {r.error}" for r in tuning_results)
|
|
213
|
+
raise TrainingError(
|
|
214
|
+
f"All candidate models failed during training:\n{error_details}",
|
|
215
|
+
"Review the errors above and ensure features are compatible."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# ── 8. Leaderboard & Model Selection ───────────────────────────────
|
|
219
|
+
_notify("evaluation", "Compiling leaderboard and selecting winning model...")
|
|
220
|
+
leaderboard = build_leaderboard(
|
|
221
|
+
tuning_results=tuning_results,
|
|
222
|
+
X_test=split_res.X_test,
|
|
223
|
+
y_test=split_res.y_test,
|
|
224
|
+
task_type=task_type,
|
|
225
|
+
primary_metric=primary_metric,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
winning_entry = leaderboard[0]
|
|
229
|
+
best_model_name = winning_entry["model"]
|
|
230
|
+
best_cv_score = winning_entry["cv_score"]
|
|
231
|
+
test_score = winning_entry["test_score"]
|
|
232
|
+
test_metrics = winning_entry.get("test_metrics", {})
|
|
233
|
+
|
|
234
|
+
# Find winning pipeline
|
|
235
|
+
winning_result = next(r for r in tuning_results if r.candidate_name == best_model_name)
|
|
236
|
+
best_pipeline = winning_result.best_pipeline
|
|
237
|
+
self._fitted_pipeline = best_pipeline
|
|
238
|
+
|
|
239
|
+
# ── 9. Explainability ──────────────────────────────────────────────
|
|
240
|
+
_notify("explainability", "Extracting feature importances...")
|
|
241
|
+
feature_importance = extract_feature_importance(best_pipeline)
|
|
242
|
+
|
|
243
|
+
# ── 10. Test Set Predictions & Preview ─────────────────────────────
|
|
244
|
+
_notify("evaluation", "Generating hold-out test set predictions...")
|
|
245
|
+
y_test_pred = best_pipeline.predict(split_res.X_test)
|
|
246
|
+
|
|
247
|
+
test_predictions_df = split_res.X_test.copy()
|
|
248
|
+
test_predictions_df[f"actual_{self.config.target}"] = split_res.y_test.values
|
|
249
|
+
test_predictions_df[f"predicted_{self.config.target}"] = y_test_pred
|
|
250
|
+
|
|
251
|
+
test_preview = []
|
|
252
|
+
preview_n = min(10, len(split_res.X_test))
|
|
253
|
+
candidate_cols = [c for c in split_res.X_test.columns if "id" not in c.lower() and "uuid" not in c.lower()]
|
|
254
|
+
if not candidate_cols:
|
|
255
|
+
candidate_cols = list(split_res.X_test.columns)
|
|
256
|
+
preview_cols = candidate_cols[:3]
|
|
257
|
+
|
|
258
|
+
if task_type == "classification":
|
|
259
|
+
matches = (split_res.y_test.values == y_test_pred)
|
|
260
|
+
test_predictions_df["match"] = matches
|
|
261
|
+
for i in range(preview_n):
|
|
262
|
+
sample_feats = {
|
|
263
|
+
col: (round(float(split_res.X_test.iloc[i][col]), 2) if isinstance(split_res.X_test.iloc[i][col], (float, np.floating)) else split_res.X_test.iloc[i][col])
|
|
264
|
+
for col in preview_cols
|
|
265
|
+
}
|
|
266
|
+
test_preview.append({
|
|
267
|
+
"row_idx": int(i + 1),
|
|
268
|
+
"actual": split_res.y_test.values[i],
|
|
269
|
+
"predicted": y_test_pred[i],
|
|
270
|
+
"match": bool(matches[i]),
|
|
271
|
+
"features": sample_feats,
|
|
272
|
+
})
|
|
273
|
+
else:
|
|
274
|
+
abs_err = np.abs(split_res.y_test.values - y_test_pred)
|
|
275
|
+
test_predictions_df["absolute_error"] = np.round(abs_err, 4)
|
|
276
|
+
for i in range(preview_n):
|
|
277
|
+
sample_feats = {
|
|
278
|
+
col: (round(float(split_res.X_test.iloc[i][col]), 2) if isinstance(split_res.X_test.iloc[i][col], (float, np.floating)) else split_res.X_test.iloc[i][col])
|
|
279
|
+
for col in preview_cols
|
|
280
|
+
}
|
|
281
|
+
test_preview.append({
|
|
282
|
+
"row_idx": int(i + 1),
|
|
283
|
+
"actual": float(np.round(split_res.y_test.values[i], 4)),
|
|
284
|
+
"predicted": float(np.round(y_test_pred[i], 4)),
|
|
285
|
+
"error": float(np.round(abs_err[i], 4)),
|
|
286
|
+
"features": sample_feats,
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
# ── 11. Artifact Generation ────────────────────────────────────────
|
|
290
|
+
_notify("artifacts", "Saving run artifacts and pipeline...")
|
|
291
|
+
artifact_mgr = ArtifactManager(base_output_dir=self.config.output_dir)
|
|
292
|
+
run_id = artifact_mgr.generate_run_id()
|
|
293
|
+
elapsed_s = round(time.perf_counter() - t_start, 2)
|
|
294
|
+
|
|
295
|
+
saved_dir = artifact_mgr.save_run_artifacts(
|
|
296
|
+
run_id=run_id,
|
|
297
|
+
pipeline=best_pipeline,
|
|
298
|
+
dataset_summary=dataset.summary(),
|
|
299
|
+
target_column=self.config.target,
|
|
300
|
+
task_type=task_type,
|
|
301
|
+
training_mode=self.config.mode.value,
|
|
302
|
+
primary_metric=primary_metric,
|
|
303
|
+
best_model_name=best_model_name,
|
|
304
|
+
best_cv_score=best_cv_score,
|
|
305
|
+
test_score=test_score,
|
|
306
|
+
test_metrics=test_metrics,
|
|
307
|
+
leaderboard=leaderboard,
|
|
308
|
+
feature_importance=feature_importance,
|
|
309
|
+
elapsed_time_s=elapsed_s,
|
|
310
|
+
random_seed=self.config.random_seed,
|
|
311
|
+
train_df=split_res.train_df,
|
|
312
|
+
test_df=split_res.test_df,
|
|
313
|
+
test_predictions_df=test_predictions_df,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
self._result = PipelineResult(
|
|
317
|
+
run_id=run_id,
|
|
318
|
+
target_column=self.config.target,
|
|
319
|
+
task_type=task_type,
|
|
320
|
+
best_model_name=best_model_name,
|
|
321
|
+
primary_metric=primary_metric,
|
|
322
|
+
best_cv_score=best_cv_score,
|
|
323
|
+
test_score=test_score,
|
|
324
|
+
leaderboard=leaderboard,
|
|
325
|
+
test_metrics=test_metrics,
|
|
326
|
+
feature_importance=feature_importance,
|
|
327
|
+
artifacts_dir=saved_dir,
|
|
328
|
+
elapsed_time_s=elapsed_s,
|
|
329
|
+
metadata={
|
|
330
|
+
"training_mode": self.config.mode.value,
|
|
331
|
+
"random_seed": self.config.random_seed,
|
|
332
|
+
"train_rows": split_res.train_size,
|
|
333
|
+
"test_rows": split_res.test_size,
|
|
334
|
+
},
|
|
335
|
+
test_preview=test_preview,
|
|
336
|
+
train_path=saved_dir / "train.csv",
|
|
337
|
+
test_path=saved_dir / "test.csv",
|
|
338
|
+
test_predictions_path=saved_dir / "test_predictions.csv",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
_notify("complete", f"Pipeline complete in {elapsed_s}s. Best model: {best_model_name} ({best_cv_score})")
|
|
342
|
+
return self._result
|
|
343
|
+
|
|
344
|
+
def predict(self, data: Union[str, Path, pd.DataFrame]) -> np.ndarray:
|
|
345
|
+
"""
|
|
346
|
+
Generate predictions using the fitted pipeline.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
data: Path to CSV or pandas DataFrame.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Numpy array of predictions.
|
|
353
|
+
"""
|
|
354
|
+
if not self.is_fitted or self._fitted_pipeline is None:
|
|
355
|
+
raise PredictionError(
|
|
356
|
+
"Pipeline has not been fitted or loaded.",
|
|
357
|
+
"Call pipeline.fit(data) or Pipeline.load(path) before calling predict."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if isinstance(data, (str, Path)):
|
|
361
|
+
dataset = load_dataset(data)
|
|
362
|
+
df = dataset.df
|
|
363
|
+
elif isinstance(data, pd.DataFrame):
|
|
364
|
+
df = data.copy()
|
|
365
|
+
else:
|
|
366
|
+
raise PredictionError(f"Unsupported data type '{type(data)}' for prediction.")
|
|
367
|
+
|
|
368
|
+
# Drop target column if present in prediction data (e.g. test set)
|
|
369
|
+
if self.config.target in df.columns:
|
|
370
|
+
df = df.drop(columns=[self.config.target])
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
return self._fitted_pipeline.predict(df)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
raise PredictionError(
|
|
376
|
+
f"Prediction failed: {e}",
|
|
377
|
+
"Ensure that the input data contains all required feature columns with compatible formats."
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def predict_proba(self, data: Union[str, Path, pd.DataFrame]) -> np.ndarray:
|
|
381
|
+
"""
|
|
382
|
+
Generate prediction probabilities for classification tasks.
|
|
383
|
+
"""
|
|
384
|
+
if not self.is_fitted or self._fitted_pipeline is None:
|
|
385
|
+
raise PredictionError("Pipeline has not been fitted or loaded.")
|
|
386
|
+
|
|
387
|
+
if not hasattr(self._fitted_pipeline, "predict_proba"):
|
|
388
|
+
raise PredictionError("The underlying best model does not support predict_proba.")
|
|
389
|
+
|
|
390
|
+
if isinstance(data, (str, Path)):
|
|
391
|
+
dataset = load_dataset(data)
|
|
392
|
+
df = dataset.df
|
|
393
|
+
elif isinstance(data, pd.DataFrame):
|
|
394
|
+
df = data.copy()
|
|
395
|
+
else:
|
|
396
|
+
raise PredictionError(f"Unsupported data type '{type(data)}' for prediction.")
|
|
397
|
+
|
|
398
|
+
if self.config.target in df.columns:
|
|
399
|
+
df = df.drop(columns=[self.config.target])
|
|
400
|
+
|
|
401
|
+
return self._fitted_pipeline.predict_proba(df)
|
|
402
|
+
|
|
403
|
+
def save(self, path: Union[str, Path]) -> Path:
|
|
404
|
+
"""
|
|
405
|
+
Save the fitted pipeline to a file or directory.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
path: Destination file or directory.
|
|
409
|
+
"""
|
|
410
|
+
if not self.is_fitted or self._fitted_pipeline is None:
|
|
411
|
+
raise PipelineError("Cannot save an unfitted pipeline.")
|
|
412
|
+
|
|
413
|
+
dest = Path(path).resolve()
|
|
414
|
+
if dest.suffix == ".joblib":
|
|
415
|
+
return save_joblib(self._fitted_pipeline, dest)
|
|
416
|
+
else:
|
|
417
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
return save_joblib(self._fitted_pipeline, dest / "pipeline.joblib")
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def load(cls, path: Union[str, Path], target: str = "unknown") -> "Pipeline":
|
|
422
|
+
"""
|
|
423
|
+
Load a previously trained pipeline from disk.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
path: Directory containing 'pipeline.joblib' or path to a .joblib file.
|
|
427
|
+
target: Optional target name for consistency.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Configured Pipeline instance ready for prediction.
|
|
431
|
+
"""
|
|
432
|
+
fitted_pipe = load_pipeline_artifact(path)
|
|
433
|
+
instance = cls(target=target)
|
|
434
|
+
instance._fitted_pipeline = fitted_pipe
|
|
435
|
+
return instance
|
mlpipe/core/result.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pipeline results and reporting structures.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PipelineResult:
|
|
12
|
+
"""Encapsulates the complete outcome of an MLPipe pipeline run."""
|
|
13
|
+
|
|
14
|
+
run_id: str
|
|
15
|
+
target_column: str
|
|
16
|
+
task_type: str
|
|
17
|
+
best_model_name: str
|
|
18
|
+
primary_metric: str
|
|
19
|
+
best_cv_score: float
|
|
20
|
+
test_score: float
|
|
21
|
+
leaderboard: List[Dict[str, Any]]
|
|
22
|
+
test_metrics: Dict[str, Any]
|
|
23
|
+
feature_importance: List[Dict[str, Any]]
|
|
24
|
+
artifacts_dir: Path
|
|
25
|
+
elapsed_time_s: float
|
|
26
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
test_preview: List[Dict[str, Any]] = field(default_factory=list)
|
|
28
|
+
train_path: Optional[Path] = None
|
|
29
|
+
test_path: Optional[Path] = None
|
|
30
|
+
test_predictions_path: Optional[Path] = None
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
33
|
+
"""Convert result to a dictionary representation."""
|
|
34
|
+
data = asdict(self)
|
|
35
|
+
data["artifacts_dir"] = str(self.artifacts_dir) if self.artifacts_dir else None
|
|
36
|
+
data["train_path"] = str(self.train_path) if self.train_path else None
|
|
37
|
+
data["test_path"] = str(self.test_path) if self.test_path else None
|
|
38
|
+
data["test_predictions_path"] = str(self.test_predictions_path) if self.test_predictions_path else None
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def best_model(self) -> str:
|
|
43
|
+
"""Convenience alias for best model name."""
|
|
44
|
+
return self.best_model_name
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def metrics(self) -> Dict[str, Any]:
|
|
48
|
+
"""Convenience alias for test metrics."""
|
|
49
|
+
return self.test_metrics
|
|
50
|
+
|
mlpipe/data/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Data ingestion, profiling, validation, and splitting for MLPipe."""
|
|
2
|
+
|
|
3
|
+
from mlpipe.data.ingestion import Dataset, load_dataset
|
|
4
|
+
from mlpipe.data.profiling import ColumnProfile, DatasetProfile, detect_column_type, profile_dataset
|
|
5
|
+
from mlpipe.data.splitting import SplitData, split_data
|
|
6
|
+
from mlpipe.data.validation import ValidationReport, detect_task, validate_dataset
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Dataset",
|
|
10
|
+
"load_dataset",
|
|
11
|
+
"ColumnProfile",
|
|
12
|
+
"DatasetProfile",
|
|
13
|
+
"detect_column_type",
|
|
14
|
+
"profile_dataset",
|
|
15
|
+
"SplitData",
|
|
16
|
+
"split_data",
|
|
17
|
+
"ValidationReport",
|
|
18
|
+
"detect_task",
|
|
19
|
+
"validate_dataset",
|
|
20
|
+
]
|