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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data validation and automatic task detection for MLPipe.
|
|
3
|
+
|
|
4
|
+
Provides rigorous pre-training checks separating fatal errors from warnings,
|
|
5
|
+
and infers task type (classification vs. regression) from target distribution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Dict, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from mlpipe.core.exceptions import ValidationError
|
|
16
|
+
from mlpipe.data.ingestion import Dataset
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_task(target_series: pd.Series) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Automatically determine if the problem is classification or regression.
|
|
22
|
+
|
|
23
|
+
Heuristic:
|
|
24
|
+
- Object, string, category, boolean dtypes -> classification.
|
|
25
|
+
- Low-cardinality integers (<= 10 unique or <= 2% of rows) -> classification.
|
|
26
|
+
- Floating point numbers with high cardinality -> regression.
|
|
27
|
+
"""
|
|
28
|
+
clean_target = target_series.dropna()
|
|
29
|
+
n_unique = clean_target.nunique()
|
|
30
|
+
n_total = len(clean_target)
|
|
31
|
+
|
|
32
|
+
if n_total == 0 or n_unique <= 1:
|
|
33
|
+
return "classification"
|
|
34
|
+
|
|
35
|
+
if pd.api.types.is_bool_dtype(clean_target):
|
|
36
|
+
return "classification"
|
|
37
|
+
|
|
38
|
+
if clean_target.dtype == object or pd.api.types.is_string_dtype(clean_target):
|
|
39
|
+
return "classification"
|
|
40
|
+
|
|
41
|
+
if pd.api.types.is_float_dtype(clean_target):
|
|
42
|
+
# Float targets are regression unless they only contain a tiny set of integers like 0.0, 1.0
|
|
43
|
+
unique_vals = clean_target.unique()
|
|
44
|
+
if len(unique_vals) <= 5 and all(float(v).is_integer() for v in unique_vals):
|
|
45
|
+
return "classification"
|
|
46
|
+
return "regression"
|
|
47
|
+
|
|
48
|
+
if pd.api.types.is_integer_dtype(clean_target):
|
|
49
|
+
# If very few unique values (e.g. 2 to 10 classes) and small ratio compared to rows -> classification
|
|
50
|
+
if n_unique <= 10 or (n_unique <= 20 and (n_unique / n_total) < 0.05):
|
|
51
|
+
return "classification"
|
|
52
|
+
return "regression"
|
|
53
|
+
|
|
54
|
+
return "classification"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ValidationReport:
|
|
59
|
+
"""Detailed outcome of pre-training dataset validation."""
|
|
60
|
+
|
|
61
|
+
is_valid: bool
|
|
62
|
+
errors: List[str] = field(default_factory=list)
|
|
63
|
+
warnings: List[str] = field(default_factory=list)
|
|
64
|
+
detected_task: str = "classification"
|
|
65
|
+
target_info: Dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
can_train: bool = True
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
69
|
+
return asdict(self)
|
|
70
|
+
|
|
71
|
+
def to_json(self, indent: int = 2) -> str:
|
|
72
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
73
|
+
|
|
74
|
+
def raise_if_invalid(self) -> None:
|
|
75
|
+
"""Raise ValidationError if any fatal check failed."""
|
|
76
|
+
if not self.can_train or not self.is_valid:
|
|
77
|
+
error_details = "\n• " + "\n• ".join(self.errors)
|
|
78
|
+
raise ValidationError(
|
|
79
|
+
f"Dataset validation failed with {len(self.errors)} fatal error(s):{error_details}",
|
|
80
|
+
"Fix the fatal issues indicated above before proceeding with model training."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_dataset(
|
|
85
|
+
dataset: Union[Dataset, pd.DataFrame],
|
|
86
|
+
target_column: str,
|
|
87
|
+
task_override: Optional[str] = None,
|
|
88
|
+
test_size: float = 0.20,
|
|
89
|
+
cv_folds: int = 5,
|
|
90
|
+
) -> ValidationReport:
|
|
91
|
+
"""
|
|
92
|
+
Perform comprehensive pre-training validation.
|
|
93
|
+
|
|
94
|
+
Checks:
|
|
95
|
+
- Target existence
|
|
96
|
+
- Target usable values and missingness
|
|
97
|
+
- Target variation (non-constant)
|
|
98
|
+
- Minimum sample count
|
|
99
|
+
- Minimum class counts (for classification)
|
|
100
|
+
- Available feature columns
|
|
101
|
+
- Train/test and CV fold feasibility
|
|
102
|
+
- Class imbalance
|
|
103
|
+
"""
|
|
104
|
+
df = dataset.df if isinstance(dataset, Dataset) else dataset
|
|
105
|
+
errors: List[str] = []
|
|
106
|
+
warnings: List[str] = []
|
|
107
|
+
|
|
108
|
+
# 1. Target column existence
|
|
109
|
+
if target_column not in df.columns:
|
|
110
|
+
errors.append(
|
|
111
|
+
f"Target column '{target_column}' does not exist in the dataset. "
|
|
112
|
+
f"Available columns: {list(df.columns)}"
|
|
113
|
+
)
|
|
114
|
+
return ValidationReport(
|
|
115
|
+
is_valid=False,
|
|
116
|
+
errors=errors,
|
|
117
|
+
warnings=warnings,
|
|
118
|
+
detected_task="unknown",
|
|
119
|
+
target_info={},
|
|
120
|
+
can_train=False,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
target = df[target_column]
|
|
124
|
+
n_rows = len(df)
|
|
125
|
+
target_missing = int(target.isna().sum())
|
|
126
|
+
target_valid_rows = n_rows - target_missing
|
|
127
|
+
|
|
128
|
+
# 2. Target missingness
|
|
129
|
+
if target_valid_rows == 0:
|
|
130
|
+
errors.append(f"Target column '{target_column}' contains only missing (NaN/null) values.")
|
|
131
|
+
elif target_missing > 0:
|
|
132
|
+
missing_pct = round((target_missing / n_rows) * 100, 2)
|
|
133
|
+
if missing_pct > 50.0:
|
|
134
|
+
errors.append(
|
|
135
|
+
f"Target column '{target_column}' has {missing_pct}% missing values (more than 50%)."
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
warnings.append(
|
|
139
|
+
f"Target column '{target_column}' contains {target_missing} missing values ({missing_pct}%). "
|
|
140
|
+
f"These {target_missing} rows will be dropped prior to training."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 3. Available feature columns
|
|
144
|
+
feature_cols = [c for c in df.columns if c != target_column]
|
|
145
|
+
if len(feature_cols) == 0:
|
|
146
|
+
errors.append("Dataset contains only the target column; at least one feature column is required.")
|
|
147
|
+
|
|
148
|
+
# 4. Minimum rows check
|
|
149
|
+
if target_valid_rows < 10:
|
|
150
|
+
errors.append(
|
|
151
|
+
f"Dataset has only {target_valid_rows} usable rows. MLPipe requires at least 10 rows."
|
|
152
|
+
)
|
|
153
|
+
elif target_valid_rows < 50:
|
|
154
|
+
warnings.append(
|
|
155
|
+
f"Dataset has only {target_valid_rows} usable rows. Results may have high variance."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# 5. Task detection or override
|
|
159
|
+
inferred_task = detect_task(target)
|
|
160
|
+
if task_override and task_override != "auto":
|
|
161
|
+
task = task_override.lower()
|
|
162
|
+
else:
|
|
163
|
+
task = inferred_task
|
|
164
|
+
|
|
165
|
+
target_info: Dict[str, Any] = {
|
|
166
|
+
"column": target_column,
|
|
167
|
+
"task": task,
|
|
168
|
+
"inferred_task": inferred_task,
|
|
169
|
+
"total_rows": n_rows,
|
|
170
|
+
"usable_rows": target_valid_rows,
|
|
171
|
+
"missing_count": target_missing,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# 6. Task-specific checks
|
|
175
|
+
clean_target = target.dropna()
|
|
176
|
+
unique_targets = clean_target.unique()
|
|
177
|
+
n_classes = len(unique_targets)
|
|
178
|
+
target_info["unique_values"] = n_classes
|
|
179
|
+
|
|
180
|
+
if task == "classification":
|
|
181
|
+
if n_classes < 2:
|
|
182
|
+
errors.append(
|
|
183
|
+
f"Target column '{target_column}' has only {n_classes} unique class. "
|
|
184
|
+
"Classification requires at least 2 distinct classes."
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
target_info["classes"] = [str(c) for c in unique_targets[:10]]
|
|
188
|
+
# Check class distribution
|
|
189
|
+
val_counts = clean_target.value_counts()
|
|
190
|
+
min_class_count = int(val_counts.min())
|
|
191
|
+
min_class_pct = round((min_class_count / len(clean_target)) * 100, 2)
|
|
192
|
+
target_info["min_class_count"] = min_class_count
|
|
193
|
+
target_info["min_class_pct"] = min_class_pct
|
|
194
|
+
|
|
195
|
+
# Check if smallest class has enough samples for train/test and CV
|
|
196
|
+
if min_class_count < cv_folds:
|
|
197
|
+
warnings.append(
|
|
198
|
+
f"Minority class '{val_counts.idxmin()}' has only {min_class_count} samples, "
|
|
199
|
+
f"which is fewer than cv_folds ({cv_folds}). Stratified folds may be adjusted."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if min_class_pct < 15.0:
|
|
203
|
+
warnings.append(
|
|
204
|
+
f"Target is imbalanced: minority class represents only {min_class_pct}% of samples."
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
elif task == "regression":
|
|
208
|
+
if n_classes <= 1:
|
|
209
|
+
errors.append(
|
|
210
|
+
f"Target column '{target_column}' has only 1 distinct numeric value. "
|
|
211
|
+
"Regression requires variation in the target."
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
try:
|
|
215
|
+
target_numeric = pd.to_numeric(clean_target, errors="raise")
|
|
216
|
+
target_info["mean"] = float(round(target_numeric.mean(), 4))
|
|
217
|
+
target_info["std"] = float(round(target_numeric.std(), 4))
|
|
218
|
+
except Exception:
|
|
219
|
+
errors.append(
|
|
220
|
+
f"Target column '{target_column}' contains non-numeric values that cannot be used for regression."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# 7. Train/Test feasibility
|
|
224
|
+
test_rows = int(target_valid_rows * test_size)
|
|
225
|
+
train_rows = target_valid_rows - test_rows
|
|
226
|
+
if train_rows < 5:
|
|
227
|
+
errors.append(f"Training set would have only {train_rows} rows; increase dataset size or decrease test_size.")
|
|
228
|
+
if test_rows < 2:
|
|
229
|
+
errors.append(f"Test set would have only {test_rows} rows; evaluation cannot be reliably performed.")
|
|
230
|
+
|
|
231
|
+
# 8. Feature quality warnings
|
|
232
|
+
for col in feature_cols:
|
|
233
|
+
col_series = df[col]
|
|
234
|
+
if col_series.nunique(dropna=True) <= 1:
|
|
235
|
+
warnings.append(f"Feature column '{col}' is constant (<=1 unique value) and will be dropped.")
|
|
236
|
+
elif col_series.isna().mean() > 0.90:
|
|
237
|
+
warnings.append(f"Feature column '{col}' has >90% missing values and will be dropped.")
|
|
238
|
+
|
|
239
|
+
is_valid = len(errors) == 0
|
|
240
|
+
|
|
241
|
+
return ValidationReport(
|
|
242
|
+
is_valid=is_valid,
|
|
243
|
+
errors=errors,
|
|
244
|
+
warnings=warnings,
|
|
245
|
+
detected_task=task,
|
|
246
|
+
target_info=target_info,
|
|
247
|
+
can_train=is_valid,
|
|
248
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Evaluation metrics and leaderboard computation for MLPipe."""
|
|
2
|
+
|
|
3
|
+
from mlpipe.evaluation.evaluator import build_leaderboard, evaluate_pipeline_on_test
|
|
4
|
+
from mlpipe.evaluation.metrics import get_display_metric_name, select_primary_metric
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"build_leaderboard",
|
|
8
|
+
"evaluate_pipeline_on_test",
|
|
9
|
+
"get_display_metric_name",
|
|
10
|
+
"select_primary_metric",
|
|
11
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model evaluation engine for MLPipe.
|
|
3
|
+
|
|
4
|
+
Calculates unbiased test-set metrics and compiles the model comparison leaderboard.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from sklearn.metrics import (
|
|
13
|
+
accuracy_score,
|
|
14
|
+
balanced_accuracy_score,
|
|
15
|
+
confusion_matrix,
|
|
16
|
+
f1_score,
|
|
17
|
+
mean_absolute_error,
|
|
18
|
+
mean_squared_error,
|
|
19
|
+
precision_score,
|
|
20
|
+
r2_score,
|
|
21
|
+
recall_score,
|
|
22
|
+
roc_auc_score,
|
|
23
|
+
)
|
|
24
|
+
from sklearn.pipeline import Pipeline
|
|
25
|
+
|
|
26
|
+
from mlpipe.evaluation.metrics import get_display_metric_name
|
|
27
|
+
from mlpipe.tuning.search import TuningResult
|
|
28
|
+
from mlpipe.utils.logging import get_logger
|
|
29
|
+
|
|
30
|
+
logger = get_logger("evaluation")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def evaluate_pipeline_on_test(
|
|
34
|
+
pipeline: Pipeline,
|
|
35
|
+
X_test: pd.DataFrame,
|
|
36
|
+
y_test: pd.Series,
|
|
37
|
+
task_type: str,
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
"""
|
|
40
|
+
Evaluate a fitted pipeline on held-out test data.
|
|
41
|
+
|
|
42
|
+
All metrics are directly computed from actual model predictions.
|
|
43
|
+
"""
|
|
44
|
+
y_pred = pipeline.predict(X_test)
|
|
45
|
+
metrics: Dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
if task_type == "classification":
|
|
48
|
+
acc = float(accuracy_score(y_test, y_pred))
|
|
49
|
+
bal_acc = float(balanced_accuracy_score(y_test, y_pred))
|
|
50
|
+
prec = float(precision_score(y_test, y_pred, average="weighted", zero_division=0))
|
|
51
|
+
rec = float(recall_score(y_test, y_pred, average="weighted", zero_division=0))
|
|
52
|
+
f1 = float(f1_score(y_test, y_pred, average="weighted", zero_division=0))
|
|
53
|
+
|
|
54
|
+
metrics["accuracy"] = round(acc, 4)
|
|
55
|
+
metrics["balanced_accuracy"] = round(bal_acc, 4)
|
|
56
|
+
metrics["precision"] = round(prec, 4)
|
|
57
|
+
metrics["recall"] = round(rec, 4)
|
|
58
|
+
metrics["f1_weighted"] = round(f1, 4)
|
|
59
|
+
|
|
60
|
+
# Calculate ROC-AUC if predict_proba is supported
|
|
61
|
+
if hasattr(pipeline, "predict_proba"):
|
|
62
|
+
try:
|
|
63
|
+
y_prob = pipeline.predict_proba(X_test)
|
|
64
|
+
classes = np.unique(y_test)
|
|
65
|
+
if len(classes) == 2:
|
|
66
|
+
auc = float(roc_auc_score(y_test, y_prob[:, 1]))
|
|
67
|
+
metrics["roc_auc"] = round(auc, 4)
|
|
68
|
+
elif len(classes) > 2:
|
|
69
|
+
auc = float(roc_auc_score(y_test, y_prob, multi_class="ovr", average="weighted"))
|
|
70
|
+
metrics["roc_auc"] = round(auc, 4)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.debug("ROC-AUC computation skipped: %s", e)
|
|
73
|
+
|
|
74
|
+
# Confusion Matrix
|
|
75
|
+
cm = confusion_matrix(y_test, y_pred)
|
|
76
|
+
metrics["confusion_matrix"] = cm.tolist()
|
|
77
|
+
|
|
78
|
+
else: # regression
|
|
79
|
+
mae = float(mean_absolute_error(y_test, y_pred))
|
|
80
|
+
mse = float(mean_squared_error(y_test, y_pred))
|
|
81
|
+
rmse = float(math.sqrt(mse))
|
|
82
|
+
r2 = float(r2_score(y_test, y_pred))
|
|
83
|
+
|
|
84
|
+
metrics["mae"] = round(mae, 4)
|
|
85
|
+
metrics["mse"] = round(mse, 4)
|
|
86
|
+
metrics["rmse"] = round(rmse, 4)
|
|
87
|
+
metrics["r2"] = round(r2, 4)
|
|
88
|
+
|
|
89
|
+
return metrics
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_leaderboard(
|
|
93
|
+
tuning_results: List[TuningResult],
|
|
94
|
+
X_test: pd.DataFrame,
|
|
95
|
+
y_test: pd.Series,
|
|
96
|
+
task_type: str,
|
|
97
|
+
primary_metric: str,
|
|
98
|
+
) -> List[Dict[str, Any]]:
|
|
99
|
+
"""
|
|
100
|
+
Compile and rank candidate models into a leaderboard.
|
|
101
|
+
|
|
102
|
+
MODELS ARE RANKED STRICTLY BY CV SCORE ON TRAINING DATA.
|
|
103
|
+
Test scores are calculated for reporting but never used for model selection.
|
|
104
|
+
"""
|
|
105
|
+
leaderboard = []
|
|
106
|
+
|
|
107
|
+
for res in tuning_results:
|
|
108
|
+
if res.best_pipeline is None or res.error:
|
|
109
|
+
leaderboard.append({
|
|
110
|
+
"model": res.candidate_name,
|
|
111
|
+
"cv_score": None,
|
|
112
|
+
"test_score": None,
|
|
113
|
+
"training_time_s": res.training_time_s,
|
|
114
|
+
"status": "failed",
|
|
115
|
+
"error": res.error,
|
|
116
|
+
"best_params": res.best_params,
|
|
117
|
+
})
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Evaluate on test set
|
|
121
|
+
test_metrics = evaluate_pipeline_on_test(res.best_pipeline, X_test, y_test, task_type)
|
|
122
|
+
|
|
123
|
+
# Extract primary metric test value
|
|
124
|
+
if task_type == "regression":
|
|
125
|
+
test_score = test_metrics.get("r2", 0.0)
|
|
126
|
+
else:
|
|
127
|
+
test_score = test_metrics.get(primary_metric, test_metrics.get("f1_weighted", 0.0))
|
|
128
|
+
|
|
129
|
+
leaderboard.append({
|
|
130
|
+
"model": res.candidate_name,
|
|
131
|
+
"cv_score": res.best_cv_score,
|
|
132
|
+
"test_score": test_score,
|
|
133
|
+
"training_time_s": res.training_time_s,
|
|
134
|
+
"status": "success",
|
|
135
|
+
"error": None,
|
|
136
|
+
"best_params": res.best_params,
|
|
137
|
+
"test_metrics": test_metrics,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
# Sort descending by CV score (failed models with None placed at the end)
|
|
141
|
+
leaderboard.sort(
|
|
142
|
+
key=lambda x: x["cv_score"] if x["cv_score"] is not None else -999999.0,
|
|
143
|
+
reverse=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return leaderboard
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metric definitions and primary metric selection logic for MLPipe.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def select_primary_metric(task_type: str, y_train: pd.Series) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Select the optimal primary optimization metric.
|
|
13
|
+
|
|
14
|
+
Documented Rationale:
|
|
15
|
+
- Regression:
|
|
16
|
+
Uses 'r2' (Coefficient of Determination) to measure variance explained.
|
|
17
|
+
- Classification:
|
|
18
|
+
- Checks class distribution in y_train.
|
|
19
|
+
- If binary and balanced (minority >= 20%): uses 'roc_auc' or 'f1' depending on class balance.
|
|
20
|
+
- If imbalanced (minority < 20%): uses 'f1_weighted' to penalize poor minority performance
|
|
21
|
+
rather than misleading accuracy.
|
|
22
|
+
- If multiclass (> 2 classes): uses 'f1_weighted' to account for class representation.
|
|
23
|
+
"""
|
|
24
|
+
if task_type == "regression":
|
|
25
|
+
return "r2"
|
|
26
|
+
|
|
27
|
+
val_counts = y_train.value_counts(normalize=True)
|
|
28
|
+
n_classes = len(val_counts)
|
|
29
|
+
|
|
30
|
+
if n_classes == 2:
|
|
31
|
+
minority_prop = float(val_counts.min())
|
|
32
|
+
if minority_prop < 0.20:
|
|
33
|
+
# Imbalanced binary
|
|
34
|
+
return "f1_weighted"
|
|
35
|
+
return "roc_auc"
|
|
36
|
+
else:
|
|
37
|
+
# Multiclass
|
|
38
|
+
return "f1_weighted"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_display_metric_name(metric_key: str) -> str:
|
|
42
|
+
"""Map sklearn scoring key to friendly display name."""
|
|
43
|
+
mapping = {
|
|
44
|
+
"f1_weighted": "F1 (Weighted)",
|
|
45
|
+
"f1": "F1",
|
|
46
|
+
"roc_auc": "ROC-AUC",
|
|
47
|
+
"accuracy": "Accuracy",
|
|
48
|
+
"balanced_accuracy": "Balanced Accuracy",
|
|
49
|
+
"r2": "R²",
|
|
50
|
+
"neg_mean_squared_error": "MSE",
|
|
51
|
+
"neg_mean_absolute_error": "MAE",
|
|
52
|
+
}
|
|
53
|
+
return mapping.get(metric_key, metric_key.upper())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feature importance extraction and model explainability for MLPipe.
|
|
3
|
+
|
|
4
|
+
Recovers post-transformation feature names to provide transparent explanations
|
|
5
|
+
for tree-based and linear models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from sklearn.pipeline import Pipeline
|
|
12
|
+
|
|
13
|
+
from mlpipe.preprocessing.builder import get_transformed_feature_names
|
|
14
|
+
from mlpipe.utils.logging import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger("explainability")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_feature_importance(pipeline: Pipeline) -> List[Dict[str, Any]]:
|
|
20
|
+
"""
|
|
21
|
+
Extract feature importance or coefficients from a fitted sklearn Pipeline.
|
|
22
|
+
|
|
23
|
+
Uses post-transformation feature names from the preprocessor step.
|
|
24
|
+
Returns sorted list of features with importance scores.
|
|
25
|
+
"""
|
|
26
|
+
if "preprocessor" not in pipeline.named_steps or "estimator" not in pipeline.named_steps:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
preprocessor = pipeline.named_steps["preprocessor"]
|
|
30
|
+
estimator = pipeline.named_steps["estimator"]
|
|
31
|
+
|
|
32
|
+
feature_names = get_transformed_feature_names(preprocessor)
|
|
33
|
+
n_features = len(feature_names)
|
|
34
|
+
|
|
35
|
+
scores: Optional[np.ndarray] = None
|
|
36
|
+
|
|
37
|
+
# 1. Tree-based models (Random Forest, Decision Tree)
|
|
38
|
+
if hasattr(estimator, "feature_importances_"):
|
|
39
|
+
raw_imp = estimator.feature_importances_
|
|
40
|
+
if len(raw_imp) == n_features:
|
|
41
|
+
scores = raw_imp
|
|
42
|
+
|
|
43
|
+
# 2. Linear models (LogisticRegression, Ridge)
|
|
44
|
+
elif hasattr(estimator, "coef_"):
|
|
45
|
+
coef = estimator.coef_
|
|
46
|
+
if coef.ndim == 2:
|
|
47
|
+
# Multiclass: average absolute coefficients across classes
|
|
48
|
+
scores = np.mean(np.abs(coef), axis=0)
|
|
49
|
+
else:
|
|
50
|
+
scores = np.abs(coef)
|
|
51
|
+
|
|
52
|
+
if len(scores) != n_features:
|
|
53
|
+
scores = None
|
|
54
|
+
|
|
55
|
+
if scores is None or len(scores) == 0:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
# Pair with feature names and sort descending
|
|
59
|
+
paired = [
|
|
60
|
+
{"feature": str(name), "importance": float(round(abs(score), 4))}
|
|
61
|
+
for name, score in zip(feature_names, scores)
|
|
62
|
+
]
|
|
63
|
+
paired.sort(key=lambda x: x["importance"], reverse=True)
|
|
64
|
+
|
|
65
|
+
return paired
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Model candidates and registries for MLPipe."""
|
|
2
|
+
|
|
3
|
+
from mlpipe.models.classification import get_classification_candidates
|
|
4
|
+
from mlpipe.models.regression import get_regression_candidates
|
|
5
|
+
from mlpipe.models.registry import ModelCandidate
|
|
6
|
+
from mlpipe.models.selection import get_candidates_for_task
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ModelCandidate",
|
|
10
|
+
"get_classification_candidates",
|
|
11
|
+
"get_regression_candidates",
|
|
12
|
+
"get_candidates_for_task",
|
|
13
|
+
]
|