serverlex 1.0.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.
- Clean.py +261 -0
- DataCheck.py +165 -0
- DriftDetector.py +90 -0
- EnsembleBuilder.py +78 -0
- Evaluation.py +191 -0
- Explainability.py +89 -0
- FeatureSelector.py +90 -0
- ModelDeployer.py +89 -0
- ModelHyperparameter.py +162 -0
- Pipeline.py +251 -0
- Visualization.py +362 -0
- serverlex/__init__.py +34 -0
- serverlex-1.0.0.dist-info/METADATA +208 -0
- serverlex-1.0.0.dist-info/RECORD +16 -0
- serverlex-1.0.0.dist-info/WHEEL +5 -0
- serverlex-1.0.0.dist-info/top_level.txt +12 -0
Evaluation.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
from sklearn.metrics import (
|
|
7
|
+
confusion_matrix,
|
|
8
|
+
classification_report,
|
|
9
|
+
roc_curve,
|
|
10
|
+
auc,
|
|
11
|
+
precision_recall_curve,
|
|
12
|
+
r2_score,
|
|
13
|
+
mean_squared_error,
|
|
14
|
+
mean_absolute_error,
|
|
15
|
+
mean_absolute_percentage_error
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger=logging.getLogger("Evaluator")
|
|
19
|
+
if not logger.handlers:
|
|
20
|
+
_h=logging.StreamHandler()
|
|
21
|
+
_h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
|
22
|
+
logger.addHandler(_h)
|
|
23
|
+
logger.setLevel(logging.INFO)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ModelEvaluator:
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.metrics={}
|
|
29
|
+
self.y_true=None
|
|
30
|
+
self.y_pred=None
|
|
31
|
+
self.y_prob=None
|
|
32
|
+
self.task=None
|
|
33
|
+
|
|
34
|
+
# ==============================================================
|
|
35
|
+
# 1. Classification Diagnostics
|
|
36
|
+
# ==============================================================
|
|
37
|
+
def plot_confusion_matrix(self, y_true, y_pred, labels=None, title="Confusion Matrix"):
|
|
38
|
+
try:
|
|
39
|
+
cm=confusion_matrix(y_true, y_pred, labels=labels)
|
|
40
|
+
fig, ax=plt.subplots(figsize=(6, 5))
|
|
41
|
+
cax=ax.imshow(cm, cmap="Blues")
|
|
42
|
+
fig.colorbar(cax, ax=ax)
|
|
43
|
+
|
|
44
|
+
tick_marks=range(len(cm))
|
|
45
|
+
display_labels=labels if labels is not None else [f"Class {i}" for i in tick_marks]
|
|
46
|
+
ax.set_xticks(tick_marks)
|
|
47
|
+
ax.set_yticks(tick_marks)
|
|
48
|
+
ax.set_xticklabels(display_labels, rotation=45, ha="right")
|
|
49
|
+
ax.set_yticklabels(display_labels)
|
|
50
|
+
|
|
51
|
+
thresh=cm.max() / 2.0
|
|
52
|
+
for i in range(len(cm)):
|
|
53
|
+
for j in range(len(cm)):
|
|
54
|
+
ax.text(j, i, f"{cm[i, j]}", ha="center", va="center",
|
|
55
|
+
color="white" if cm[i, j] > thresh else "black", fontsize=11, fontweight="bold")
|
|
56
|
+
|
|
57
|
+
ax.set_title(title, fontsize=12, fontweight="bold")
|
|
58
|
+
ax.set_ylabel("Actual Label")
|
|
59
|
+
ax.set_xlabel("Predicted Label")
|
|
60
|
+
plt.tight_layout()
|
|
61
|
+
plt.show()
|
|
62
|
+
return cm
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error("Error in plot_confusion_matrix: %s", e)
|
|
65
|
+
|
|
66
|
+
def plot_roc_curve(self, y_true, y_prob, title="ROC Curve"):
|
|
67
|
+
try:
|
|
68
|
+
fpr, tpr, _ = roc_curve(y_true, y_prob)
|
|
69
|
+
roc_auc=auc(fpr, tpr)
|
|
70
|
+
|
|
71
|
+
fig, ax=plt.subplots(figsize=(6, 5))
|
|
72
|
+
ax.plot(fpr, tpr, color="darkorange", lw=2, label=f"ROC (AUC = {roc_auc:.4f})")
|
|
73
|
+
ax.plot([0, 1], [0, 1], color="navy", lw=1.5, linestyle="--")
|
|
74
|
+
ax.set_xlim([0.0, 1.0])
|
|
75
|
+
ax.set_ylim([0.0, 1.05])
|
|
76
|
+
ax.set_xlabel("False Positive Rate")
|
|
77
|
+
ax.set_ylabel("True Positive Rate")
|
|
78
|
+
ax.set_title(title, fontsize=12, fontweight="bold")
|
|
79
|
+
ax.legend(loc="lower right")
|
|
80
|
+
ax.grid(True, linestyle="--", alpha=0.5)
|
|
81
|
+
plt.tight_layout()
|
|
82
|
+
plt.show()
|
|
83
|
+
logger.info("ROC-AUC score: %.4f", roc_auc)
|
|
84
|
+
return roc_auc
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.error("Error in plot_roc_curve: %s", e)
|
|
87
|
+
|
|
88
|
+
def plot_precision_recall(self, y_true, y_prob, title="Precision-Recall Curve"):
|
|
89
|
+
try:
|
|
90
|
+
precision, recall, _ = precision_recall_curve(y_true, y_prob)
|
|
91
|
+
fig, ax=plt.subplots(figsize=(6, 5))
|
|
92
|
+
ax.plot(recall, precision, color="purple", lw=2)
|
|
93
|
+
ax.set_xlabel("Recall")
|
|
94
|
+
ax.set_ylabel("Precision")
|
|
95
|
+
ax.set_title(title, fontsize=12, fontweight="bold")
|
|
96
|
+
ax.grid(True, linestyle="--", alpha=0.5)
|
|
97
|
+
plt.tight_layout()
|
|
98
|
+
plt.show()
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.error("Error in plot_precision_recall: %s", e)
|
|
101
|
+
|
|
102
|
+
# ==============================================================
|
|
103
|
+
# 2. Regression Diagnostics
|
|
104
|
+
# ==============================================================
|
|
105
|
+
def plot_actual_vs_predicted(self, y_true, y_pred, title="Actual vs. Predicted"):
|
|
106
|
+
try:
|
|
107
|
+
fig, ax=plt.subplots(figsize=(6, 5))
|
|
108
|
+
ax.scatter(y_true, y_pred, alpha=0.6, edgecolors="k", color="royalblue")
|
|
109
|
+
|
|
110
|
+
min_val=min(min(y_true), min(y_pred))
|
|
111
|
+
max_val=max(max(y_true), max(y_pred))
|
|
112
|
+
ax.plot([min_val, max_val], [min_val, max_val], color="crimson", lw=2, linestyle="--", label="Ideal Fit (y=x)")
|
|
113
|
+
|
|
114
|
+
ax.set_xlabel("Actual Values")
|
|
115
|
+
ax.set_ylabel("Predicted Values")
|
|
116
|
+
ax.set_title(title, fontsize=12, fontweight="bold")
|
|
117
|
+
ax.legend()
|
|
118
|
+
ax.grid(True, linestyle="--", alpha=0.5)
|
|
119
|
+
plt.tight_layout()
|
|
120
|
+
plt.show()
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error("Error in plot_actual_vs_predicted: %s", e)
|
|
123
|
+
|
|
124
|
+
def plot_residuals(self, y_true, y_pred, title="Residual Analysis"):
|
|
125
|
+
try:
|
|
126
|
+
residuals = np.array(y_true) - np.array(y_pred)
|
|
127
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.5))
|
|
128
|
+
|
|
129
|
+
ax1.scatter(y_pred, residuals, alpha=0.6, edgecolors="k", color="darkcyan")
|
|
130
|
+
ax1.axhline(0, color="crimson", linestyle="--", lw=1.5)
|
|
131
|
+
ax1.set_xlabel("Predicted Values")
|
|
132
|
+
ax1.set_ylabel("Residuals")
|
|
133
|
+
ax1.set_title("Residuals vs. Predicted", fontsize=11, fontweight="bold")
|
|
134
|
+
ax1.grid(True, linestyle="--", alpha=0.5)
|
|
135
|
+
|
|
136
|
+
ax2.hist(residuals, bins=20, color="teal", edgecolor="black", alpha=0.7)
|
|
137
|
+
ax2.axvline(0, color="crimson", linestyle="--", lw=1.5)
|
|
138
|
+
ax2.set_xlabel("Residual Value")
|
|
139
|
+
ax2.set_ylabel("Count")
|
|
140
|
+
ax2.set_title("Distribution of Errors", fontsize=11, fontweight="bold")
|
|
141
|
+
|
|
142
|
+
plt.suptitle(title, fontsize=13, fontweight="bold")
|
|
143
|
+
plt.tight_layout()
|
|
144
|
+
plt.show()
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.error("Error in plot_residuals: %s", e)
|
|
147
|
+
|
|
148
|
+
# ==============================================================
|
|
149
|
+
# 3. Deep End-to-End Diagnostics Runner
|
|
150
|
+
# ==============================================================
|
|
151
|
+
def evaluate(self, model, X, y):
|
|
152
|
+
try:
|
|
153
|
+
self.y_true = np.array(y)
|
|
154
|
+
self.y_pred = model.predict(X)
|
|
155
|
+
|
|
156
|
+
is_class = (
|
|
157
|
+
self.y_true.dtype == "object"
|
|
158
|
+
or str(self.y_true.dtype).startswith("cat")
|
|
159
|
+
or len(np.unique(self.y_true)) <= 20
|
|
160
|
+
)
|
|
161
|
+
self.task = "Classification" if is_class else "Regression"
|
|
162
|
+
|
|
163
|
+
logger.info("Evaluating model predictions (task=%s, samples=%d)", self.task, len(self.y_true))
|
|
164
|
+
|
|
165
|
+
if is_class:
|
|
166
|
+
logger.info("Classification report:\n%s", classification_report(self.y_true, self.y_pred))
|
|
167
|
+
self.plot_confusion_matrix(self.y_true, self.y_pred)
|
|
168
|
+
|
|
169
|
+
if len(np.unique(self.y_true)) == 2 and hasattr(model, "predict_proba"):
|
|
170
|
+
self.y_prob = model.predict_proba(X)[:, 1]
|
|
171
|
+
self.plot_roc_curve(self.y_true, self.y_prob)
|
|
172
|
+
self.plot_precision_recall(self.y_true, self.y_prob)
|
|
173
|
+
|
|
174
|
+
else:
|
|
175
|
+
r2 = r2_score(self.y_true, self.y_pred)
|
|
176
|
+
rmse = np.sqrt(mean_squared_error(self.y_true, self.y_pred))
|
|
177
|
+
mae = mean_absolute_error(self.y_true, self.y_pred)
|
|
178
|
+
mape = mean_absolute_percentage_error(self.y_true, self.y_pred)
|
|
179
|
+
|
|
180
|
+
n = len(self.y_true)
|
|
181
|
+
p = X.shape[1]
|
|
182
|
+
adj_r2 = 1 - (1 - r2) * (n - 1) / (n - p - 1) if (n - p - 1) > 0 else r2
|
|
183
|
+
|
|
184
|
+
logger.info("Regression metrics: R2=%.4f, Adj_R2=%.4f, RMSE=%.4f, MAE=%.4f, MAPE=%.2f%%",
|
|
185
|
+
r2, adj_r2, rmse, mae, mape * 100)
|
|
186
|
+
|
|
187
|
+
self.plot_actual_vs_predicted(self.y_true, self.y_pred)
|
|
188
|
+
self.plot_residuals(self.y_true, self.y_pred)
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error("Error in evaluate: %s", e)
|
Explainability.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
from sklearn.inspection import permutation_importance
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelExplainer:
|
|
8
|
+
"""
|
|
9
|
+
Model Explainability & Feature Importance:
|
|
10
|
+
- Native feature importances (Tree-based & Linear models).
|
|
11
|
+
- Model-agnostic Permutation Importance.
|
|
12
|
+
- Publication-ready bar plots of top driving features.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, model, feature_names=None):
|
|
16
|
+
self.model = model
|
|
17
|
+
self.feature_names = list(feature_names) if feature_names is not None else None
|
|
18
|
+
|
|
19
|
+
def get_feature_importances(self):
|
|
20
|
+
"""
|
|
21
|
+
Extracts native feature importances or linear coefficients.
|
|
22
|
+
Returns a sorted pandas DataFrame.
|
|
23
|
+
"""
|
|
24
|
+
# 1. Tree-based models (RandomForest, DecisionTree, GradientBoosting)
|
|
25
|
+
if hasattr(self.model, "feature_importances_"):
|
|
26
|
+
importances = self.model.feature_importances_
|
|
27
|
+
names = self.feature_names or [f"Feature_{i}" for i in range(len(importances))]
|
|
28
|
+
df = pd.DataFrame({"Feature": names, "Importance": importances})
|
|
29
|
+
return df.sort_values(by="Importance", ascending=False).reset_index(drop=True)
|
|
30
|
+
|
|
31
|
+
# 2. Linear models (LogisticRegression, Ridge, LinearRegression)
|
|
32
|
+
elif hasattr(self.model, "coef_"):
|
|
33
|
+
coef = self.model.coef_
|
|
34
|
+
if coef.ndim > 1:
|
|
35
|
+
coef = np.mean(np.abs(coef), axis=0) # Multi-class or 2D coefs
|
|
36
|
+
else:
|
|
37
|
+
coef = np.abs(coef)
|
|
38
|
+
names = self.feature_names or [f"Feature_{i}" for i in range(len(coef))]
|
|
39
|
+
df = pd.DataFrame({"Feature": names, "Importance": coef})
|
|
40
|
+
return df.sort_values(by="Importance", ascending=False).reset_index(drop=True)
|
|
41
|
+
|
|
42
|
+
else:
|
|
43
|
+
raise AttributeError("Model does not have native feature_importances_ or coef_. Use permutation_importance() instead.")
|
|
44
|
+
|
|
45
|
+
def compute_permutation_importance(self, X, y, scoring=None, n_repeats=10, random_state=42):
|
|
46
|
+
"""
|
|
47
|
+
Calculates Model-Agnostic Permutation Importance:
|
|
48
|
+
Measures the drop in model score when a feature's values are randomly shuffled.
|
|
49
|
+
"""
|
|
50
|
+
print("Computing Permutation Importance across features...")
|
|
51
|
+
res = permutation_importance(
|
|
52
|
+
self.model,
|
|
53
|
+
X,
|
|
54
|
+
y,
|
|
55
|
+
scoring=scoring,
|
|
56
|
+
n_repeats=n_repeats,
|
|
57
|
+
random_state=random_state,
|
|
58
|
+
n_jobs=-1
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
names = self.feature_names or (X.columns.tolist() if hasattr(X, "columns") else [f"Feature_{i}" for i in range(X.shape[1])])
|
|
62
|
+
df = pd.DataFrame({
|
|
63
|
+
"Feature": names,
|
|
64
|
+
"Importance_Mean": res.importances_mean,
|
|
65
|
+
"Importance_Std": res.importances_std
|
|
66
|
+
}).sort_values(by="Importance_Mean", ascending=False).reset_index(drop=True)
|
|
67
|
+
|
|
68
|
+
return df
|
|
69
|
+
|
|
70
|
+
def plot_feature_importance(self, top_n=10, title="Top Feature Importances", save_path=None):
|
|
71
|
+
"""Plots a horizontal bar chart of the top N most important features."""
|
|
72
|
+
try:
|
|
73
|
+
df = self.get_feature_importances().head(top_n)
|
|
74
|
+
except AttributeError:
|
|
75
|
+
print("Cannot extract native feature importance. Run compute_permutation_importance() first.")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
fig, ax = plt.subplots(figsize=(8, max(4, top_n * 0.4)))
|
|
79
|
+
ax.barh(df["Feature"], df["Importance"], color="teal", edgecolor="black")
|
|
80
|
+
ax.invert_yaxis()
|
|
81
|
+
ax.set_title(title, fontsize=12, fontweight="bold")
|
|
82
|
+
ax.set_xlabel("Importance Score")
|
|
83
|
+
plt.tight_layout()
|
|
84
|
+
|
|
85
|
+
if save_path:
|
|
86
|
+
plt.savefig(save_path, dpi=150)
|
|
87
|
+
print(f"Feature importance plot saved to '{save_path}'")
|
|
88
|
+
|
|
89
|
+
plt.show()
|
FeatureSelector.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from sklearn.feature_selection import (
|
|
6
|
+
mutual_info_classif,
|
|
7
|
+
mutual_info_regression,
|
|
8
|
+
VarianceThreshold,
|
|
9
|
+
SelectKBest
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
logger=logging.getLogger("FeatureSelector")
|
|
13
|
+
if not logger.handlers:
|
|
14
|
+
_h=logging.StreamHandler()
|
|
15
|
+
_h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
|
16
|
+
logger.addHandler(_h)
|
|
17
|
+
logger.setLevel(logging.INFO)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FeatureSelector:
|
|
21
|
+
def __init__(self, k=10, variance_threshold=0.01, corr_threshold=0.90):
|
|
22
|
+
self.k=k
|
|
23
|
+
self.variance_threshold=variance_threshold
|
|
24
|
+
self.corr_threshold=corr_threshold
|
|
25
|
+
self.selected_features=[]
|
|
26
|
+
self.feature_scores={}
|
|
27
|
+
self.is_fitted=False
|
|
28
|
+
|
|
29
|
+
def fit(self, X, y):
|
|
30
|
+
try:
|
|
31
|
+
if not isinstance(X, pd.DataFrame):
|
|
32
|
+
X=pd.DataFrame(X)
|
|
33
|
+
if not isinstance(y, pd.Series):
|
|
34
|
+
y=pd.Series(y)
|
|
35
|
+
|
|
36
|
+
num_cols=X.select_dtypes(include='number').columns.tolist()
|
|
37
|
+
cur_X=X[num_cols].copy()
|
|
38
|
+
|
|
39
|
+
# 1. Prune near-zero variance features
|
|
40
|
+
var_selector=VarianceThreshold(threshold=self.variance_threshold)
|
|
41
|
+
var_selector.fit(cur_X)
|
|
42
|
+
non_zero_cols=cur_X.columns[var_selector.get_support()].tolist()
|
|
43
|
+
cur_X=cur_X[non_zero_cols]
|
|
44
|
+
|
|
45
|
+
# 2. Prune collinear features (redundant duplicates)
|
|
46
|
+
corr_matrix=cur_X.corr().abs()
|
|
47
|
+
upper_tri=corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool))
|
|
48
|
+
cols_to_drop=[col for col in upper_tri.columns if any(upper_tri[col] > self.corr_threshold)]
|
|
49
|
+
cur_X=cur_X.drop(columns=cols_to_drop)
|
|
50
|
+
|
|
51
|
+
# 3. Mutual Information Feature Importance
|
|
52
|
+
is_class=y.dtype == 'object' or str(y.dtype).startswith('cat') or y.nunique() <= 20
|
|
53
|
+
mi_func=mutual_info_classif if is_class else mutual_info_regression
|
|
54
|
+
|
|
55
|
+
mi_scores=mi_func(cur_X, y, random_state=42)
|
|
56
|
+
score_series=pd.Series(mi_scores, index=cur_X.columns).sort_values(ascending=False)
|
|
57
|
+
self.feature_scores=score_series.to_dict()
|
|
58
|
+
|
|
59
|
+
# Pick top K features
|
|
60
|
+
k_val=min(self.k, len(score_series))
|
|
61
|
+
self.selected_features=score_series.head(k_val).index.tolist()
|
|
62
|
+
self.is_fitted=True
|
|
63
|
+
|
|
64
|
+
logger.info("Feature selection complete: %d features selected from %d initial features", len(self.selected_features), X.shape[1])
|
|
65
|
+
return self
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error("Error in FeatureSelector.fit: %s", e)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def transform(self, X):
|
|
71
|
+
try:
|
|
72
|
+
if not self.is_fitted:
|
|
73
|
+
raise ValueError("FeatureSelector is not fitted yet. Call fit() first.")
|
|
74
|
+
|
|
75
|
+
if not isinstance(X, pd.DataFrame):
|
|
76
|
+
X=pd.DataFrame(X)
|
|
77
|
+
|
|
78
|
+
# Align to selected features (fill missing with 0)
|
|
79
|
+
return X.reindex(columns=self.selected_features, fill_value=0)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error("Error in FeatureSelector.transform: %s", e)
|
|
82
|
+
return X
|
|
83
|
+
|
|
84
|
+
def fit_transform(self, X, y):
|
|
85
|
+
self.fit(X, y)
|
|
86
|
+
return self.transform(X)
|
|
87
|
+
|
|
88
|
+
def get_ranked_scores(self):
|
|
89
|
+
"""Returns DataFrame of features ranked by Mutual Information."""
|
|
90
|
+
return pd.DataFrame(list(self.feature_scores.items()), columns=["Feature", "Mutual_Information_Score"])
|
ModelDeployer.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import joblib
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import numpy as np
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
logger=logging.getLogger("ModelDeployer")
|
|
8
|
+
if not logger.handlers:
|
|
9
|
+
_h=logging.StreamHandler()
|
|
10
|
+
_h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
|
11
|
+
logger.addHandler(_h)
|
|
12
|
+
logger.setLevel(logging.INFO)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelDeployer:
|
|
16
|
+
"""
|
|
17
|
+
Production Model Serving Engine:
|
|
18
|
+
- Bundles fitted MLPipeline + trained Model into a single deployable artifact.
|
|
19
|
+
- Saves and loads via joblib.
|
|
20
|
+
- Supports predict() directly on raw, uncleaned data (DataFrames or JSON/dicts).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, pipeline=None, model=None, metadata=None):
|
|
24
|
+
self.pipeline = pipeline
|
|
25
|
+
self.model = model
|
|
26
|
+
self.metadata = metadata or {}
|
|
27
|
+
if "created_at" not in self.metadata:
|
|
28
|
+
self.metadata["created_at"] = str(datetime.now())
|
|
29
|
+
|
|
30
|
+
def save(self, filepath="model_bundle.joblib"):
|
|
31
|
+
if self.pipeline is None or self.model is None:
|
|
32
|
+
raise ValueError("Both pipeline and model must be provided before saving.")
|
|
33
|
+
|
|
34
|
+
bundle = {
|
|
35
|
+
"pipeline": self.pipeline,
|
|
36
|
+
"model": self.model,
|
|
37
|
+
"metadata": self.metadata
|
|
38
|
+
}
|
|
39
|
+
joblib.dump(bundle, filepath)
|
|
40
|
+
logger.info("Saved model artifact bundle to '%s'", filepath)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, filepath="model_bundle.joblib"):
|
|
44
|
+
bundle = joblib.load(filepath)
|
|
45
|
+
deployer = cls(
|
|
46
|
+
pipeline=bundle["pipeline"],
|
|
47
|
+
model=bundle["model"],
|
|
48
|
+
metadata=bundle.get("metadata", {})
|
|
49
|
+
)
|
|
50
|
+
logger.info("Loaded model artifact bundle from '%s'", filepath)
|
|
51
|
+
return deployer
|
|
52
|
+
|
|
53
|
+
def _prepare_input(self, raw_input):
|
|
54
|
+
if isinstance(raw_input, dict):
|
|
55
|
+
df = pd.DataFrame([raw_input])
|
|
56
|
+
elif isinstance(raw_input, list):
|
|
57
|
+
df = pd.DataFrame(raw_input)
|
|
58
|
+
elif isinstance(raw_input, pd.DataFrame):
|
|
59
|
+
df = raw_input.copy()
|
|
60
|
+
else:
|
|
61
|
+
raise TypeError("Input must be a pandas DataFrame, a dict (single sample), or a list of dicts.")
|
|
62
|
+
return df
|
|
63
|
+
|
|
64
|
+
def predict(self, raw_input):
|
|
65
|
+
df = self._prepare_input(raw_input)
|
|
66
|
+
transformed_res = self.pipeline.transform(df)
|
|
67
|
+
|
|
68
|
+
if isinstance(transformed_res, tuple):
|
|
69
|
+
X = transformed_res[0]
|
|
70
|
+
else:
|
|
71
|
+
X = transformed_res
|
|
72
|
+
|
|
73
|
+
predictions = self.model.predict(X)
|
|
74
|
+
return predictions
|
|
75
|
+
|
|
76
|
+
def predict_proba(self, raw_input):
|
|
77
|
+
if not hasattr(self.model, "predict_proba"):
|
|
78
|
+
raise AttributeError(f"Model '{type(self.model).__name__}' does not support predict_proba().")
|
|
79
|
+
|
|
80
|
+
df = self._prepare_input(raw_input)
|
|
81
|
+
transformed_res = self.pipeline.transform(df)
|
|
82
|
+
|
|
83
|
+
if isinstance(transformed_res, tuple):
|
|
84
|
+
X = transformed_res[0]
|
|
85
|
+
else:
|
|
86
|
+
X = transformed_res
|
|
87
|
+
|
|
88
|
+
probabilities = self.model.predict_proba(X)
|
|
89
|
+
return probabilities
|
ModelHyperparameter.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from sklearn.model_selection import GridSearchCV, cross_val_score, KFold, StratifiedKFold
|
|
6
|
+
from sklearn.linear_model import LogisticRegression, Ridge
|
|
7
|
+
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
|
|
8
|
+
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor, GradientBoostingClassifier, GradientBoostingRegressor
|
|
9
|
+
|
|
10
|
+
logger=logging.getLogger("HyperparameterTuner")
|
|
11
|
+
if not logger.handlers:
|
|
12
|
+
_h=logging.StreamHandler()
|
|
13
|
+
_h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
|
14
|
+
logger.addHandler(_h)
|
|
15
|
+
logger.setLevel(logging.INFO)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModelHyperparameter:
|
|
19
|
+
@staticmethod
|
|
20
|
+
def get_default_param_grid(model_name: str, task: str = "classification"):
|
|
21
|
+
name = model_name.lower().replace(" ", "_")
|
|
22
|
+
|
|
23
|
+
if "forest" in name or name == "rf":
|
|
24
|
+
return {
|
|
25
|
+
"n_estimators": [50, 100, 200],
|
|
26
|
+
"max_depth": [None, 5, 10],
|
|
27
|
+
"min_samples_split": [2, 5]
|
|
28
|
+
}
|
|
29
|
+
elif "tree" in name or name == "dt":
|
|
30
|
+
return {
|
|
31
|
+
"max_depth": [3, 5, 10, None],
|
|
32
|
+
"min_samples_split": [2, 5, 10]
|
|
33
|
+
}
|
|
34
|
+
elif "gradient" in name or "boost" in name or name == "gb":
|
|
35
|
+
return {
|
|
36
|
+
"n_estimators": [50, 100],
|
|
37
|
+
"learning_rate": [0.01, 0.1, 0.2],
|
|
38
|
+
"max_depth": [3, 5]
|
|
39
|
+
}
|
|
40
|
+
elif "logistic" in name:
|
|
41
|
+
return {
|
|
42
|
+
"C": [0.01, 0.1, 1.0, 10.0],
|
|
43
|
+
"penalty": ["l2"]
|
|
44
|
+
}
|
|
45
|
+
elif "ridge" in name or "linear" in name:
|
|
46
|
+
return {
|
|
47
|
+
"alpha": [0.01, 0.1, 1.0, 10.0, 100.0]
|
|
48
|
+
}
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"No default grid for '{model_name}'. Please provide a custom param_grid.")
|
|
51
|
+
|
|
52
|
+
# ==============================================================
|
|
53
|
+
# 1. Non-Nested Cross-Validation (Standard GridSearchCV)
|
|
54
|
+
# ==============================================================
|
|
55
|
+
def tune_non_nested(self, estimator, param_grid, X, y, cv=5, scoring=None):
|
|
56
|
+
logger.info("Running GridSearchCV (estimator=%s, cv=%d, param_combinations=%s)",
|
|
57
|
+
type(estimator).__name__, cv, list(param_grid.keys()))
|
|
58
|
+
grid = GridSearchCV(
|
|
59
|
+
estimator=estimator,
|
|
60
|
+
param_grid=param_grid,
|
|
61
|
+
cv=cv,
|
|
62
|
+
scoring=scoring,
|
|
63
|
+
n_jobs=-1
|
|
64
|
+
)
|
|
65
|
+
grid.fit(X, y)
|
|
66
|
+
|
|
67
|
+
logger.info("GridSearchCV complete. Best score: %.4f | Optimal params: %s",
|
|
68
|
+
grid.best_score_, grid.best_params_)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"best_estimator": grid.best_estimator_,
|
|
72
|
+
"best_params": grid.best_params_,
|
|
73
|
+
"best_score": grid.best_score_,
|
|
74
|
+
"cv_results": pd.DataFrame(grid.cv_results_)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# ==============================================================
|
|
78
|
+
# 2. Nested Cross-Validation (Unbiased Generalization Estimate)
|
|
79
|
+
# ==============================================================
|
|
80
|
+
def tune_nested_cv(self, estimator, param_grid, X, y, outer_cv=5, inner_cv=3, scoring=None):
|
|
81
|
+
logger.info("Running Nested CV (estimator=%s, outer_cv=%d, inner_cv=%d)",
|
|
82
|
+
type(estimator).__name__, outer_cv, inner_cv)
|
|
83
|
+
|
|
84
|
+
is_classification = (
|
|
85
|
+
y.dtype == "object"
|
|
86
|
+
or str(y.dtype).startswith("cat")
|
|
87
|
+
or (isinstance(y, pd.Series) and y.nunique() <= 20)
|
|
88
|
+
or len(np.unique(y)) <= 20
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if is_classification:
|
|
92
|
+
outer_splitter = StratifiedKFold(n_splits=outer_cv, shuffle=True, random_state=42)
|
|
93
|
+
inner_splitter = StratifiedKFold(n_splits=inner_cv, shuffle=True, random_state=42)
|
|
94
|
+
default_scoring = "accuracy"
|
|
95
|
+
else:
|
|
96
|
+
outer_splitter = KFold(n_splits=outer_cv, shuffle=True, random_state=42)
|
|
97
|
+
inner_splitter = KFold(n_splits=inner_cv, shuffle=True, random_state=42)
|
|
98
|
+
default_scoring = "r2"
|
|
99
|
+
|
|
100
|
+
active_scoring = scoring or default_scoring
|
|
101
|
+
|
|
102
|
+
inner_grid = GridSearchCV(
|
|
103
|
+
estimator=estimator,
|
|
104
|
+
param_grid=param_grid,
|
|
105
|
+
cv=inner_splitter,
|
|
106
|
+
scoring=active_scoring,
|
|
107
|
+
n_jobs=-1
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
nested_scores = cross_val_score(
|
|
111
|
+
inner_grid,
|
|
112
|
+
X,
|
|
113
|
+
y,
|
|
114
|
+
cv=outer_splitter,
|
|
115
|
+
scoring=active_scoring,
|
|
116
|
+
n_jobs=-1
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
logger.info("Nested CV outer fold scores: %s", np.round(nested_scores, 4).tolist())
|
|
120
|
+
logger.info("Nested CV generalization score: %.4f (+/- %.4f)", nested_scores.mean(), nested_scores.std())
|
|
121
|
+
|
|
122
|
+
final_grid = GridSearchCV(estimator=estimator, param_grid=param_grid, cv=inner_splitter, scoring=active_scoring, n_jobs=-1)
|
|
123
|
+
final_grid.fit(X, y)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"nested_scores": nested_scores,
|
|
127
|
+
"nested_mean": nested_scores.mean(),
|
|
128
|
+
"nested_std": nested_scores.std(),
|
|
129
|
+
"best_estimator": final_grid.best_estimator_,
|
|
130
|
+
"best_params": final_grid.best_params_,
|
|
131
|
+
"metric": active_scoring
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# ==============================================================
|
|
135
|
+
# 3. Side-by-Side Comparison: Nested vs Non-Nested
|
|
136
|
+
# ==============================================================
|
|
137
|
+
def compare_nested_vs_non_nested(self, estimator, param_grid, X, y, outer_cv=5, inner_cv=3, scoring=None):
|
|
138
|
+
logger.info("Executing comparison between Non-Nested and Nested CV")
|
|
139
|
+
|
|
140
|
+
non_nested_res = self.tune_non_nested(estimator, param_grid, X, y, cv=outer_cv, scoring=scoring)
|
|
141
|
+
nested_res = self.tune_nested_cv(estimator, param_grid, X, y, outer_cv=outer_cv, inner_cv=inner_cv, scoring=scoring)
|
|
142
|
+
|
|
143
|
+
comparison_df = pd.DataFrame([
|
|
144
|
+
{
|
|
145
|
+
"Method": "Non-Nested CV (GridSearchCV)",
|
|
146
|
+
"Score": f"{non_nested_res['best_score']:.4f}",
|
|
147
|
+
"Type": "Optimized deployment score"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"Method": "Nested CV (Outer + Inner)",
|
|
151
|
+
"Score": f"{nested_res['nested_mean']:.4f} (+/- {nested_res['nested_std']:.4f})",
|
|
152
|
+
"Type": "Unbiased generalization estimate"
|
|
153
|
+
}
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
logger.info("Comparison summary:\n%s", comparison_df.to_string(index=False))
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"comparison": comparison_df,
|
|
160
|
+
"non_nested": non_nested_res,
|
|
161
|
+
"nested": nested_res
|
|
162
|
+
}
|