⚙ MLOps Review PR #47 ChurnRadar v2.1

Revisión de Machine Learning en Producción

RetailIQ SaaS  ·  XGBoost Churn Model  ·  Revisado: 18 jun 2026  ·  Revisor: CULTIVA IA / MLE Reviewer
🚫
Decisión final
BLOCK
3
Críticos
2
Altos
2
Medios
1
Bajos
5
Archivos revisados
4
Áreas afectadas
🛑
BLOCK — No desplegar a producción
3 riesgos críticos deben resolverse antes de cualquier merge
Riesgos primarios:
⚠ Fuga de datos (data leakage) ⚠ Serving inseguro (pickle sin validación) ⚠ Entrenamiento irreproducible ↑ Transformaciones desincronizadas train/serve ~ Monitorización incompleta
Crítico
● Crítico
Split aleatorio en datos temporales — fuga de datos garantizada
training/train.py : 112
El PR reemplaza el split temporal por train_test_split(random_state=42), que mezcla datos de distintos periodos. El modelo de churn predice en el futuro; si el conjunto de test contiene clientes cuya actividad futura ya está en el conjunto de train, el AUC-ROC reportado (0.847) está inflado y no refleja el rendimiento real en producción.
# ❌ INCORRECTO — mezcla periodos temporales X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) # ✅ CORRECTO — split temporal basado en fecha de corte cutoff = pd.Timestamp('2026-03-01') train_mask = features_df['cohort_date'] < cutoff X_train, y_train = X[train_mask], y[train_mask] X_test, y_test = X[~train_mask], y[~train_mask]
Restaurar split temporal o por entidad (customer_id). Re-evaluar AUC-ROC y F1 con el split correcto antes de cualquier comparación con el modelo en producción.
● Crítico
Deserialización pickle sin validación de integridad — RCE en serving
serving/predict.py : 8–10
El serving carga el artefacto con pickle.load() sin verificar hash, firma digital ni procedencia del fichero. Un artefacto comprometido en el bucket S3 ejecutaría código arbitrario al arrancar el servidor. Además, el PR guarda el modelo en models/ con ruta hardcoded y sin versión explícita en el nombre.
# ❌ Sin verificación de integridad with open('models/churn_model_v2.1.pkl', 'rb') as f: model = pickle.load(f) # RCE si artefacto comprometido # ✅ Migrar a joblib con hash SHA-256 pre-carga import hashlib, joblib EXPECTED_SHA256 = "abc123..." # del artifact registry artifact_path = os.environ["MODEL_ARTIFACT_PATH"] # sin hardcode with open(artifact_path, 'rb') as f: data = f.read() assert hashlib.sha256(data).hexdigest() == EXPECTED_SHA256, "Artifact tampered" model = joblib.load(artifact_path)
Migrar a joblib o MLflow model registry. Verificar SHA-256 del artefacto antes de cargarlo. Parametrizar la ruta por variable de entorno. Añadir model_version en el output de predicción.
● Crítico
Transformaciones train/serve desincronizadas — predicciones silenciosamente incorrectas
serving/predict.py : 14–21  vs  training/feature_engineering.py : 23–35
El serving reimplementa las features manualmente en vez de importar feature_engineering.build_features(). Las features RFM (recency_score, frequency_score, monetary_score) no se calculan en serving; en su lugar hay un df.get('rfm_score', 0) que cae silenciosamente a 0 para todos los clientes. El modelo recibe vectores de features distintos a los de entrenamiento.
# ❌ En serving — rfm_score siempre 0, feature drift silencioso df['rfm_score'] = df.get('rfm_score', 0) # ✅ Importar el mismo módulo de features from training.feature_engineering import build_features def predict_churn(customer_data: dict) -> dict: df = pd.DataFrame([customer_data]) df = build_features(df) # misma transformación que en train score = model.predict_proba(df[FEATURE_COLS])[:, 1][0] return {"churn_probability": score, "model_version": MODEL_VERSION}
Unificar transformaciones en un módulo compartido shared/feature_engineering.py importado tanto en training como en serving. Añadir test de equivalencia assert build_features(sample) == serve_features(sample) en CI.
Alto
● Alto
Threshold 0.38 tuneado en notebook local — promoción sin gate reproducible
notebooks/threshold_analysis.ipynb  /  training/train.py : 132
El umbral de decisión se eligió manualmente en un notebook ejecutado en local, sin dataset versionado ni artefacto de resultado. La justificación del PR es "ver notebook". El notebook no está en CI y puede tener estado oculto. Si el notebook se re-ejecuta sobre datos distintos, el threshold podría cambiar sin dejar rastro.
Convertir el análisis de threshold en un script Python con dataset versionado y output guardado como artefacto (threshold.json). El threshold debe declararse en config (config/model.yaml) con comentario de tradeoff F1/recall, no como constante mágica.
● Alto
Evaluación sin slice metrics — posible regresión en segmentos clave no detectada
training/train.py : 135–142
El PR solo reporta AUC-ROC y F1 globales. En churn B2B, los segmentos de alto valor (enterprise vs SMB), antigüedad de cliente y verticales pueden comportarse de forma muy diferente. Una mejora global puede enmascarar regresión en clientes enterprise (alto LTV, alto coste de falso positivo).
Añadir evaluación por segmento: customer_tier (enterprise/mid/smb), industry_vertical, y clientes con antigüedad >12 meses. Documentar gates de promoción: si AUC enterprise cae >2pp respecto a producción, BLOCK.
Medio
● Medio
Monitoring limitado a uptime — sin drift de features ni calidad de predicciones
monitoring/alerts.yaml
El fichero de alertas solo monitoriza uptime del servicio. No hay alertas para: (1) drift de distribución en features de entrada, (2) proporción de predicciones positivas (prediction drift), (3) latencia de inferencia por percentil, (4) llegada tardía de etiquetas reales para evaluación online.
Añadir alertas: PSI >0.2 en features RFM, tasa de churn predicha fuera de rango [0.05, 0.35], p99 latencia >200ms, ausencia de etiquetas reales en ventana de 7 días.
● Medio
3 errores mypy silenciados con # type: ignore sin justificación
training/train.py (múltiples líneas)
Los # type: ignore no documentados ocultan posibles errores de tipo en el pipeline de features. En producción, errores de tipo silenciados pueden causar comportamiento inesperado sin fallo explícito (ej. float vs int en scores RFM).
Añadir comentario explicativo a cada # type: ignore o resolver los errores. Habilitar --strict en mypy para módulos críticos de ML.
Bajo
● Bajo
model_version ausente en el output de predicción
serving/predict.py : 24
El endpoint devuelve {"churn_probability": ..., "churn_flag": ...} sin incluir la versión del modelo. Esto impide unir predicciones pasadas con el modelo que las generó cuando se haga rollback o comparación A/B.
Añadir "model_version": MODEL_VERSION al response. Leer MODEL_VERSION de variable de entorno o de metadata del artefacto.