serverlex 1.2.0__tar.gz → 1.2.1__tar.gz
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.
- {serverlex-1.2.0 → serverlex-1.2.1}/PKG-INFO +1 -1
- {serverlex-1.2.0 → serverlex-1.2.1}/pyproject.toml +1 -1
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/__init__.py +1 -1
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/diagnostics/drift.py +65 -24
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/diagnostics/evaluation.py +68 -12
- serverlex-1.2.1/serverlex/preprocessing/clean.py +306 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/preprocessing/pipeline.py +30 -13
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/serving/deployer.py +105 -27
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/visualization/eda.py +61 -32
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex.egg-info/PKG-INFO +1 -1
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex.egg-info/SOURCES.txt +1 -11
- serverlex-1.2.1/serverlex.egg-info/top_level.txt +1 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/setup.py +1 -14
- serverlex-1.2.1/tests/test_audit_remediation.py +190 -0
- serverlex-1.2.0/Clean.py +0 -2
- serverlex-1.2.0/DataCheck.py +0 -2
- serverlex-1.2.0/DriftDetector.py +0 -2
- serverlex-1.2.0/EnsembleBuilder.py +0 -2
- serverlex-1.2.0/Evaluation.py +0 -2
- serverlex-1.2.0/Explainability.py +0 -2
- serverlex-1.2.0/FeatureSelector.py +0 -2
- serverlex-1.2.0/ModelDeployer.py +0 -2
- serverlex-1.2.0/ModelHyperparameter.py +0 -2
- serverlex-1.2.0/Pipeline.py +0 -2
- serverlex-1.2.0/Visualization.py +0 -2
- serverlex-1.2.0/serverlex/preprocessing/clean.py +0 -267
- serverlex-1.2.0/serverlex.egg-info/top_level.txt +0 -12
- {serverlex-1.2.0 → serverlex-1.2.1}/README.md +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/diagnostics/__init__.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/diagnostics/dataguard.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/diagnostics/explainability.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/modeling/__init__.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/modeling/ensemble.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/modeling/hyperparameter.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/modeling/selector.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/preprocessing/__init__.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/serving/__init__.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex/visualization/__init__.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex.egg-info/dependency_links.txt +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/serverlex.egg-info/requires.txt +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/setup.cfg +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/tests/test_clean.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/tests/test_dataguard.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/tests/test_deployer.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/tests/test_drift.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/tests/test_leakage.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/tests/test_parity.py +0 -0
- {serverlex-1.2.0 → serverlex-1.2.1}/tests/test_pipeline.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "serverlex"
|
|
7
|
-
version = "1.2.
|
|
7
|
+
version = "1.2.1"
|
|
8
8
|
description = "Production-Safe ML Preprocessing, Diagnostics, Tuning, and Serving Platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Serverlex Team" }]
|
|
@@ -87,19 +87,46 @@ class DriftDetector:
|
|
|
87
87
|
logger.info("Running drift detection across %d features (alpha=%.3f, psi_threshold=%.2f)", len(ref_cols), self.alpha, self.psi_threshold)
|
|
88
88
|
|
|
89
89
|
for col in common_cols:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
ref_series = self.ref_data[col].dropna()
|
|
91
|
+
cur_series = cur_data[col].dropna()
|
|
92
|
+
|
|
93
|
+
if len(ref_series) == 0 or len(cur_series) == 0:
|
|
94
|
+
continue
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
# 2. Check Constant Shift (was constant in reference, altered in current)
|
|
97
|
+
if ref_series.nunique() <= 1:
|
|
98
|
+
ref_val = ref_series.iloc[0] if len(ref_series) > 0 else None
|
|
99
|
+
is_shifted = (cur_series.nunique() > 1) or (cur_series.nunique() == 1 and cur_series.iloc[0] != ref_val)
|
|
100
|
+
if is_shifted:
|
|
101
|
+
drift_count += 1
|
|
102
|
+
logger.warning("Constant feature shift detected in '%s' (ref=%s, cur_unique=%s)", col, ref_val, cur_series.unique())
|
|
103
|
+
self.drift_results.append({
|
|
104
|
+
"Feature": col,
|
|
105
|
+
"Type": "Constant Shift",
|
|
106
|
+
"Statistic": None,
|
|
107
|
+
"P_Value": 0.0,
|
|
108
|
+
"PSI": 1.0,
|
|
109
|
+
"Wasserstein": None,
|
|
110
|
+
"Drift_Detected": True,
|
|
111
|
+
"Severity": "High"
|
|
112
|
+
})
|
|
113
|
+
continue
|
|
99
114
|
|
|
100
|
-
|
|
115
|
+
# 3. Numerical Drift
|
|
116
|
+
if pd.api.types.is_numeric_dtype(self.ref_data[col]):
|
|
117
|
+
ref_vals = ref_series
|
|
118
|
+
cur_vals = cur_series
|
|
119
|
+
|
|
120
|
+
stat, p_val = ks_2samp(ref_vals, cur_vals)
|
|
121
|
+
psi = self.calculate_psi(ref_vals, cur_vals)
|
|
122
|
+
w_dist = float(np.round(wasserstein_distance(ref_vals, cur_vals), 4))
|
|
123
|
+
|
|
124
|
+
# Guard against small sample noise: KS p-value requires sample size >= 10
|
|
125
|
+
if len(cur_vals) < 10:
|
|
126
|
+
is_drift = False
|
|
127
|
+
severity = "Insufficient Samples (< 10)"
|
|
128
|
+
else:
|
|
101
129
|
is_drift = (psi >= self.psi_threshold) or (p_val < self.alpha and psi >= 0.10)
|
|
102
|
-
|
|
103
130
|
if psi >= 0.25:
|
|
104
131
|
severity = "High"
|
|
105
132
|
elif psi >= 0.10:
|
|
@@ -107,26 +134,40 @@ class DriftDetector:
|
|
|
107
134
|
else:
|
|
108
135
|
severity = "None"
|
|
109
136
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
137
|
+
if is_drift:
|
|
138
|
+
drift_count += 1
|
|
139
|
+
logger.warning("Data drift detected in '%s' (KS=%.4f, p=%.5f, PSI=%.4f, Wasserstein=%.4f, Severity=%s)", col, stat, p_val, psi, w_dist, severity)
|
|
113
140
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
self.drift_results.append({
|
|
142
|
+
"Feature": col,
|
|
143
|
+
"Type": "Numerical",
|
|
144
|
+
"Statistic": round(stat, 4),
|
|
145
|
+
"P_Value": round(p_val, 5),
|
|
146
|
+
"PSI": psi,
|
|
147
|
+
"Wasserstein": w_dist,
|
|
148
|
+
"Drift_Detected": is_drift,
|
|
149
|
+
"Severity": severity
|
|
150
|
+
})
|
|
124
151
|
|
|
125
|
-
#
|
|
152
|
+
# 4. Categorical Drift
|
|
126
153
|
else:
|
|
127
154
|
ref_counts=self.ref_data[col].astype(str).value_counts()
|
|
128
155
|
cur_counts=cur_data[col].astype(str).value_counts()
|
|
129
156
|
|
|
157
|
+
# Guard against tiny batches: Chi-square and PSI are unstable for n < 10
|
|
158
|
+
if len(cur_series) < 10:
|
|
159
|
+
self.drift_results.append({
|
|
160
|
+
"Feature": col,
|
|
161
|
+
"Type": "Categorical",
|
|
162
|
+
"Statistic": None,
|
|
163
|
+
"P_Value": 1.0,
|
|
164
|
+
"PSI": 0.0,
|
|
165
|
+
"Wasserstein": None,
|
|
166
|
+
"Drift_Detected": False,
|
|
167
|
+
"Severity": "Insufficient Samples (< 10)"
|
|
168
|
+
})
|
|
169
|
+
continue
|
|
170
|
+
|
|
130
171
|
all_cats=list(set(ref_counts.index).union(set(cur_counts.index)))
|
|
131
172
|
ref_aligned=np.array([ref_counts.get(cat, 0) for cat in all_cats], dtype=float)
|
|
132
173
|
cur_aligned=np.array([cur_counts.get(cat, 0) for cat in all_cats], dtype=float)
|
|
@@ -34,7 +34,9 @@ class ModelEvaluator:
|
|
|
34
34
|
# ==============================================================
|
|
35
35
|
# 1. Classification Diagnostics
|
|
36
36
|
# ==============================================================
|
|
37
|
-
|
|
37
|
+
# 1. Classification Diagnostics
|
|
38
|
+
# ==============================================================
|
|
39
|
+
def plot_confusion_matrix(self, y_true, y_pred, labels=None, title="Confusion Matrix", save_path=None):
|
|
38
40
|
try:
|
|
39
41
|
cm=confusion_matrix(y_true, y_pred, labels=labels)
|
|
40
42
|
fig, ax=plt.subplots(figsize=(6, 5))
|
|
@@ -58,12 +60,17 @@ class ModelEvaluator:
|
|
|
58
60
|
ax.set_ylabel("Actual Label")
|
|
59
61
|
ax.set_xlabel("Predicted Label")
|
|
60
62
|
plt.tight_layout()
|
|
61
|
-
|
|
63
|
+
if save_path:
|
|
64
|
+
plt.savefig(save_path)
|
|
65
|
+
plt.close(fig)
|
|
66
|
+
else:
|
|
67
|
+
plt.show()
|
|
62
68
|
return cm
|
|
63
69
|
except Exception as e:
|
|
64
70
|
logger.error("Error in plot_confusion_matrix: %s", e)
|
|
71
|
+
raise
|
|
65
72
|
|
|
66
|
-
def plot_roc_curve(self, y_true, y_prob, title="ROC Curve"):
|
|
73
|
+
def plot_roc_curve(self, y_true, y_prob, title="ROC Curve", save_path=None):
|
|
67
74
|
try:
|
|
68
75
|
y_true_arr = np.asarray(y_true)
|
|
69
76
|
y_prob_arr = np.asarray(y_prob)
|
|
@@ -98,14 +105,18 @@ class ModelEvaluator:
|
|
|
98
105
|
ax.legend(loc="lower right")
|
|
99
106
|
ax.grid(True, linestyle="--", alpha=0.5)
|
|
100
107
|
plt.tight_layout()
|
|
101
|
-
|
|
108
|
+
if save_path:
|
|
109
|
+
plt.savefig(save_path)
|
|
110
|
+
plt.close(fig)
|
|
111
|
+
else:
|
|
112
|
+
plt.show()
|
|
102
113
|
logger.info("ROC-AUC score: %.4f", roc_auc)
|
|
103
114
|
return roc_auc
|
|
104
115
|
except Exception as e:
|
|
105
116
|
logger.error("Error in plot_roc_curve: %s", e)
|
|
106
117
|
raise
|
|
107
118
|
|
|
108
|
-
def plot_precision_recall(self, y_true, y_prob, title="Precision-Recall Curve"):
|
|
119
|
+
def plot_precision_recall(self, y_true, y_prob, title="Precision-Recall Curve", save_path=None):
|
|
109
120
|
try:
|
|
110
121
|
precision, recall, _ = precision_recall_curve(y_true, y_prob)
|
|
111
122
|
fig, ax=plt.subplots(figsize=(6, 5))
|
|
@@ -115,14 +126,19 @@ class ModelEvaluator:
|
|
|
115
126
|
ax.set_title(title, fontsize=12, fontweight="bold")
|
|
116
127
|
ax.grid(True, linestyle="--", alpha=0.5)
|
|
117
128
|
plt.tight_layout()
|
|
118
|
-
|
|
129
|
+
if save_path:
|
|
130
|
+
plt.savefig(save_path)
|
|
131
|
+
plt.close(fig)
|
|
132
|
+
else:
|
|
133
|
+
plt.show()
|
|
119
134
|
except Exception as e:
|
|
120
135
|
logger.error("Error in plot_precision_recall: %s", e)
|
|
136
|
+
raise
|
|
121
137
|
|
|
122
138
|
# ==============================================================
|
|
123
139
|
# 2. Regression Diagnostics
|
|
124
140
|
# ==============================================================
|
|
125
|
-
def plot_actual_vs_predicted(self, y_true, y_pred, title="Actual vs. Predicted"):
|
|
141
|
+
def plot_actual_vs_predicted(self, y_true, y_pred, title="Actual vs. Predicted", save_path=None):
|
|
126
142
|
try:
|
|
127
143
|
fig, ax=plt.subplots(figsize=(6, 5))
|
|
128
144
|
ax.scatter(y_true, y_pred, alpha=0.6, edgecolors="k", color="royalblue")
|
|
@@ -137,11 +153,16 @@ class ModelEvaluator:
|
|
|
137
153
|
ax.legend()
|
|
138
154
|
ax.grid(True, linestyle="--", alpha=0.5)
|
|
139
155
|
plt.tight_layout()
|
|
140
|
-
|
|
156
|
+
if save_path:
|
|
157
|
+
plt.savefig(save_path)
|
|
158
|
+
plt.close(fig)
|
|
159
|
+
else:
|
|
160
|
+
plt.show()
|
|
141
161
|
except Exception as e:
|
|
142
162
|
logger.error("Error in plot_actual_vs_predicted: %s", e)
|
|
163
|
+
raise
|
|
143
164
|
|
|
144
|
-
def plot_residuals(self, y_true, y_pred, title="Residual Analysis"):
|
|
165
|
+
def plot_residuals(self, y_true, y_pred, title="Residual Analysis", save_path=None):
|
|
145
166
|
try:
|
|
146
167
|
residuals = np.array(y_true) - np.array(y_pred)
|
|
147
168
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.5))
|
|
@@ -161,9 +182,14 @@ class ModelEvaluator:
|
|
|
161
182
|
|
|
162
183
|
plt.suptitle(title, fontsize=13, fontweight="bold")
|
|
163
184
|
plt.tight_layout()
|
|
164
|
-
|
|
185
|
+
if save_path:
|
|
186
|
+
plt.savefig(save_path)
|
|
187
|
+
plt.close(fig)
|
|
188
|
+
else:
|
|
189
|
+
plt.show()
|
|
165
190
|
except Exception as e:
|
|
166
191
|
logger.error("Error in plot_residuals: %s", e)
|
|
192
|
+
raise
|
|
167
193
|
|
|
168
194
|
# ==============================================================
|
|
169
195
|
# 3. Deep End-to-End Diagnostics Runner
|
|
@@ -183,14 +209,33 @@ class ModelEvaluator:
|
|
|
183
209
|
logger.info("Evaluating model predictions (task=%s, samples=%d)", self.task, len(self.y_true))
|
|
184
210
|
|
|
185
211
|
if is_class:
|
|
212
|
+
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
|
|
186
213
|
logger.info("Classification report:\n%s", classification_report(self.y_true, self.y_pred))
|
|
187
214
|
self.plot_confusion_matrix(self.y_true, self.y_pred)
|
|
188
215
|
|
|
216
|
+
acc = accuracy_score(self.y_true, self.y_pred)
|
|
217
|
+
prec = precision_score(self.y_true, self.y_pred, average='weighted', zero_division=0)
|
|
218
|
+
rec = recall_score(self.y_true, self.y_pred, average='weighted', zero_division=0)
|
|
219
|
+
f1_w = f1_score(self.y_true, self.y_pred, average='weighted', zero_division=0)
|
|
220
|
+
f1_m = f1_score(self.y_true, self.y_pred, average='macro', zero_division=0)
|
|
221
|
+
roc_auc = None
|
|
222
|
+
|
|
189
223
|
if len(np.unique(self.y_true)) == 2 and hasattr(model, "predict_proba"):
|
|
190
224
|
self.y_prob = model.predict_proba(X)[:, 1]
|
|
191
|
-
self.plot_roc_curve(self.y_true, self.y_prob)
|
|
225
|
+
roc_auc = self.plot_roc_curve(self.y_true, self.y_prob)
|
|
192
226
|
self.plot_precision_recall(self.y_true, self.y_prob)
|
|
193
227
|
|
|
228
|
+
self.metrics = {
|
|
229
|
+
"task": "Classification",
|
|
230
|
+
"accuracy": round(float(acc), 4),
|
|
231
|
+
"precision_weighted": round(float(prec), 4),
|
|
232
|
+
"recall_weighted": round(float(rec), 4),
|
|
233
|
+
"f1_weighted": round(float(f1_w), 4),
|
|
234
|
+
"f1_macro": round(float(f1_m), 4),
|
|
235
|
+
"roc_auc": round(float(roc_auc), 4) if roc_auc is not None else None
|
|
236
|
+
}
|
|
237
|
+
return self.metrics
|
|
238
|
+
|
|
194
239
|
else:
|
|
195
240
|
r2 = r2_score(self.y_true, self.y_pred)
|
|
196
241
|
rmse = np.sqrt(mean_squared_error(self.y_true, self.y_pred))
|
|
@@ -198,7 +243,7 @@ class ModelEvaluator:
|
|
|
198
243
|
mape = mean_absolute_percentage_error(self.y_true, self.y_pred)
|
|
199
244
|
|
|
200
245
|
n = len(self.y_true)
|
|
201
|
-
p = X.shape[1]
|
|
246
|
+
p = X.shape[1] if hasattr(X, "shape") and len(X.shape) > 1 else 1
|
|
202
247
|
adj_r2 = 1 - (1 - r2) * (n - 1) / (n - p - 1) if (n - p - 1) > 0 else r2
|
|
203
248
|
|
|
204
249
|
logger.info("Regression metrics: R2=%.4f, Adj_R2=%.4f, RMSE=%.4f, MAE=%.4f, MAPE=%.2f%%",
|
|
@@ -207,5 +252,16 @@ class ModelEvaluator:
|
|
|
207
252
|
self.plot_actual_vs_predicted(self.y_true, self.y_pred)
|
|
208
253
|
self.plot_residuals(self.y_true, self.y_pred)
|
|
209
254
|
|
|
255
|
+
self.metrics = {
|
|
256
|
+
"task": "Regression",
|
|
257
|
+
"r2": round(float(r2), 4),
|
|
258
|
+
"adj_r2": round(float(adj_r2), 4),
|
|
259
|
+
"rmse": round(float(rmse), 4),
|
|
260
|
+
"mae": round(float(mae), 4),
|
|
261
|
+
"mape": round(float(mape), 4)
|
|
262
|
+
}
|
|
263
|
+
return self.metrics
|
|
264
|
+
|
|
210
265
|
except Exception as e:
|
|
211
266
|
logger.error("Error in evaluate: %s", e)
|
|
267
|
+
raise
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger=logging.getLogger("Clean")
|
|
6
|
+
if not logger.handlers:
|
|
7
|
+
_h=logging.StreamHandler()
|
|
8
|
+
_h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
|
|
9
|
+
logger.addHandler(_h)
|
|
10
|
+
logger.setLevel(logging.INFO)
|
|
11
|
+
|
|
12
|
+
class Clean:
|
|
13
|
+
def __init__(self, path_or_df):
|
|
14
|
+
if isinstance(path_or_df, pd.DataFrame):
|
|
15
|
+
self.path = None
|
|
16
|
+
self.data = path_or_df.copy()
|
|
17
|
+
else:
|
|
18
|
+
self.path = path_or_df
|
|
19
|
+
self.data = None
|
|
20
|
+
|
|
21
|
+
def load(self):
|
|
22
|
+
if not hasattr(self, 'data') or self.data is None:
|
|
23
|
+
if self.path is not None:
|
|
24
|
+
self.data = pd.read_csv(self.path)
|
|
25
|
+
else:
|
|
26
|
+
raise ValueError("No dataset or file path provided to Clean.")
|
|
27
|
+
return self.data
|
|
28
|
+
|
|
29
|
+
def separate(self):
|
|
30
|
+
self.load()
|
|
31
|
+
self.numerical_columns = self.data.select_dtypes(include='number')
|
|
32
|
+
self.categorical_columns = self.data.select_dtypes(include=['object', 'category', 'string'])
|
|
33
|
+
self.categrorical_columns = self.categorical_columns # Backward-compatibility typo alias
|
|
34
|
+
return self.numerical_columns, self.categorical_columns
|
|
35
|
+
|
|
36
|
+
seperate = separate # Backward-compatibility typo alias
|
|
37
|
+
|
|
38
|
+
def outliers_IQR(self, param='m', exclude=()):
|
|
39
|
+
self.separate()
|
|
40
|
+
exclude = set(exclude) if exclude else set()
|
|
41
|
+
cols = [c for c in self.numerical_columns.columns if c not in exclude]
|
|
42
|
+
if not cols:
|
|
43
|
+
return self.data
|
|
44
|
+
|
|
45
|
+
num = self.numerical_columns[cols]
|
|
46
|
+
q1 = num.quantile(0.25)
|
|
47
|
+
q3 = num.quantile(0.75)
|
|
48
|
+
iqr = q3 - q1
|
|
49
|
+
lower_bound = q1 - 1.5 * iqr
|
|
50
|
+
upper_bound = q3 + 1.5 * iqr
|
|
51
|
+
|
|
52
|
+
bad = (num < lower_bound) | (num > upper_bound)
|
|
53
|
+
|
|
54
|
+
if param in ['r', 'drop', 'remove']:
|
|
55
|
+
self.data = self.data.loc[~bad.any(axis=1)]
|
|
56
|
+
self.separate()
|
|
57
|
+
return self.data
|
|
58
|
+
|
|
59
|
+
if param in ['m', 'mean']:
|
|
60
|
+
fill = num.mean()
|
|
61
|
+
else:
|
|
62
|
+
fill = num.median()
|
|
63
|
+
|
|
64
|
+
self.data[cols] = num.mask(bad, fill, axis=1)
|
|
65
|
+
self.separate()
|
|
66
|
+
return self.data
|
|
67
|
+
|
|
68
|
+
def outliers_Z(self, param='m', threshold=None, exclude=()):
|
|
69
|
+
self.separate()
|
|
70
|
+
exclude = set(exclude) if exclude else set()
|
|
71
|
+
cols = [c for c in self.numerical_columns.columns if c not in exclude]
|
|
72
|
+
if not cols:
|
|
73
|
+
return self.data
|
|
74
|
+
|
|
75
|
+
num = self.numerical_columns[cols]
|
|
76
|
+
mean = num.mean()
|
|
77
|
+
std_dev = num.std()
|
|
78
|
+
std_dev = std_dev.replace(0, 1.0)
|
|
79
|
+
z_score = (num - mean) / std_dev
|
|
80
|
+
t = threshold if threshold is not None else 3.0
|
|
81
|
+
|
|
82
|
+
bad = z_score.abs() >= t
|
|
83
|
+
|
|
84
|
+
if param in ['r', 'drop', 'remove']:
|
|
85
|
+
self.data = self.data.loc[~bad.any(axis=1)]
|
|
86
|
+
self.separate()
|
|
87
|
+
return self.data
|
|
88
|
+
|
|
89
|
+
if param in ['m', 'mean']:
|
|
90
|
+
fill = mean
|
|
91
|
+
else:
|
|
92
|
+
fill = num.median()
|
|
93
|
+
|
|
94
|
+
self.data[cols] = num.mask(bad, fill, axis=1)
|
|
95
|
+
self.separate()
|
|
96
|
+
return self.data
|
|
97
|
+
|
|
98
|
+
def Clean_string(self, data_time=None):
|
|
99
|
+
self.separate()
|
|
100
|
+
|
|
101
|
+
# Handle date time format: data_time="col" or data_time=["col"] or data_time=["col", format]
|
|
102
|
+
if data_time is not None:
|
|
103
|
+
if isinstance(data_time, str):
|
|
104
|
+
col_name, dt_format = data_time, None
|
|
105
|
+
elif len(data_time) == 1:
|
|
106
|
+
col_name, dt_format = data_time[0], None
|
|
107
|
+
else:
|
|
108
|
+
col_name, dt_format = data_time[0], data_time[1]
|
|
109
|
+
|
|
110
|
+
if col_name in self.data.columns:
|
|
111
|
+
parsed_dates = pd.to_datetime(self.data[col_name], format='mixed', errors='coerce')
|
|
112
|
+
if dt_format:
|
|
113
|
+
self.data[col_name] = parsed_dates.dt.strftime(dt_format)
|
|
114
|
+
else:
|
|
115
|
+
self.data[col_name] = parsed_dates
|
|
116
|
+
|
|
117
|
+
# Standardize strings: strip leading/trailing, normalize internal whitespace to single space, lowercase
|
|
118
|
+
cat_cols = [c for c in self.categorical_columns.columns if data_time is None or c != data_time[0]]
|
|
119
|
+
for col in cat_cols:
|
|
120
|
+
orig_na = self.data[col].isna()
|
|
121
|
+
cleaned = self.data[col].astype(str).str.strip().str.lower().str.replace(r"\s+", " ", regex=True)
|
|
122
|
+
self.data[col] = cleaned.mask(orig_na, None)
|
|
123
|
+
|
|
124
|
+
self.separate()
|
|
125
|
+
return self.data
|
|
126
|
+
|
|
127
|
+
def delete_duplicates(self):
|
|
128
|
+
self.load()
|
|
129
|
+
self.data = self.data.drop_duplicates()
|
|
130
|
+
self.separate()
|
|
131
|
+
return self.data
|
|
132
|
+
def fill_missing(self, param='m'):
|
|
133
|
+
self.separate()
|
|
134
|
+
if param in ['r', 'd', 'drop']:
|
|
135
|
+
self.data = self.data.dropna()
|
|
136
|
+
self.separate()
|
|
137
|
+
return self.data
|
|
138
|
+
if param in ['m', 'mean']:
|
|
139
|
+
fill_vals = self.numerical_columns.mean()
|
|
140
|
+
else:
|
|
141
|
+
fill_vals = self.numerical_columns.median()
|
|
142
|
+
self.numerical_columns = self.numerical_columns.fillna(fill_vals)
|
|
143
|
+
self.data[self.numerical_columns.columns] = self.numerical_columns
|
|
144
|
+
self.separate()
|
|
145
|
+
return self.data
|
|
146
|
+
|
|
147
|
+
def fill_categorical_missing(self, fill_val=None):
|
|
148
|
+
self.separate()
|
|
149
|
+
for col in self.categorical_columns.columns:
|
|
150
|
+
val = fill_val if fill_val is not None else (self.data[col].mode()[0] if not self.data[col].mode().empty else "unknown")
|
|
151
|
+
self.data[col] = self.data[col].fillna(val)
|
|
152
|
+
self.separate()
|
|
153
|
+
return self.data
|
|
154
|
+
|
|
155
|
+
def clean_column_names(self):
|
|
156
|
+
self.load()
|
|
157
|
+
self.data.columns = (
|
|
158
|
+
self.data.columns.str.strip()
|
|
159
|
+
.str.lower()
|
|
160
|
+
.str.replace(r"\s+", "_", regex=True)
|
|
161
|
+
.str.replace(r"[^\w_]", "", regex=True)
|
|
162
|
+
)
|
|
163
|
+
self.separate()
|
|
164
|
+
return self.data
|
|
165
|
+
|
|
166
|
+
def drop_uninformative(self, null_threshold=0.7):
|
|
167
|
+
self.load()
|
|
168
|
+
null_pct = self.data.isnull().mean()
|
|
169
|
+
cols_to_drop = null_pct[null_pct > null_threshold].index.tolist()
|
|
170
|
+
zero_var = [col for col in self.data.columns if self.data[col].nunique(dropna=False) <= 1]
|
|
171
|
+
all_drop = list(set(cols_to_drop + zero_var))
|
|
172
|
+
if all_drop:
|
|
173
|
+
self.data = self.data.drop(columns=all_drop)
|
|
174
|
+
self.separate()
|
|
175
|
+
return self.data
|
|
176
|
+
|
|
177
|
+
def clean_numeric_strings(self, columns):
|
|
178
|
+
self.load()
|
|
179
|
+
for col in columns:
|
|
180
|
+
if col in self.data.columns:
|
|
181
|
+
cleaned = self.data[col].astype(str).str.replace(r"[\$,]", "", regex=True).str.strip()
|
|
182
|
+
self.data[col] = pd.to_numeric(cleaned, errors="coerce")
|
|
183
|
+
self.separate()
|
|
184
|
+
return self.data
|
|
185
|
+
|
|
186
|
+
def low_corr(self, target=None, threshold=0.1):
|
|
187
|
+
self.separate()
|
|
188
|
+
corr_matrix = self.numerical_columns.corr().abs()
|
|
189
|
+
if target is not None:
|
|
190
|
+
if target in corr_matrix.columns:
|
|
191
|
+
low_corr_cols = corr_matrix[target][corr_matrix[target] < threshold].index.tolist()
|
|
192
|
+
self.data = self.data.drop(columns=low_corr_cols)
|
|
193
|
+
self.separate()
|
|
194
|
+
else:
|
|
195
|
+
np.fill_diagonal(corr_matrix.values, 0)
|
|
196
|
+
low_corr_cols = corr_matrix.columns[(corr_matrix.max() < threshold)].tolist()
|
|
197
|
+
if low_corr_cols:
|
|
198
|
+
self.data = self.data.drop(columns=low_corr_cols)
|
|
199
|
+
self.separate()
|
|
200
|
+
return self.data
|
|
201
|
+
|
|
202
|
+
def summary(self):
|
|
203
|
+
self.load()
|
|
204
|
+
missing_cols = (self.data.isnull().sum() > 0).sum()
|
|
205
|
+
logger.info("Dataset summary: shape=%s, duplicates=%d, columns_with_missing=%d",
|
|
206
|
+
self.data.shape, self.data.duplicated().sum(), missing_cols)
|
|
207
|
+
return {
|
|
208
|
+
"shape": self.data.shape,
|
|
209
|
+
"duplicates": int(self.data.duplicated().sum()),
|
|
210
|
+
"columns_with_missing": int(missing_cols)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def export(self, output_path):
|
|
214
|
+
self.load()
|
|
215
|
+
self.data.to_csv(output_path, index=False)
|
|
216
|
+
logger.info("Saved cleaned dataset to '%s'", output_path)
|
|
217
|
+
|
|
218
|
+
def total_clean(
|
|
219
|
+
self,
|
|
220
|
+
data_time=None,
|
|
221
|
+
numeric_string_cols=None,
|
|
222
|
+
missing_param='m',
|
|
223
|
+
cat_fill_val=None,
|
|
224
|
+
outlier_method='IQR',
|
|
225
|
+
outlier_param='m',
|
|
226
|
+
outlier_threshold=None,
|
|
227
|
+
exclude_outliers=None,
|
|
228
|
+
null_threshold=0.7,
|
|
229
|
+
corr_target=None,
|
|
230
|
+
corr_threshold=None,
|
|
231
|
+
export_path=None
|
|
232
|
+
):
|
|
233
|
+
# 1. Load data
|
|
234
|
+
self.load()
|
|
235
|
+
|
|
236
|
+
# 2. Standardize column names
|
|
237
|
+
self.clean_column_names()
|
|
238
|
+
|
|
239
|
+
def _normalize_col(c):
|
|
240
|
+
import re
|
|
241
|
+
return re.sub(r"[^\w_]", "", re.sub(r"\s+", "_", str(c).strip().lower()))
|
|
242
|
+
|
|
243
|
+
if data_time is not None:
|
|
244
|
+
if isinstance(data_time, str):
|
|
245
|
+
c, fmt = data_time, None
|
|
246
|
+
elif len(data_time) == 1:
|
|
247
|
+
c, fmt = data_time[0], None
|
|
248
|
+
else:
|
|
249
|
+
c, fmt = data_time[0], data_time[1]
|
|
250
|
+
|
|
251
|
+
if c not in self.data.columns and _normalize_col(c) in self.data.columns:
|
|
252
|
+
data_time = [_normalize_col(c), fmt] if fmt else [_normalize_col(c)]
|
|
253
|
+
|
|
254
|
+
if numeric_string_cols:
|
|
255
|
+
numeric_string_cols = [
|
|
256
|
+
_normalize_col(c) if c not in self.data.columns and _normalize_col(c) in self.data.columns else c
|
|
257
|
+
for c in numeric_string_cols
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
if corr_target is not None:
|
|
261
|
+
if corr_target not in self.data.columns and _normalize_col(corr_target) in self.data.columns:
|
|
262
|
+
corr_target = _normalize_col(corr_target)
|
|
263
|
+
|
|
264
|
+
# 3. Remove duplicate rows
|
|
265
|
+
self.delete_duplicates()
|
|
266
|
+
|
|
267
|
+
# 4. Drop uninformative columns (high nulls or constant values)
|
|
268
|
+
if null_threshold is not None:
|
|
269
|
+
self.drop_uninformative(null_threshold=null_threshold)
|
|
270
|
+
|
|
271
|
+
# 5. Clean currency / formatted numeric strings into actual numbers
|
|
272
|
+
if numeric_string_cols:
|
|
273
|
+
self.clean_numeric_strings(numeric_string_cols)
|
|
274
|
+
|
|
275
|
+
# 6. Clean strings & format datetime
|
|
276
|
+
self.Clean_string(data_time=data_time)
|
|
277
|
+
|
|
278
|
+
# 7. Fill missing values (numerical)
|
|
279
|
+
self.fill_missing(param=missing_param)
|
|
280
|
+
|
|
281
|
+
# 8. Fill missing values (categorical)
|
|
282
|
+
self.fill_categorical_missing(fill_val=cat_fill_val)
|
|
283
|
+
|
|
284
|
+
# 9. Handle numerical outliers with target/exclude protection
|
|
285
|
+
exclude_set = set()
|
|
286
|
+
if exclude_outliers:
|
|
287
|
+
exclude_set.update([_normalize_col(c) if _normalize_col(c) in self.data.columns else c for c in exclude_outliers])
|
|
288
|
+
if corr_target:
|
|
289
|
+
exclude_set.add(corr_target)
|
|
290
|
+
|
|
291
|
+
if outlier_method and str(outlier_method).lower() != 'none':
|
|
292
|
+
if outlier_method == 'Z':
|
|
293
|
+
self.outliers_Z(param=outlier_param, threshold=outlier_threshold, exclude=exclude_set)
|
|
294
|
+
elif outlier_method == 'IQR':
|
|
295
|
+
self.outliers_IQR(param=outlier_param, exclude=exclude_set)
|
|
296
|
+
|
|
297
|
+
# 10. Drop low correlation columns if requested
|
|
298
|
+
if corr_threshold is not None:
|
|
299
|
+
self.low_corr(target=corr_target, threshold=corr_threshold)
|
|
300
|
+
|
|
301
|
+
# 11. Export if path specified
|
|
302
|
+
if export_path:
|
|
303
|
+
self.export(export_path)
|
|
304
|
+
|
|
305
|
+
return self.data
|
|
306
|
+
|