cleanflow-kit 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,874 @@
1
+ """
2
+ Model Trainer Module
3
+ ====================
4
+ Advanced ML training with auto-detection, multi-model comparison,
5
+ cross-validation, hyperparameter tuning, and model export.
6
+ Memory-optimized for 512MB hosting (no SHAP, reduced models).
7
+ """
8
+
9
+ import logging
10
+ logger = logging.getLogger(__name__)
11
+ import pandas as pd
12
+ import numpy as np
13
+ import time
14
+ import joblib
15
+ import re
16
+ import gc
17
+ import warnings
18
+ from datetime import datetime
19
+ from typing import Optional, Dict, Any, List, Tuple
20
+ from .feature_engineer import FeatureEngineer
21
+ from .data_cleaner import DataCleaner
22
+
23
+ from sklearn.model_selection import (
24
+ cross_validate, StratifiedKFold, KFold, RandomizedSearchCV
25
+ )
26
+ from sklearn.pipeline import Pipeline, make_pipeline
27
+ from sklearn.compose import ColumnTransformer, make_column_transformer
28
+ from sklearn.impute import SimpleImputer
29
+ from sklearn.preprocessing import StandardScaler, LabelEncoder, OrdinalEncoder
30
+ from sklearn.linear_model import LogisticRegression, Ridge
31
+ from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
32
+ from sklearn.metrics import (
33
+ accuracy_score, precision_score, recall_score, f1_score, roc_auc_score,
34
+ r2_score, mean_absolute_error, mean_squared_error,
35
+ classification_report, make_scorer
36
+ )
37
+ from ._compat import normalize_string_columns
38
+
39
+ HAS_XGBOOST = False # Disabled for memory optimization
40
+
41
+ try:
42
+ from imblearn.over_sampling import SMOTE
43
+ HAS_SMOTE = True
44
+ except ImportError:
45
+ HAS_SMOTE = False
46
+
47
+ warnings.filterwarnings('ignore')
48
+
49
+
50
+ # ─── Column pattern filters ─────────────────────────────────────────────────
51
+ ID_PATTERNS = re.compile(
52
+ r'^(id|_id|index|row_?num|serial|sr_?no|unnamed)', re.IGNORECASE
53
+ )
54
+ URL_PATTERNS = re.compile(
55
+ r'(url|link|href|src|image|img|photo|avatar|thumbnail|path|file)', re.IGNORECASE
56
+ )
57
+ TARGET_HINTS = [
58
+ 'target', 'label', 'class', 'outcome', 'result', 'y',
59
+ 'is_', 'has_', 'survived', 'churn', 'fraud', 'default',
60
+ 'price', 'salary', 'revenue', 'cost', 'amount', 'score'
61
+ ]
62
+
63
+
64
+ class ModelTrainer:
65
+ """
66
+ Advanced ML model trainer with auto-detection, multi-model comparison,
67
+ cross-validation, hyperparameter tuning, and explainability.
68
+
69
+ Example
70
+ -------
71
+ >>> trainer = ModelTrainer(df, target_col='price', problem_type='regression')
72
+ >>> results = trainer.run()
73
+ >>> print(results['best_model'])
74
+ >>> print(results['metrics_dashboard'])
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ df: pd.DataFrame,
80
+ target_col: Optional[str] = None,
81
+ problem_type: Optional[str] = None,
82
+ raw_df: Optional[pd.DataFrame] = None
83
+ ):
84
+ self.original_df = normalize_string_columns(
85
+ raw_df.copy() if raw_df is not None else df.copy()
86
+ )
87
+ self.df = normalize_string_columns(df.copy())
88
+ self.target_col = target_col
89
+ self.problem_type = problem_type # 'classification' or 'regression'
90
+ self.warnings: List[str] = []
91
+ self.log: List[str] = []
92
+
93
+ # Will be set during prepare
94
+ self.X = None
95
+ self.y = None
96
+ self.feature_names: List[str] = []
97
+ self.scaler = None
98
+ self.label_encoder = None
99
+
100
+ # Results
101
+ self.models: Dict[str, Any] = {}
102
+ self.cv_results: Dict[str, Dict] = {}
103
+ self.best_model_name: str = ''
104
+ self.best_model = None
105
+ self.best_metrics: Dict[str, float] = {}
106
+ self.importances: Dict[str, float] = {}
107
+
108
+ # ─── 1. Target Detection ────────────────────────────────────────────
109
+ def detect_target(self) -> str:
110
+ """Auto-detect or validate the target column."""
111
+ cols = self.df.columns.tolist()
112
+
113
+ # If already provided, validate it
114
+ if self.target_col:
115
+ if self.target_col in cols:
116
+ self.log.append(f"Target column confirmed: '{self.target_col}'")
117
+ return self.target_col
118
+ else:
119
+ self.warnings.append(
120
+ f"Specified target '{self.target_col}' not found in data."
121
+ )
122
+ self.target_col = None
123
+
124
+ # Try heuristic search
125
+ for hint in TARGET_HINTS:
126
+ for col in cols:
127
+ if hint.lower() == col.lower() or col.lower().startswith(hint.lower()):
128
+ self.target_col = col
129
+ self.log.append(f"Auto-detected target column: '{col}' (matched hint '{hint}')")
130
+ return col
131
+
132
+ # Fallback: last column
133
+ self.target_col = cols[-1]
134
+ self.warnings.append(
135
+ f"No obvious target found — using last column '{self.target_col}'. "
136
+ "You can specify a target column explicitly."
137
+ )
138
+ self.log.append(f"Fallback target column: '{self.target_col}'")
139
+ return self.target_col
140
+
141
+ # ─── 2. Problem Type Detection ──────────────────────────────────────
142
+ def detect_problem_type(self) -> str:
143
+ """Detect whether this is a classification or regression problem."""
144
+ if self.target_col is None:
145
+ self.detect_target()
146
+
147
+ y = self.df[self.target_col]
148
+
149
+ # User override with validation
150
+ if self.problem_type:
151
+ if self.problem_type == 'regression':
152
+ # Validate that target is numeric
153
+ if y.dtype == 'object' or y.dtype.name == 'category':
154
+ try:
155
+ pd.to_numeric(y, errors='raise')
156
+ except Exception:
157
+ self.warnings.append(
158
+ f"Regression requested but target '{self.target_col}' is non-numeric. "
159
+ "Switching to classification."
160
+ )
161
+ self.log.append(f"Auto-corrected problem type to 'classification' (target is string)")
162
+ self.problem_type = 'classification'
163
+ return 'classification'
164
+
165
+ self.log.append(f"Problem type specified: {self.problem_type}")
166
+ return self.problem_type
167
+
168
+ # Auto-detection
169
+ # Object/category → classification
170
+ if y.dtype == 'object' or y.dtype.name == 'category':
171
+ self.problem_type = 'classification'
172
+ # Few unique values → classification
173
+ elif y.nunique() <= 15 and y.nunique() / len(y) < 0.05:
174
+ self.problem_type = 'classification'
175
+ else:
176
+ self.problem_type = 'regression'
177
+
178
+ self.log.append(f"Auto-detected problem type: {self.problem_type}")
179
+ return self.problem_type
180
+
181
+ # ─── 3. Feature Preparation ─────────────────────────────────────────
182
+ def prepare_features(self) -> Tuple[np.ndarray, np.ndarray]:
183
+ """Full feature preparation pipeline. Returns (X, y)."""
184
+ self.detect_target()
185
+ self.detect_problem_type()
186
+
187
+ if len(self.df) < 100:
188
+ self.warnings.append(
189
+ f"⚠ Small dataset ({len(self.df)} rows). "
190
+ "Results may be unreliable. Consider collecting more data."
191
+ )
192
+
193
+ fe = FeatureEngineer(self.df, target_col=self.target_col, problem_type=self.problem_type)
194
+ fe.create_datetime_features()
195
+ fe.encode_categorical()
196
+ fe.create_binned_features(n_bins=5)
197
+ fe.drop_highly_correlated(threshold=0.9)
198
+
199
+ # Merge log
200
+ self.log.extend(fe.get_summary().get("transformations", []))
201
+ self.df = fe.get_transformed_data()
202
+
203
+ # Separate target
204
+ y = self.df[self.target_col].copy()
205
+ X = self.df.drop(columns=[self.target_col]).copy()
206
+
207
+ # Drop any remaining non-numeric
208
+ non_numeric = X.select_dtypes(exclude=[np.number]).columns.tolist()
209
+ if non_numeric:
210
+ X.drop(columns=non_numeric, inplace=True)
211
+ self.log.append(f"Dropped {len(non_numeric)} non-numeric columns before training")
212
+
213
+ X = X.fillna(X.median())
214
+
215
+ # target encode
216
+ if self.problem_type == 'classification' and y.dtype == 'object':
217
+ self.label_encoder = LabelEncoder()
218
+ y = pd.Series(self.label_encoder.fit_transform(y), name=self.target_col)
219
+ self.log.append("Label-encoded target column")
220
+
221
+ y = y.fillna(y.mode()[0] if self.problem_type == 'classification' else y.median())
222
+
223
+ self.feature_names = X.columns.tolist()
224
+
225
+ # Scaling is already supported by FE
226
+ fe_scaler = FeatureEngineer(X, target_col=None, problem_type=self.problem_type)
227
+ fe_scaler.scale_features(method='standard')
228
+ X_scaled = fe_scaler.get_transformed_data().values
229
+ self.scaler = fe_scaler.scalers.get('main', None)
230
+ self.log.append(f"Scaled {len(self.feature_names)} features")
231
+
232
+ self.X = X_scaled
233
+ self.y = y.values if hasattr(y, 'values') else np.array(y)
234
+
235
+ self.log.append(f"Final training shape: X={self.X.shape}, y={self.y.shape}")
236
+ return self.X, self.y
237
+
238
+ def detect_imbalance(self) -> Dict[str, Any]:
239
+ """Detect class imbalance for classification problems."""
240
+ if self.problem_type != 'classification':
241
+ return {'imbalanced': False, 'reason': 'Regression problem'}
242
+
243
+ counts = pd.Series(self.y).value_counts(normalize=True)
244
+ majority_pct = counts.iloc[0]
245
+ minority_pct = counts.iloc[-1]
246
+
247
+ imbalanced = majority_pct > 0.70
248
+ info = {
249
+ 'imbalanced': imbalanced,
250
+ 'majority_class': str(counts.index[0]),
251
+ 'majority_pct': round(majority_pct * 100, 1),
252
+ 'minority_class': str(counts.index[-1]),
253
+ 'minority_pct': round(minority_pct * 100, 1),
254
+ 'class_distribution': {str(k): round(v * 100, 1) for k, v in counts.items()}
255
+ }
256
+
257
+ if imbalanced:
258
+ self.warnings.append(
259
+ f"Class imbalance detected: majority {info['majority_pct']}% / "
260
+ f"minority {info['minority_pct']}%. Using class weights or SMOTE."
261
+ )
262
+ self.log.append(f"Imbalance detected: {info['class_distribution']}")
263
+
264
+ return info
265
+
266
+ # ─── 5. Model Definitions ──────────────────────────────────────────
267
+ def _get_models_and_params(self) -> Dict[str, Dict]:
268
+ """Return model instances and hyperparameter grids."""
269
+ imbalance = self.detect_imbalance()
270
+ use_balanced = imbalance.get('imbalanced', False)
271
+ n_classes = len(np.unique(self.y)) if self.problem_type == 'classification' else 0
272
+
273
+ if self.problem_type == 'classification':
274
+ models = {
275
+ 'Logistic Regression': {
276
+ 'model': LogisticRegression(
277
+ max_iter=1000, random_state=42,
278
+ class_weight='balanced' if use_balanced else None
279
+ ),
280
+ 'params': {
281
+ 'C': [0.1, 1, 10],
282
+ 'solver': ['lbfgs']
283
+ }
284
+ },
285
+ 'Random Forest': {
286
+ 'model': RandomForestClassifier(
287
+ random_state=42, n_jobs=1,
288
+ class_weight='balanced' if use_balanced else None
289
+ ),
290
+ 'params': {
291
+ 'n_estimators': [50, 100],
292
+ 'max_depth': [5, 10],
293
+ 'min_samples_split': [2, 5]
294
+ }
295
+ }
296
+ }
297
+ else: # regression
298
+ models = {
299
+ 'Ridge Regression': {
300
+ 'model': Ridge(random_state=42),
301
+ 'params': {
302
+ 'alpha': [0.1, 1, 10]
303
+ }
304
+ },
305
+ 'Random Forest': {
306
+ 'model': RandomForestRegressor(random_state=42, n_jobs=1),
307
+ 'params': {
308
+ 'n_estimators': [50, 100],
309
+ 'max_depth': [5, 10],
310
+ 'min_samples_split': [2, 5]
311
+ }
312
+ }
313
+ }
314
+
315
+ return models
316
+
317
+ # ─── 6. Training ───────────────────────────────────────────────────
318
+ def train_all_models(self) -> Dict[str, Dict]:
319
+ """Train all models with cross-validation and hyperparameter tuning."""
320
+ if self.X is None:
321
+ self.prepare_features()
322
+
323
+ model_defs = self._get_models_and_params()
324
+
325
+ # Handle SMOTE for classification imbalance
326
+ X_train, y_train = self.X, self.y
327
+ imbalance = self.detect_imbalance()
328
+ if imbalance.get('imbalanced') and HAS_SMOTE and self.problem_type == 'classification':
329
+ try:
330
+ smote = SMOTE(random_state=42)
331
+ X_train, y_train = smote.fit_resample(X_train, y_train)
332
+ self.log.append(f"Applied SMOTE: {len(self.y)} → {len(y_train)} samples")
333
+ except Exception as e:
334
+ self.log.append(f"SMOTE failed, using original data: {e}")
335
+
336
+ # CV strategy
337
+ n_folds = 3
338
+ if self.problem_type == 'classification':
339
+ cv = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=42)
340
+ scoring_primary = 'f1_weighted'
341
+ scoring = ['accuracy', 'precision_weighted', 'recall_weighted', 'f1_weighted']
342
+ try:
343
+ if len(np.unique(y_train)) == 2:
344
+ scoring.append('roc_auc')
345
+ except Exception:
346
+ pass
347
+ else:
348
+ cv = KFold(n_splits=n_folds, shuffle=True, random_state=42)
349
+ scoring_primary = 'r2'
350
+ scoring = ['r2', 'neg_mean_absolute_error', 'neg_root_mean_squared_error']
351
+
352
+ # Limit search iterations for speed
353
+ n_iter = min(10, max(3, 100 // len(model_defs)))
354
+
355
+ for name, config in model_defs.items():
356
+ self.log.append(f"Training {name}...")
357
+ try:
358
+ # Hyperparameter tuning
359
+ search = RandomizedSearchCV(
360
+ config['model'],
361
+ config['params'],
362
+ n_iter=min(n_iter, self._param_combinations(config['params'])),
363
+ cv=cv,
364
+ scoring=scoring_primary,
365
+ random_state=42,
366
+ n_jobs=1,
367
+ error_score='raise'
368
+ )
369
+ search.fit(X_train, y_train)
370
+ best_estimator = search.best_estimator_
371
+
372
+ # Cross-validate with all metrics
373
+ cv_scores = cross_validate(
374
+ best_estimator, X_train, y_train,
375
+ cv=cv, scoring=scoring, n_jobs=1
376
+ )
377
+
378
+ # Store results
379
+ metrics = {}
380
+ for metric_name in scoring:
381
+ key = f'test_{metric_name}'
382
+ if key in cv_scores:
383
+ scores = cv_scores[key]
384
+ # Convert negative metrics
385
+ if metric_name.startswith('neg_'):
386
+ scores = -scores
387
+ clean_name = metric_name.replace('neg_', '')
388
+ else:
389
+ clean_name = metric_name
390
+ metrics[clean_name] = {
391
+ 'mean': round(float(np.mean(scores)), 4),
392
+ 'std': round(float(np.std(scores)), 4)
393
+ }
394
+
395
+ self.models[name] = best_estimator
396
+ self.cv_results[name] = {
397
+ 'metrics': metrics,
398
+ 'best_params': search.best_params_,
399
+ 'best_cv_score': round(float(search.best_score_), 4)
400
+ }
401
+ self.log.append(f" {name} → best CV score: {search.best_score_:.4f}")
402
+
403
+ except Exception as e:
404
+ self.log.append(f" {name} failed: {str(e)[:100]}")
405
+ self.cv_results[name] = {'error': str(e)}
406
+ finally:
407
+ gc.collect()
408
+
409
+ return self.cv_results
410
+
411
+ def _param_combinations(self, params: Dict) -> int:
412
+ """Calculate total parameter combinations."""
413
+ total = 1
414
+ for v in params.values():
415
+ total *= len(v)
416
+ return total
417
+
418
+ # ─── 7. Best Model Selection ───────────────────────────────────────
419
+ def get_best_model(self) -> Dict[str, Any]:
420
+ """Select the best model based on primary metric."""
421
+ if not self.cv_results:
422
+ self.train_all_models()
423
+
424
+ primary_metric = 'f1_weighted' if self.problem_type == 'classification' else 'r2'
425
+
426
+ best_score = -np.inf
427
+ for name, result in self.cv_results.items():
428
+ if 'error' in result:
429
+ continue
430
+ score = result['metrics'].get(primary_metric, {}).get('mean', -np.inf)
431
+ if score > best_score:
432
+ best_score = score
433
+ self.best_model_name = name
434
+
435
+ if self.best_model_name:
436
+ self.best_model = self.models[self.best_model_name]
437
+ self.best_metrics = self.cv_results[self.best_model_name]['metrics']
438
+ self.log.append(f"Best model: {self.best_model_name} ({primary_metric}={best_score:.4f})")
439
+
440
+ return {
441
+ 'name': self.best_model_name,
442
+ 'metrics': self.best_metrics,
443
+ 'params': self.cv_results.get(self.best_model_name, {}).get('best_params', {})
444
+ }
445
+
446
+ # ─── 8. Feature Importance ─────────────────────────────────────────
447
+ def get_feature_importance(self, top_n: int = 10) -> Dict[str, float]:
448
+ """Extract top feature importances from the best model."""
449
+ if self.best_model is None:
450
+ self.get_best_model()
451
+ if self.best_model is None:
452
+ return {}
453
+
454
+ importances = None
455
+ try:
456
+ if hasattr(self.best_model, 'feature_importances_'):
457
+ importances = self.best_model.feature_importances_
458
+ elif hasattr(self.best_model, 'coef_'):
459
+ coef = self.best_model.coef_
460
+ if coef.ndim > 1:
461
+ importances = np.mean(np.abs(coef), axis=0)
462
+ else:
463
+ importances = np.abs(coef)
464
+ except Exception:
465
+ pass
466
+
467
+ if importances is None or len(importances) != len(self.feature_names):
468
+ return {}
469
+
470
+ imp_dict = dict(zip(self.feature_names, importances.tolist()))
471
+ imp_dict = dict(sorted(imp_dict.items(), key=lambda x: x[1], reverse=True))
472
+
473
+ self.importances = {k: round(v, 6) for k, v in list(imp_dict.items())[:top_n]}
474
+ return self.importances
475
+
476
+ # ─── 9. Metrics Dashboard ──────────────────────────────────────────
477
+ def get_metrics_dashboard(self) -> Dict[str, Any]:
478
+ """Build a complete metrics dashboard."""
479
+ if not self.cv_results:
480
+ self.train_all_models()
481
+
482
+ dashboard = {
483
+ 'problem_type': self.problem_type,
484
+ 'target_column': self.target_col,
485
+ 'dataset_shape': {
486
+ 'rows': int(self.X.shape[0]),
487
+ 'features': int(self.X.shape[1])
488
+ },
489
+ 'models_compared': {},
490
+ 'best_model': self.get_best_model(),
491
+ 'feature_importance': self.get_feature_importance(),
492
+ 'reliability': self.get_reliability_score(),
493
+ 'warnings': self.warnings,
494
+ 'log': self.log,
495
+ 'explanation': self.explain_model() # Add explanation
496
+ }
497
+
498
+ # Per-model summary
499
+ for name, result in self.cv_results.items():
500
+ if 'error' in result:
501
+ dashboard['models_compared'][name] = {'error': result['error']}
502
+ else:
503
+ dashboard['models_compared'][name] = {
504
+ 'metrics': result['metrics'],
505
+ 'best_params': result['best_params']
506
+ }
507
+
508
+ return dashboard
509
+
510
+ # ─── 15. Explainability (disabled for memory) ─────────────────────
511
+ def explain_model(self) -> Dict[str, Any]:
512
+ """SHAP disabled for memory optimization on 512MB hosting."""
513
+ return {'available': False, 'error': 'Disabled for memory optimization'}
514
+
515
+ # ─── 14. Full Pipeline Run ─────────────────────────────────────────
516
+ def run(self) -> Dict[str, Any]:
517
+ """
518
+ Run the full ML training pipeline:
519
+ 1. Detect target & problem type
520
+ 2. Prepare features
521
+ 3. Train all models with CV + tuning
522
+ 4. Select best model
523
+ 5. Get feature importance
524
+ 6. Build metrics dashboard
525
+ """
526
+ self.prepare_features()
527
+ self.train_all_models()
528
+ self.get_best_model()
529
+ dashboard = self.get_metrics_dashboard()
530
+ gc.collect()
531
+ return dashboard
532
+
533
+ # ─── 10. Reliability Score ─────────────────────────────────────────
534
+ def _compute_reliability(self, n_rows: int, cv_std: float, metric_val: float) -> Dict[str, Any]:
535
+ """
536
+ Compute a 0–100 reliability score based on:
537
+ - Dataset size (bigger = better)
538
+ - CV score variance (lower = better)
539
+ - Primary metric quality
540
+ """
541
+ score = 50 # baseline
542
+ reasons = []
543
+
544
+ # Dataset size factor (0–25 pts)
545
+ if n_rows >= 10000:
546
+ score += 25
547
+ reasons.append("Large dataset (+25)")
548
+ elif n_rows >= 1000:
549
+ pts = int(15 + (n_rows - 1000) / 900 * 10)
550
+ score += pts
551
+ reasons.append(f"Medium dataset (+{pts})")
552
+ elif n_rows >= 100:
553
+ pts = int(5 + (n_rows - 100) / 900 * 10)
554
+ score += pts
555
+ reasons.append(f"Small dataset (+{pts})")
556
+ else:
557
+ score -= 15
558
+ reasons.append("Very small dataset (-15)")
559
+
560
+ # CV variance factor (0–15 pts)
561
+ # Use provided std dev
562
+ if cv_std < 0.02:
563
+ score += 15
564
+ reasons.append("Very stable CV scores (+15)")
565
+ elif cv_std < 0.05:
566
+ score += 10
567
+ reasons.append("Stable CV scores (+10)")
568
+ elif cv_std < 0.1:
569
+ score += 5
570
+ reasons.append("Moderate CV variance (+5)")
571
+ else:
572
+ score -= 5
573
+ reasons.append("High CV variance (-5)")
574
+
575
+ # Metric quality factor (0–10 pts)
576
+ if metric_val >= 0.9:
577
+ score += 10
578
+ reasons.append("Excellent primary metric (+10)")
579
+ elif metric_val >= 0.75:
580
+ score += 5
581
+ reasons.append("Good primary metric (+5)")
582
+ elif metric_val < 0.5:
583
+ score -= 10
584
+ reasons.append("Poor primary metric (-10)")
585
+
586
+ score = max(0, min(100, score))
587
+ return {
588
+ 'score': score,
589
+ 'grade': 'A' if score >= 85 else 'B' if score >= 70 else 'C' if score >= 50 else 'D',
590
+ 'reasons': reasons
591
+ }
592
+
593
+ def get_reliability_score(self) -> Dict[str, Any]:
594
+ """Wrapper for current model reliability."""
595
+ n = self.X.shape[0] if self.X is not None else len(self.df)
596
+
597
+ primary = 'f1_weighted' if self.problem_type == 'classification' else 'r2'
598
+ std = 0.1
599
+ val = 0
600
+
601
+ if self.best_metrics:
602
+ std = self.best_metrics.get(primary, {}).get('std', 0.1)
603
+ val = self.best_metrics.get(primary, {}).get('mean', 0)
604
+
605
+ return self._compute_reliability(n, std, val)
606
+
607
+ # ─── 11. Export ────────────────────────────────────────────────────
608
+ def export_model(self, path: str) -> str:
609
+ """Export the best model and metadata to a .pkl file."""
610
+ if self.best_model is None:
611
+ self.get_best_model()
612
+
613
+ export_data = {
614
+ 'model': self.best_model,
615
+ 'model_name': self.best_model_name,
616
+ 'scaler': self.scaler,
617
+ 'label_encoder': self.label_encoder,
618
+ 'feature_names': self.feature_names,
619
+ 'target_col': self.target_col,
620
+ 'problem_type': self.problem_type,
621
+ 'metrics': self.best_metrics,
622
+ 'feature_importance': self.importances,
623
+ 'exported_at': datetime.now().isoformat(),
624
+ 'reliability': self.get_reliability_score()
625
+ }
626
+
627
+ joblib.dump(export_data, path)
628
+ self.log.append(f"Model exported to '{path}'")
629
+ return path
630
+
631
+ # ─── 13. Comparison Logic ──────────────────────────────────────────
632
+ def train_naive_baseline(self) -> Dict[str, Any]:
633
+ """
634
+ Train a 'Naive' model on raw data for comparison.
635
+ Strategy: 'Just Encoding' + Minimal Imputation (to prevent crash).
636
+ """
637
+ try:
638
+ if self.original_df is None:
639
+ msg = "Raw data not available for comparison"
640
+ self.log.append(msg)
641
+ return {"score": 0.0, "metrics": {}, "error": msg, "time_taken": 0, "memory_mb": 0}
642
+
643
+ # work on a copy of original_df
644
+ df = self.original_df.copy()
645
+
646
+ # 1. Drop IDs (essential) but keep almost everything else
647
+ for col in df.columns:
648
+ if col == self.target_col: continue
649
+ if ID_PATTERNS.match(col) or URL_PATTERNS.search(col):
650
+ df.drop(columns=[col], inplace=True)
651
+
652
+ # 2. Handle Target (and potential cleaning-renaming mismatch)
653
+ if self.target_col not in df.columns:
654
+ # Try case-insensitive match
655
+ col_map = {c.lower(): c for c in df.columns}
656
+ if self.target_col.lower() in col_map:
657
+ found_col = col_map[self.target_col.lower()]
658
+ df.rename(columns={found_col: self.target_col}, inplace=True)
659
+ self.log.append(f"Mapped raw target '{found_col}' to '{self.target_col}'")
660
+ else:
661
+ # Fail gracefully
662
+ msg = f"Target '{self.target_col}' not found in raw data"
663
+ self.log.append(msg)
664
+ return {"score": 0.0, "metrics": {}, "error": msg, "time_taken": 0, "memory_mb": 0}
665
+
666
+ y = df[self.target_col]
667
+ X = df.drop(columns=[self.target_col])
668
+
669
+ # 3. Naive Preprocessing (The "Lazy" Way - using Pipeline to avoid leakage)
670
+
671
+ # Identify columns
672
+ num_cols = X.select_dtypes(include=[np.number]).columns
673
+ cat_cols = X.select_dtypes(exclude=[np.number]).columns
674
+
675
+ # Preprocessing for numeric data: simple mean imputation
676
+ num_transformer = SimpleImputer(strategy='mean')
677
+
678
+ # Preprocessing for categorical data: constant fill + ordinal encoding
679
+ # OrdinalEncoder is used because it handles 2D arrays (unlike LabelEncoder)
680
+ # and we can handle unknown values by encoding them as -1
681
+ cat_transformer = make_pipeline(
682
+ SimpleImputer(strategy='constant', fill_value='missing'),
683
+ OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1)
684
+ )
685
+
686
+ preprocessor = make_column_transformer(
687
+ (num_transformer, num_cols),
688
+ (cat_transformer, cat_cols),
689
+ remainder='passthrough'
690
+ )
691
+
692
+ # Encode target if necessary
693
+ if self.problem_type == 'classification' and y.dtype == 'object':
694
+ le = LabelEncoder()
695
+ y = le.fit_transform(y.astype(str))
696
+
697
+ # 4. Train Naive Model (Simple Decision Tree)
698
+ if self.problem_type == 'classification':
699
+ from sklearn.tree import DecisionTreeClassifier
700
+
701
+ model = DecisionTreeClassifier(random_state=42)
702
+ scoring = 'accuracy'
703
+ else:
704
+ from sklearn.tree import DecisionTreeRegressor
705
+ model = DecisionTreeRegressor(random_state=42)
706
+ scoring = 'r2'
707
+
708
+ # Create full pipeline
709
+ # Note: We don't scale or do anything fancy. Just impute -> encode -> tree.
710
+ pipeline = make_pipeline(preprocessor, model)
711
+
712
+ # Quick CV
713
+ n_folds = 3
714
+ metrics = {}
715
+ if self.problem_type == 'classification':
716
+ cv = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=42)
717
+ scoring_dict = {
718
+ 'accuracy': 'accuracy',
719
+ 'precision': 'precision_weighted',
720
+ 'f1': 'f1_weighted'
721
+ }
722
+ else:
723
+ cv = KFold(n_splits=n_folds, shuffle=True, random_state=42)
724
+ scoring_dict = {
725
+ 'r2': 'r2',
726
+ 'mae': 'neg_mean_absolute_error',
727
+ 'mse': 'neg_mean_squared_error'
728
+ }
729
+
730
+ start_time = time.time()
731
+ try:
732
+ cv_results = cross_validate(pipeline, X, y, cv=cv, scoring=scoring_dict)
733
+ for metric_name, scorer_name in scoring_dict.items():
734
+ key = f"test_{metric_name}"
735
+ if key in cv_results:
736
+ score = np.mean(cv_results[key])
737
+ if 'neg_' in scorer_name: score = -score
738
+ metrics[metric_name] = round(score, 4)
739
+
740
+ # Main score for comparison
741
+ main_metric = 'accuracy' if self.problem_type == 'classification' else 'r2'
742
+ mean_score = metrics.get(main_metric, 0)
743
+
744
+ except ValueError as ve:
745
+ # Fallback for very small datasets
746
+ model.fit(preprocessor.fit_transform(X, y), y)
747
+ mean_score = model.score(preprocessor.transform(X), y)
748
+ metrics[main_metric] = round(mean_score, 4)
749
+
750
+ end_time = time.time()
751
+ time_taken = round(end_time - start_time, 4)
752
+ memory_mb = round(self.original_df.memory_usage(deep=True).sum() / (1024 * 1024), 2)
753
+
754
+ # Calculate Reliability for Raw
755
+ # Dataset size
756
+ n_raw = len(df)
757
+
758
+ # Std dev of primary metric
759
+ try:
760
+ if 'results' in locals() and results is not None:
761
+ if self.problem_type == 'classification':
762
+ raw_std = results['test_f1'].std()
763
+ raw_val = results['test_f1'].mean()
764
+ else:
765
+ raw_std = results['test_r2'].std()
766
+ raw_val = results['test_r2'].mean()
767
+ else:
768
+ raise ValueError("Results not available")
769
+ except Exception:
770
+ # Fallback if CV failed or results missing
771
+ raw_std = 0.2 # Assume high variance
772
+ raw_val = mean_score
773
+
774
+ reliability = self._compute_reliability(n_raw, raw_std, raw_val)
775
+
776
+ return {
777
+ "score": round(mean_score, 4),
778
+ "metrics": metrics,
779
+ "model_type": "Decision Tree (Baseline)",
780
+ "time_taken": time_taken,
781
+ "memory_mb": memory_mb,
782
+ "reliability": reliability
783
+ }
784
+
785
+ except Exception as e:
786
+ logger.debug(f"Naive baseline failed: {e}")
787
+ logger.debug(f"Target: {self.target_col}")
788
+ if self.original_df is not None:
789
+ logger.debug(f"Columns: {self.original_df.columns.tolist()}")
790
+ self.log.append(f"Naive baseline failed: {e}")
791
+ return {"score": 0.0, "metrics": {}, "error": str(e), "time_taken": 0, "memory_mb": 0}
792
+
793
+ def run_full_comparison(self) -> Dict[str, Any]:
794
+ """
795
+ Run both Naive (Raw) and Advanced (Cleaned) pipelines and compare.
796
+ """
797
+ # 1. Train Naive
798
+ naive_results = self.train_naive_baseline()
799
+
800
+ # 2. Run Advanced Pipeline (with timing)
801
+ start_adv = time.time()
802
+ advanced_dashboard = self.run()
803
+ end_adv = time.time()
804
+ advanced_dashboard['time_taken'] = round(end_adv - start_adv, 4)
805
+
806
+ # 3. Compare
807
+ advanced_score = 0
808
+ advanced_metrics = {}
809
+
810
+ # Calculate Advanced Stats
811
+ start_time_adv = time.time()
812
+ # (The 'run' method was already called above, so we can't truly measure it here unless we wrap 'self.run()'.
813
+ # But 'self.run()' includes 'train_all_models' which logs time.
814
+ # For this demo, let's assume Advanced takes slightly longer than Baseline + overhead due to search)
815
+ # We'll use a heuristic based on complexity or just measure overhead of this function call as proxy if 'run' isn't wrapped.
816
+ # BETTER: Let's assume 'run' took some time. We can't retroactively measure it easily without changing 'run'.
817
+ # Let's use a placeholder heuristic: Advanced Time = Baseline Time * 1.5 (Simulated for Demo effect as "Optimization" usually implies *better* result but *more* compute?
818
+ # Actually user asked for "optimization like cpu time... by cleaned data".
819
+ # Let's calculate memory for cleaned df.
820
+ advanced_memory_mb = round(self.df.memory_usage(deep=True).sum() / (1024 * 1024), 2)
821
+
822
+ # In a real app we'd time 'self.run()' properly.
823
+ # Let's wrap 'self.run()' in the caller next time. For now, let's estimate advanced_time based on log entries or just use a random factor of baseline to show data.
824
+ # Wait, I can just measure 'time.time()' before and after 'self.run()' call in this method!
825
+ # Ah, 'self.run()' is called at line 958. I should have wrapped it there.
826
+ # I will replace the whole function to wrap 'self.run()' with timing.
827
+
828
+ if 'best_model' in advanced_dashboard and 'metrics' in advanced_dashboard['best_model']:
829
+ all_metrics = advanced_dashboard['best_model']['metrics']
830
+ logger.debug(f"Available metrics keys: {list(all_metrics.keys())}")
831
+ for k, v in all_metrics.items():
832
+ if isinstance(v, dict) and 'mean' in v:
833
+ # Normalize keys: backend might use 'f1_weighted', frontend wants 'f1'
834
+ metric_key = k
835
+ if k == 'f1_weighted': metric_key = 'f1'
836
+ if k == 'precision_weighted': metric_key = 'precision'
837
+ advanced_metrics[metric_key] = round(v['mean'], 4)
838
+
839
+ if self.problem_type == 'classification':
840
+ advanced_score = advanced_metrics.get('accuracy', 0)
841
+ else:
842
+ advanced_score = advanced_metrics.get('r2', 0)
843
+
844
+ naive_score = naive_results.get('score', 0)
845
+ naive_metrics = naive_results.get('metrics', {})
846
+
847
+ # Calculate Improvement
848
+ improvement = 0
849
+ if naive_score != 0:
850
+ improvement = ((advanced_score - naive_score) / abs(naive_score)) * 100
851
+ elif advanced_score > 0:
852
+ improvement = 100
853
+
854
+ # Add comparison to dashboard
855
+ advanced_dashboard['comparison'] = {
856
+ 'raw_score': naive_score,
857
+ 'cleaned_score': advanced_score,
858
+ 'raw_metrics': naive_metrics,
859
+ 'cleaned_metrics': advanced_metrics,
860
+ 'improvement_pct': round(improvement, 1),
861
+ 'metric': 'Accuracy' if self.problem_type == 'classification' else 'R² Score',
862
+ 'raw_error': naive_results.get('error'),
863
+ 'raw_reliability': naive_results.get('reliability'),
864
+ 'optimization': {
865
+ 'raw_time': naive_results.get('time_taken', 0),
866
+ 'cleaned_time': advanced_dashboard.get('time_taken', 0), # Will be set by wrapper
867
+ 'raw_space': naive_results.get('memory_mb', 0),
868
+ 'cleaned_space': advanced_memory_mb
869
+ }
870
+ }
871
+
872
+ return advanced_dashboard
873
+
874
+