import base64 import pickle import pandas as pd import numpy as np import json from typing import List, Dict, Any from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder from sklearn.metrics import accuracy_score, roc_auc_score, precision_score, recall_score, confusion_matrix import xgboost as xgb def _is_id_column(series: pd.Series) -> bool: if series.dtype == object: n_unique = series.nunique() n_rows = len(series) if n_unique / n_rows > 0.8 or n_unique > 100: return True if series.str.match(r"^[A-Z]+-\d+$").all(): return True return False def _is_categorical(series: pd.Series) -> bool: if series.dtype == object or series.dtype.name == "category": n_unique = series.nunique() if 2 <= n_unique <= 20: return True return False def train_from_data(data: List[Dict[str, Any]], target_col: str, model_type: str = "churn") -> dict: df = pd.DataFrame(data) y = df[target_col] X = df.drop(columns=[target_col]) skipped = [] encoders = {} X_processed = pd.DataFrame(index=X.index) for col in X.columns: if _is_id_column(X[col]): skipped.append(col) continue if _is_categorical(X[col]): le = LabelEncoder() values = X[col].astype(str).fillna("__missing__") le.fit(values) X_processed[col] = le.transform(values) encoders[col] = {"classes_": le.classes_.tolist()} elif pd.api.types.is_numeric_dtype(X[col]): X_processed[col] = X[col].fillna(X[col].median() if not X[col].isna().all() else 0) else: skipped.append(col) if len(X_processed.columns) == 0: raise ValueError("No usable features found.") if y.nunique() < 2: raise ValueError(f"Target column needs both positive and negative examples.") X_train, X_test, y_train, y_test = train_test_split( X_processed, y, test_size=0.2, random_state=42, stratify=y ) pos = int(y_train.sum()) if hasattr(y_train, 'sum') else int((y_train == 1).sum()) neg = len(y_train) - pos scale_weight = neg / max(pos, 1) model = xgb.XGBClassifier( n_estimators=100, max_depth=5, learning_rate=0.08, subsample=0.8, colsample_bytree=0.8, scale_pos_weight=scale_weight, random_state=42, eval_metric="logloss" ) model.fit(X_train, y_train) y_pred = model.predict(X_test) y_proba = model.predict_proba(X_test)[:, 1] model_b64 = base64.b64encode(pickle.dumps(model)).decode() metrics = { "accuracy": round(float(accuracy_score(y_test, y_pred)), 4), "roc_auc": round(float(roc_auc_score(y_test, y_proba)), 4), "precision": round(float(precision_score(y_test, y_pred, zero_division=0)), 4), "recall": round(float(recall_score(y_test, y_pred, zero_division=0)), 4), "confusion_matrix": confusion_matrix(y_test, y_pred).tolist(), "feature_importance": dict( zip(X_processed.columns.tolist(), [float(v) for v in model.feature_importances_]) ), } f1_denom = metrics["precision"] + metrics["recall"] metrics["f1"] = round(2 * metrics["precision"] * metrics["recall"] / max(f1_denom, 1e-9), 4) summary = { "n_rows": len(df), "n_features": len(X_processed.columns), "target_col": target_col, "imbalance_ratio": round(neg / max(pos, 1), 1), "skipped_columns": skipped, } return { "model_binary": model_b64, "feature_names": X_processed.columns.tolist(), "encoders_json": json.dumps(encoders), "metrics": metrics, "metrics_json": json.dumps(metrics), "summary": summary, "summary_json": json.dumps(summary), "n_features": len(X_processed.columns), "n_rows": len(X), } def predict_with_model(model_b64: str, feature_names: list, encoders_json: str, data: List[Dict[str, Any]]) -> list: model = pickle.loads(base64.b64decode(model_b64)) encoders_data = json.loads(encoders_json) df = pd.DataFrame(data) for col, enc_info in encoders_data.items(): if col in df.columns: classes = enc_info["classes_"] mapping = {c: i for i, c in enumerate(classes)} df[col] = df[col].astype(str).map(lambda x: mapping.get(x, -1)) for col in feature_names: if col not in df.columns: df[col] = 0 X = df[feature_names].fillna(0) probs = model.predict_proba(X)[:, 1] scores = [round(float(p * 100), 1) for p in probs] return scores