serverlex 1.2.2__tar.gz → 1.2.3__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.2 → serverlex-1.2.3}/PKG-INFO +1 -1
- {serverlex-1.2.2 → serverlex-1.2.3}/pyproject.toml +1 -1
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/__init__.py +1 -1
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/dataguard.py +93 -11
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/preprocessing/clean.py +16 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/preprocessing/pipeline.py +45 -1
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/serving/deployer.py +40 -4
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/PKG-INFO +1 -1
- {serverlex-1.2.2 → serverlex-1.2.3}/setup.py +1 -1
- {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_audit_remediation.py +49 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_deployer.py +14 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/README.md +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/__init__.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/drift.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/evaluation.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/explainability.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/__init__.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/ensemble.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/hyperparameter.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/selector.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/preprocessing/__init__.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/serving/__init__.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/visualization/__init__.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/visualization/eda.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/SOURCES.txt +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/dependency_links.txt +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/requires.txt +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/top_level.txt +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/setup.cfg +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_clean.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_dataguard.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_drift.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_leakage.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_parity.py +0 -0
- {serverlex-1.2.2 → serverlex-1.2.3}/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.3"
|
|
8
8
|
description = "Production-Safe ML Preprocessing, Diagnostics, Tuning, and Serving Platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Serverlex Team" }]
|
|
@@ -13,7 +13,15 @@ if not logger.handlers:
|
|
|
13
13
|
|
|
14
14
|
class DataGuard:
|
|
15
15
|
def __init__(self, df=None, target=None):
|
|
16
|
-
|
|
16
|
+
if df is not None:
|
|
17
|
+
self.data=df.copy()
|
|
18
|
+
# Automatically strip embedded header rows (e.g. from CSV concatenation)
|
|
19
|
+
min_match = max(2, len(self.data.columns) // 2)
|
|
20
|
+
header_mask = (self.data == self.data.columns).sum(axis=1) >= min_match
|
|
21
|
+
if header_mask.any():
|
|
22
|
+
self.data = self.data[~header_mask].reset_index(drop=True)
|
|
23
|
+
else:
|
|
24
|
+
self.data=None
|
|
17
25
|
self.target=target
|
|
18
26
|
self.warnings=[]
|
|
19
27
|
self.id_cols=[]
|
|
@@ -22,8 +30,28 @@ class DataGuard:
|
|
|
22
30
|
|
|
23
31
|
def set_data(self, df):
|
|
24
32
|
self.data=df.copy()
|
|
33
|
+
if self.data is not None:
|
|
34
|
+
min_match = max(2, len(self.data.columns) // 2)
|
|
35
|
+
header_mask = (self.data == self.data.columns).sum(axis=1) >= min_match
|
|
36
|
+
if header_mask.any():
|
|
37
|
+
self.data = self.data[~header_mask].reset_index(drop=True)
|
|
25
38
|
self.warnings=[]
|
|
26
39
|
|
|
40
|
+
def _get_numeric_coerced_df(self):
|
|
41
|
+
"""Helper to coerce numeric-majority object columns so audit checks are not blinded by string dtypes."""
|
|
42
|
+
_UNIT_PAT = r'(?i)\s*(gb|mb|kb|tb|pb|yrs?|years?|months?|days?|hours?|hrs?|mins?|sec|seconds?|%|km|miles?|kg|lbs?)\s*$'
|
|
43
|
+
_SYM_PAT = r'[₹\$,/€£¥\s]'
|
|
44
|
+
df_coerced = self.data.copy()
|
|
45
|
+
for col in df_coerced.select_dtypes(include=['object', 'string']).columns:
|
|
46
|
+
sample = df_coerced[col].dropna().astype(str).str.strip()
|
|
47
|
+
if sample.empty or len(sample) < 5:
|
|
48
|
+
continue
|
|
49
|
+
cleaned = sample.str.replace(_UNIT_PAT, '', regex=True).str.replace(_SYM_PAT, '', regex=True).str.replace(',', '.', regex=False).str.strip()
|
|
50
|
+
coerced = pd.to_numeric(cleaned, errors='coerce')
|
|
51
|
+
if coerced.notna().mean() >= 0.70:
|
|
52
|
+
df_coerced[col] = coerced
|
|
53
|
+
return df_coerced
|
|
54
|
+
|
|
27
55
|
# 1. Detect ID Columns
|
|
28
56
|
def check_id_columns(self, threshold=0.95):
|
|
29
57
|
try:
|
|
@@ -56,12 +84,14 @@ class DataGuard:
|
|
|
56
84
|
if not target_to_use or target_to_use not in self.data.columns:
|
|
57
85
|
return []
|
|
58
86
|
|
|
59
|
-
|
|
87
|
+
# Use coerced numeric dataframe so string-formatted leakage (e.g. '$1,200', '0.994') is caught
|
|
88
|
+
df_coerced = self._get_numeric_coerced_df()
|
|
89
|
+
num_cols=df_coerced.select_dtypes(include='number').copy()
|
|
60
90
|
if target_to_use not in num_cols.columns:
|
|
61
91
|
try:
|
|
62
|
-
num_cols[target_to_use]=pd.to_numeric(
|
|
92
|
+
num_cols[target_to_use]=pd.to_numeric(df_coerced[target_to_use], errors='coerce')
|
|
63
93
|
except Exception:
|
|
64
|
-
num_cols[target_to_use]=
|
|
94
|
+
num_cols[target_to_use]=df_coerced[target_to_use].astype('category').cat.codes
|
|
65
95
|
|
|
66
96
|
if target_to_use in num_cols.columns:
|
|
67
97
|
corrs=num_cols.corr().abs()[target_to_use]
|
|
@@ -89,7 +119,11 @@ class DataGuard:
|
|
|
89
119
|
if not target_to_use or target_to_use not in self.data.columns:
|
|
90
120
|
return
|
|
91
121
|
|
|
92
|
-
|
|
122
|
+
target_series = pd.to_numeric(self.data[target_to_use], errors='coerce').dropna()
|
|
123
|
+
if target_series.empty:
|
|
124
|
+
target_series = self.data[target_to_use].dropna()
|
|
125
|
+
|
|
126
|
+
counts=target_series.value_counts(normalize=True)
|
|
93
127
|
if len(counts) > 1 and len(counts) <= 20:
|
|
94
128
|
minority_prop=counts.min()
|
|
95
129
|
if minority_prop < 0.10:
|
|
@@ -107,21 +141,69 @@ class DataGuard:
|
|
|
107
141
|
def check_cardinality(self, threshold=100):
|
|
108
142
|
try:
|
|
109
143
|
self.high_cardinality_cols=[]
|
|
110
|
-
|
|
144
|
+
# Use coerced numeric dataframe so numerical columns are not falsely treated as categoricals
|
|
145
|
+
df_coerced = self._get_numeric_coerced_df()
|
|
146
|
+
cat_cols=df_coerced.select_dtypes(include=['object', 'category', 'string']).columns
|
|
147
|
+
|
|
148
|
+
pii_cols = []
|
|
149
|
+
date_cols = []
|
|
150
|
+
generic_high_card = []
|
|
111
151
|
|
|
112
152
|
for col in cat_cols:
|
|
113
|
-
n_unique=
|
|
114
|
-
if
|
|
115
|
-
|
|
153
|
+
n_unique=df_coerced[col].nunique()
|
|
154
|
+
if col in self.id_cols:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
col_clean = re.sub(r'[^\w]', '', col.lower())
|
|
158
|
+
sample = df_coerced[col].dropna().astype(str).str.strip()
|
|
159
|
+
|
|
160
|
+
# Check if it's a datetime column
|
|
161
|
+
is_name_date = bool(re.search(r'(date|time|timestamp|dob)$', col_clean))
|
|
162
|
+
is_parse_date = (
|
|
163
|
+
pd.to_datetime(sample.iloc[:100], format='mixed', errors='coerce').notna().mean() >= 0.70
|
|
164
|
+
if not sample.empty else False
|
|
165
|
+
)
|
|
166
|
+
if is_name_date or is_parse_date:
|
|
167
|
+
date_cols.append((col, n_unique))
|
|
168
|
+
logger.info("Datetime column identified: '%s' (%d unique timestamps)", col, n_unique)
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Check if it's PII (name, email, phone, etc.)
|
|
172
|
+
is_pii = bool(re.search(r'(name|fullname|firstname|lastname|email|phone|address|ssn)$', col_clean))
|
|
173
|
+
if is_pii:
|
|
174
|
+
pii_cols.append((col, n_unique))
|
|
175
|
+
logger.warning("PII column detected: '%s' (%d unique values)", col, n_unique)
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
if n_unique > threshold:
|
|
179
|
+
generic_high_card.append((col, n_unique))
|
|
116
180
|
logger.warning("High cardinality column '%s': %d unique values", col, n_unique)
|
|
117
181
|
|
|
118
|
-
if
|
|
182
|
+
if pii_cols:
|
|
183
|
+
self.warnings.append({
|
|
184
|
+
"Check": "PII Detection",
|
|
185
|
+
"Status": "WARN",
|
|
186
|
+
"Details": f"Columns {pii_cols} appear to contain Personally Identifiable Information (PII).",
|
|
187
|
+
"Fix": "Drop PII columns to protect user privacy and prevent overfitting."
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if date_cols:
|
|
191
|
+
self.warnings.append({
|
|
192
|
+
"Check": "Datetime Features",
|
|
193
|
+
"Status": "INFO",
|
|
194
|
+
"Details": f"Columns {date_cols} are datetime series rather than plain categoricals.",
|
|
195
|
+
"Fix": "Extract datetime features (year, month, day, dayofweek) via pipeline's data_time parameter."
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
if generic_high_card:
|
|
119
199
|
self.warnings.append({
|
|
120
200
|
"Check": "High Cardinality",
|
|
121
201
|
"Status": "WARN",
|
|
122
|
-
"Details": f"Categorical columns {
|
|
202
|
+
"Details": f"Categorical columns {generic_high_card} have > {threshold} unique categories.",
|
|
123
203
|
"Fix": "Use Target/Frequency Encoding or group rare categories."
|
|
124
204
|
})
|
|
205
|
+
|
|
206
|
+
self.high_cardinality_cols = pii_cols + generic_high_card
|
|
125
207
|
return self.high_cardinality_cols
|
|
126
208
|
except Exception as e:
|
|
127
209
|
logger.error("Error in check_cardinality: %s", e)
|
|
@@ -162,6 +162,19 @@ class Clean:
|
|
|
162
162
|
self.separate()
|
|
163
163
|
return self.data
|
|
164
164
|
|
|
165
|
+
def drop_embedded_headers(self):
|
|
166
|
+
"""Remove rows that are copies of the header (e.g. from CSV concatenation)."""
|
|
167
|
+
self.load()
|
|
168
|
+
# A row is an embedded header if it matches ≥ half of column names exactly
|
|
169
|
+
min_match = max(2, len(self.data.columns) // 2)
|
|
170
|
+
mask = (self.data == self.data.columns).sum(axis=1) >= min_match
|
|
171
|
+
if mask.any():
|
|
172
|
+
n = int(mask.sum())
|
|
173
|
+
logger.warning("Dropping %d embedded header row(s) found in data", n)
|
|
174
|
+
self.data = self.data[~mask].reset_index(drop=True)
|
|
175
|
+
self.separate()
|
|
176
|
+
return self.data
|
|
177
|
+
|
|
165
178
|
def delete_duplicates(self):
|
|
166
179
|
self.load()
|
|
167
180
|
self.data = self.data.drop_duplicates()
|
|
@@ -271,6 +284,9 @@ class Clean:
|
|
|
271
284
|
# 1. Load data
|
|
272
285
|
self.load()
|
|
273
286
|
|
|
287
|
+
# 1a. Strip embedded header rows (e.g. from CSV concatenation) BEFORE column rename
|
|
288
|
+
self.drop_embedded_headers()
|
|
289
|
+
|
|
274
290
|
# 2. Standardize column names
|
|
275
291
|
self.clean_column_names()
|
|
276
292
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import re
|
|
2
3
|
import pandas as pd
|
|
3
4
|
import numpy as np
|
|
4
5
|
from serverlex.preprocessing.clean import Clean
|
|
@@ -72,6 +73,34 @@ class MLPipeline:
|
|
|
72
73
|
|
|
73
74
|
logger.info("Fitting pipeline on %d rows (target=%s, encode=%s, scale=%s)", len(self.data), self.target, self.encode, self.scale)
|
|
74
75
|
|
|
76
|
+
# 0. Auto-detect object columns that are predominantly numeric (≥70% parseable).
|
|
77
|
+
# This handles columns read as strings from dirty CSVs (e.g. "9.6 GB", "50 kg", "0,0")
|
|
78
|
+
# so they are coerced to numeric rather than one-hot encoded as categoricals.
|
|
79
|
+
_UNIT_PAT = r'(?i)\s*(gb|mb|kb|tb|pb|yrs?|years?|months?|days?|hours?|hrs?|mins?|sec|seconds?|%|km|miles?|kg|lbs?)\s*$'
|
|
80
|
+
_SYM_PAT = r'[₹\$,/€£¥\s]'
|
|
81
|
+
_auto_numeric = set(numeric_string_cols or [])
|
|
82
|
+
for col in self.data.select_dtypes(include=['object', 'string']).columns:
|
|
83
|
+
if self.target and col.strip().lower() == self.target:
|
|
84
|
+
continue
|
|
85
|
+
sample = self.data[col].dropna().astype(str).str.strip()
|
|
86
|
+
if sample.empty or len(sample) < 5:
|
|
87
|
+
continue
|
|
88
|
+
cleaned_sample = (
|
|
89
|
+
sample
|
|
90
|
+
.str.replace(_UNIT_PAT, '', regex=True)
|
|
91
|
+
.str.replace(_SYM_PAT, '', regex=True)
|
|
92
|
+
.str.replace(',', '.', regex=False)
|
|
93
|
+
.str.strip()
|
|
94
|
+
)
|
|
95
|
+
parse_rate = pd.to_numeric(cleaned_sample, errors='coerce').notna().mean()
|
|
96
|
+
if parse_rate >= 0.70:
|
|
97
|
+
_auto_numeric.add(col)
|
|
98
|
+
if _auto_numeric:
|
|
99
|
+
extra = sorted(_auto_numeric - set(numeric_string_cols or []))
|
|
100
|
+
if extra:
|
|
101
|
+
logger.info("Auto-detected numeric-majority string columns (will coerce): %s", extra)
|
|
102
|
+
numeric_string_cols = list(_auto_numeric) if _auto_numeric else numeric_string_cols
|
|
103
|
+
|
|
75
104
|
# 1. Clean the training data using your Clean class with target protection
|
|
76
105
|
clean_kwargs = kwargs.copy()
|
|
77
106
|
if self.target:
|
|
@@ -188,12 +217,27 @@ class MLPipeline:
|
|
|
188
217
|
out.columns=out.columns.str.strip().str.lower().str.replace(' ', '_').str.replace(r'[^\w_]', '', regex=True)
|
|
189
218
|
|
|
190
219
|
# Clean numeric string columns using saved self.numeric_string_cols
|
|
220
|
+
_UNIT_PAT2 = r'(?i)\s*(gb|mb|kb|tb|pb|yrs?|years?|months?|days?|hours?|hrs?|mins?|sec|seconds?|%|km|miles?|kg|lbs?)\s*$'
|
|
221
|
+
_SYM_PAT2 = r'[₹\$,/€£¥]'
|
|
191
222
|
if hasattr(self, 'numeric_string_cols') and self.numeric_string_cols:
|
|
192
223
|
for col in self.numeric_string_cols:
|
|
193
224
|
if col in out.columns:
|
|
194
|
-
cleaned=out[col].astype(str).str.replace(
|
|
225
|
+
cleaned=out[col].astype(str).str.replace(_SYM_PAT2, '', regex=True).str.replace(_UNIT_PAT2, '', regex=True).str.replace(',', '.', regex=False).str.strip()
|
|
195
226
|
out[col]=pd.to_numeric(cleaned, errors='coerce')
|
|
196
227
|
|
|
228
|
+
# Also coerce any num_fill column that is still string/object dtype
|
|
229
|
+
# (handles auto-detected columns stored after column name cleaning)
|
|
230
|
+
for col in list(self.num_fill.keys()):
|
|
231
|
+
if col in out.columns and (out[col].dtype == object or str(out[col].dtype).startswith('str')):
|
|
232
|
+
cleaned = (
|
|
233
|
+
out[col].astype(str)
|
|
234
|
+
.str.replace(_UNIT_PAT2, '', regex=True)
|
|
235
|
+
.str.replace(_SYM_PAT2, '', regex=True)
|
|
236
|
+
.str.replace(',', '.', regex=False)
|
|
237
|
+
.str.strip()
|
|
238
|
+
)
|
|
239
|
+
out[col] = pd.to_numeric(cleaned, errors='coerce')
|
|
240
|
+
|
|
197
241
|
# 2. Extract datetime features using saved date_col
|
|
198
242
|
if self.date_col and self.date_col in out.columns:
|
|
199
243
|
dt=pd.to_datetime(out[self.date_col], format='mixed', errors='coerce')
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
import hashlib
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
import joblib
|
|
6
7
|
import pandas as pd
|
|
7
8
|
import numpy as np
|
|
@@ -176,8 +177,22 @@ class ModelDeployer:
|
|
|
176
177
|
"""
|
|
177
178
|
clean_df = df.copy()
|
|
178
179
|
|
|
179
|
-
# 1.
|
|
180
|
-
clean_df.columns = [
|
|
180
|
+
# 1. Standardize incoming column names to match Clean.clean_column_names
|
|
181
|
+
clean_df.columns = [
|
|
182
|
+
re.sub(r"[^\w_]", "", c.strip().lower().replace(' ', '_'))
|
|
183
|
+
for c in clean_df.columns
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
# 1b. Expand date_col into sub-features (mirrors pipeline.transform) so users
|
|
187
|
+
# can pass e.g. signup_date instead of signup_date_year/month/day/dayofweek.
|
|
188
|
+
date_col = getattr(self.pipeline, "date_col", None) if self.pipeline else None
|
|
189
|
+
if date_col and date_col in clean_df.columns:
|
|
190
|
+
dt = pd.to_datetime(clean_df[date_col], format='mixed', errors='coerce')
|
|
191
|
+
clean_df[f'{date_col}_year'] = dt.dt.year
|
|
192
|
+
clean_df[f'{date_col}_month'] = dt.dt.month
|
|
193
|
+
clean_df[f'{date_col}_day'] = dt.dt.day
|
|
194
|
+
clean_df[f'{date_col}_dayofweek'] = dt.dt.dayofweek
|
|
195
|
+
clean_df = clean_df.drop(columns=[date_col])
|
|
181
196
|
|
|
182
197
|
# 2. Strict mode validation checks before coercion/imputation
|
|
183
198
|
if strict and self.pipeline is not None:
|
|
@@ -203,11 +218,19 @@ class ModelDeployer:
|
|
|
203
218
|
problems.append(f"{col}: non-numeric values")
|
|
204
219
|
|
|
205
220
|
# Check missing required training features
|
|
221
|
+
date_col = getattr(self.pipeline, "date_col", None)
|
|
222
|
+
generated_date_cols = {f"{date_col}_year", f"{date_col}_month", f"{date_col}_day", f"{date_col}_dayofweek"} if date_col else set()
|
|
223
|
+
|
|
206
224
|
missing_features = []
|
|
207
225
|
if hasattr(self.pipeline, "num_fill"):
|
|
208
|
-
|
|
226
|
+
missing_num = [c for c in self.pipeline.num_fill.keys() if c not in clean_df.columns]
|
|
227
|
+
if date_col and date_col in clean_df.columns:
|
|
228
|
+
missing_num = [c for c in missing_num if c not in generated_date_cols]
|
|
229
|
+
missing_features.extend(missing_num)
|
|
230
|
+
|
|
209
231
|
if hasattr(self.pipeline, "cat_fill"):
|
|
210
232
|
missing_features.extend([c for c in self.pipeline.cat_fill.keys() if c not in clean_df.columns])
|
|
233
|
+
|
|
211
234
|
if missing_features:
|
|
212
235
|
problems.append(f"missing required training features: {missing_features}")
|
|
213
236
|
|
|
@@ -215,9 +238,15 @@ class ModelDeployer:
|
|
|
215
238
|
raise ValueError("Strict deployment error: " + "; ".join(problems))
|
|
216
239
|
|
|
217
240
|
# 3. Handle numerical missing columns and currency parsing
|
|
241
|
+
# Note: date_col sub-features were already expanded above; the source date col is gone.
|
|
242
|
+
generated_date_cols = {f"{date_col}_year", f"{date_col}_month", f"{date_col}_day", f"{date_col}_dayofweek"} if date_col else set()
|
|
243
|
+
|
|
218
244
|
if hasattr(self.pipeline, "num_fill") and self.pipeline.num_fill:
|
|
219
245
|
for num_col, fill_val in self.pipeline.num_fill.items():
|
|
220
246
|
if num_col not in clean_df.columns:
|
|
247
|
+
if num_col in generated_date_cols:
|
|
248
|
+
# Already expanded from date_col above; skip imputation
|
|
249
|
+
continue
|
|
221
250
|
clean_df[num_col] = fill_val
|
|
222
251
|
logger.info("Missing numerical feature '%s' imputed with training baseline: %s", num_col, fill_val)
|
|
223
252
|
else:
|
|
@@ -226,6 +255,7 @@ class ModelDeployer:
|
|
|
226
255
|
stripped = col_vals.astype(str).str.replace(r'[\$,]', '', regex=True).str.strip()
|
|
227
256
|
clean_df[num_col] = pd.to_numeric(stripped, errors='coerce').fillna(fill_val)
|
|
228
257
|
|
|
258
|
+
|
|
229
259
|
# 4. Handle categorical missing columns
|
|
230
260
|
if hasattr(self.pipeline, "cat_fill") and self.pipeline.cat_fill:
|
|
231
261
|
for cat_col, fill_val in self.pipeline.cat_fill.items():
|
|
@@ -247,7 +277,7 @@ class ModelDeployer:
|
|
|
247
277
|
|
|
248
278
|
return self._validate_and_sanitize(df, strict=strict)
|
|
249
279
|
|
|
250
|
-
def predict(self, raw_input, strict=False):
|
|
280
|
+
def predict(self, raw_input, strict=False, threshold=None):
|
|
251
281
|
if self.pipeline is None or self.model is None:
|
|
252
282
|
raise AttributeError("Pipeline and model must be loaded before calling predict().")
|
|
253
283
|
|
|
@@ -259,6 +289,12 @@ class ModelDeployer:
|
|
|
259
289
|
else:
|
|
260
290
|
X = transformed_res
|
|
261
291
|
|
|
292
|
+
# If a custom decision threshold is provided and model supports probabilities
|
|
293
|
+
if threshold is not None and hasattr(self.model, "predict_proba"):
|
|
294
|
+
proba = self.model.predict_proba(X)
|
|
295
|
+
if proba.ndim == 2 and proba.shape[1] >= 2:
|
|
296
|
+
return (proba[:, 1] >= threshold).astype(int)
|
|
297
|
+
|
|
262
298
|
predictions = self.model.predict(X)
|
|
263
299
|
return predictions
|
|
264
300
|
|
|
@@ -6,7 +6,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
|
6
6
|
|
|
7
7
|
setup(
|
|
8
8
|
name="serverlex",
|
|
9
|
-
version="1.2.
|
|
9
|
+
version="1.2.3",
|
|
10
10
|
author="Serverlex Team",
|
|
11
11
|
description="Production-Safe ML Preprocessing, Diagnostics, Tuning, and Serving Platform",
|
|
12
12
|
long_description=long_description,
|
|
@@ -249,3 +249,52 @@ def test_deployer_hmac_missing_key_clear_error(tmp_path):
|
|
|
249
249
|
with pytest.raises(ValueError, match="saved with HMAC authenticity protection"):
|
|
250
250
|
ModelDeployer.load(bundle_path)
|
|
251
251
|
|
|
252
|
+
|
|
253
|
+
def test_clean_drops_embedded_header_rows():
|
|
254
|
+
"""1.2.3 Regression: Embedded duplicate header rows from CSV concatenation are dropped."""
|
|
255
|
+
df = pd.DataFrame({
|
|
256
|
+
"age": ["25", "30", "age", "45"],
|
|
257
|
+
"fee": ["100", "200", "fee", "300"],
|
|
258
|
+
"plan": ["basic", "standard", "plan", "premium"]
|
|
259
|
+
})
|
|
260
|
+
cleaner = Clean(df)
|
|
261
|
+
cleaner.drop_embedded_headers()
|
|
262
|
+
assert len(cleaner.data) == 3
|
|
263
|
+
assert "age" not in cleaner.data["age"].values
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_pipeline_auto_coerces_numeric_strings_and_serving(tmp_path):
|
|
267
|
+
"""1.2.3 Regression: Columns with units/symbols (9.6 GB, 0,0) are auto-coerced to numeric and deploy cleanly."""
|
|
268
|
+
df = pd.DataFrame({
|
|
269
|
+
"signup_date": ["2024-01-01", "2024-02-01", "2024-03-01", "2024-04-01"] * 10,
|
|
270
|
+
"usage_gb": ["6.5", "9.6 GB", "0,0", "12.2"] * 10,
|
|
271
|
+
"fee_per_gb": ["45.5", "50.0", "10.2", "88.1"] * 10,
|
|
272
|
+
"auto_renew": ["0", "1", "0", "1"] * 10,
|
|
273
|
+
"churn": [0, 1, 0, 1] * 10
|
|
274
|
+
})
|
|
275
|
+
pipeline = MLPipeline()
|
|
276
|
+
pipeline.fit(df, target="churn", data_time=["signup_date"], encode="onehot", scale="standard")
|
|
277
|
+
|
|
278
|
+
# usage_gb and fee_per_gb should be numeric, not one-hot encoded
|
|
279
|
+
assert "usage_gb" in pipeline.num_fill
|
|
280
|
+
assert "fee_per_gb" in pipeline.num_fill
|
|
281
|
+
assert "usage_gb" not in pipeline.cat_categories
|
|
282
|
+
|
|
283
|
+
X, y = pipeline.transform(df)
|
|
284
|
+
assert X.shape[1] < 15 # not blown up into dozens of categories
|
|
285
|
+
|
|
286
|
+
model = RandomForestClassifier(n_estimators=10, random_state=42).fit(X, y)
|
|
287
|
+
deployer = ModelDeployer(pipeline=pipeline, model=model)
|
|
288
|
+
bundle_file = str(tmp_path / "auto_bundle.joblib")
|
|
289
|
+
deployer.save(bundle_file)
|
|
290
|
+
|
|
291
|
+
loaded = ModelDeployer.load(bundle_file)
|
|
292
|
+
sample = {
|
|
293
|
+
"signup_date": "2024-05-01",
|
|
294
|
+
"usage_gb": 10.5,
|
|
295
|
+
"fee_per_gb": 47.92,
|
|
296
|
+
"auto_renew": 1
|
|
297
|
+
}
|
|
298
|
+
pred = loaded.predict(sample, strict=True)
|
|
299
|
+
assert len(pred) == 1
|
|
300
|
+
|
|
@@ -97,3 +97,17 @@ def test_deployer_batch_prediction(trained_bundle):
|
|
|
97
97
|
]
|
|
98
98
|
preds = deployer.predict(batch)
|
|
99
99
|
assert len(preds) == 2
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_deployer_custom_decision_threshold(trained_bundle):
|
|
103
|
+
deployer = ModelDeployer.load(trained_bundle)
|
|
104
|
+
sample = {"age": 30.0, "income": 50000.0, "city": "New York"}
|
|
105
|
+
proba = deployer.predict_proba(sample)[0][1]
|
|
106
|
+
|
|
107
|
+
# Extreme low threshold forces 1 (positive)
|
|
108
|
+
pred_low = deployer.predict(sample, threshold=0.0)
|
|
109
|
+
assert pred_low[0] == 1
|
|
110
|
+
|
|
111
|
+
# Extreme high threshold forces 0 (negative)
|
|
112
|
+
pred_high = deployer.predict(sample, threshold=1.0)
|
|
113
|
+
assert pred_high[0] == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|