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.
Files changed (35) hide show
  1. {serverlex-1.2.2 → serverlex-1.2.3}/PKG-INFO +1 -1
  2. {serverlex-1.2.2 → serverlex-1.2.3}/pyproject.toml +1 -1
  3. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/__init__.py +1 -1
  4. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/dataguard.py +93 -11
  5. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/preprocessing/clean.py +16 -0
  6. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/preprocessing/pipeline.py +45 -1
  7. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/serving/deployer.py +40 -4
  8. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/PKG-INFO +1 -1
  9. {serverlex-1.2.2 → serverlex-1.2.3}/setup.py +1 -1
  10. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_audit_remediation.py +49 -0
  11. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_deployer.py +14 -0
  12. {serverlex-1.2.2 → serverlex-1.2.3}/README.md +0 -0
  13. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/__init__.py +0 -0
  14. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/drift.py +0 -0
  15. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/evaluation.py +0 -0
  16. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/diagnostics/explainability.py +0 -0
  17. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/__init__.py +0 -0
  18. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/ensemble.py +0 -0
  19. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/hyperparameter.py +0 -0
  20. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/modeling/selector.py +0 -0
  21. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/preprocessing/__init__.py +0 -0
  22. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/serving/__init__.py +0 -0
  23. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/visualization/__init__.py +0 -0
  24. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex/visualization/eda.py +0 -0
  25. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/SOURCES.txt +0 -0
  26. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/dependency_links.txt +0 -0
  27. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/requires.txt +0 -0
  28. {serverlex-1.2.2 → serverlex-1.2.3}/serverlex.egg-info/top_level.txt +0 -0
  29. {serverlex-1.2.2 → serverlex-1.2.3}/setup.cfg +0 -0
  30. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_clean.py +0 -0
  31. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_dataguard.py +0 -0
  32. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_drift.py +0 -0
  33. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_leakage.py +0 -0
  34. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_parity.py +0 -0
  35. {serverlex-1.2.2 → serverlex-1.2.3}/tests/test_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverlex
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Production-Safe ML Preprocessing, Diagnostics, Tuning, and Serving Platform
5
5
  Author: Serverlex Team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "serverlex"
7
- version = "1.2.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" }]
@@ -24,4 +24,4 @@ __all__ = [
24
24
  "ModelDeployer",
25
25
  ]
26
26
 
27
- __version__ = "1.2.2"
27
+ __version__ = "1.2.3"
@@ -13,7 +13,15 @@ if not logger.handlers:
13
13
 
14
14
  class DataGuard:
15
15
  def __init__(self, df=None, target=None):
16
- self.data=df.copy() if df is not None else None
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
- num_cols=self.data.select_dtypes(include='number').copy()
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(self.data[target_to_use])
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]=self.data[target_to_use].astype('category').cat.codes
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
- counts=self.data[target_to_use].value_counts(normalize=True)
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
- cat_cols=self.data.select_dtypes(include=['object', 'category', 'string']).columns
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=self.data[col].nunique()
114
- if n_unique > threshold and col not in self.id_cols:
115
- self.high_cardinality_cols.append((col, n_unique))
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 self.high_cardinality_cols:
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 {self.high_cardinality_cols} have > {threshold} unique categories.",
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(r'[\$,]', '', regex=True).str.strip()
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. Snake case incoming column names
180
- clean_df.columns = [c.strip().lower().replace(' ', '_').replace('$', '') for c in 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
- missing_features.extend([c for c in self.pipeline.num_fill.keys() if c not in clean_df.columns])
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverlex
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Production-Safe ML Preprocessing, Diagnostics, Tuning, and Serving Platform
5
5
  Author: Serverlex Team
6
6
  License: MIT
@@ -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.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