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
@@ -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,5 @@
1
+ """Explainability module for MLPipe."""
2
+
3
+ from mlpipe.explainability.importance import extract_feature_importance
4
+
5
+ __all__ = ["extract_feature_importance"]
@@ -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
+ ]