Predicción de bajas de clientes
Qué clientes de una operadora están a punto de irse, a quién merece la pena llamar y cuánto dinero ahorra hacerlo bien: un proyecto de machine learning completo, del problema de negocio a la API, con resultados reales.
- Problema
- Datos
- Exploración
- Preprocesado
- Modelos candidatos
- Optimización
- Evaluación en test
- Umbral de negocio
- Interpretación
- Calculadora de riesgo
- Despliegue
- Resultados y conclusiones
01Problema
Una operadora de telecomunicaciones pierde cada mes una parte de sus clientes (churn). Conseguir un cliente nuevo cuesta bastante más que conservar uno, así que el equipo de retención quiere llamar a tiempo a quienes están a punto de irse con una oferta. Pero llamar a todos es caro, y llamar al azar no sirve.
Objetivo: dar a cada cliente una probabilidad de baja y decidir a quién contactar para que la campaña gane el máximo dinero posible.
| Pregunta | Respuesta en este proyecto |
|---|---|
| Tipo de problema | Clasificación binaria (se va / se queda) con clases desbalanceadas: 26,5 % de bajas |
| Métrica técnica | ROC-AUC (ordenar bien por riesgo) y PR-AUC (calidad al buscar la clase minoritaria) |
| Métrica de negocio | Beneficio de la campaña de retención, según un umbral de decisión |
| Restricción | El equipo comercial tiene que entender por qué el modelo marca a un cliente |
02Datos
Dataset IBM Telco Customer Churn: 7.043 clientes de una operadora ficticia de California, publicado por IBM como ejemplo (licencia Apache 2.0). Cada fila es un cliente con 19 variables y la etiqueta Churn (si se dio de baja en el último mes).
| Grupo | Variables |
|---|---|
| Perfil | Género, mayor de 65 años, pareja, personas a cargo |
| Contrato | Antigüedad en meses (tenure), tipo de contrato, factura electrónica, método de pago |
| Servicios | Teléfono, varias líneas, tipo de internet (DSL o fibra), seguridad, copia de seguridad, protección de dispositivos, soporte técnico, TV y películas en streaming |
| Facturación | Cuota mensual y total facturado |
Primer problema de calidad: TotalCharges se carga como texto porque 11 clientes lo tienen en blanco. Los 11 tienen antigüedad 0: son altas recientes que aún no han recibido ninguna factura. Se rellena con 0, que es su valor real, en vez de borrar las filas o usar la mediana.
df = pd.read_csv("data/telco_churn.csv")
df.shape # (7043, 21)
(df["TotalCharges"].str.strip() == "").sum() # 11, todos con tenure = 0
df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce").fillna(0.0)
df["Churn"] = (df["Churn"] == "Yes").astype(int)
df["Churn"].mean() # 0.26503Exploración
Antes de entrenar nada: ¿qué clientes se van más? La tasa global es del 26,5 %, pero cambia muchísimo según el grupo.
| Variable | Grupo con más bajas | Grupo con menos bajas |
|---|---|---|
| Internet | Fibra óptica: 41,9 % | Sin internet: 7,4 % |
| Método de pago | Cheque electrónico: 45,3 % | Tarjeta automática: 15,2 % |
| Soporte técnico | Sin soporte: 41,6 % | Con soporte: 15,2 % |
| Edad | Mayores de 65: 41,7 % | Resto: 23,6 % |
| Factura | Electrónica: 33,6 % | En papel: 16,3 % |
Dos avisos para las etapas siguientes: tenure y TotalCharges están muy correlacionadas (r = 0,83), porque el total es aproximadamente la cuota por los meses; y la fibra tiene más bajas, pero también es más cara. Muchas variables cuentan la misma historia, y eso complicará leer los coeficientes del modelo.
¿Correlación o causa? Los clientes con fibra se van más, pero eso no significa que la fibra provoque bajas: puede deberse a su precio o a la competencia en esas zonas. Lo explica el nivel 3 de estadística.
04Preprocesado
- División estratificada 80/20 antes de mirar nada más: 5.634 clientes para entrenar y 1.409 para el test final, ambos con un 26,5 % de bajas. El test se guarda bajo llave hasta el final.
- Numéricas (antigüedad, cuota, total): estandarizadas.
- Categóricas (16 columnas): one-hot; las de sí/no se quedan en una sola columna. En total, 40 columnas.
- Todo dentro de un Pipeline: el escalado y la codificación se aprenden solo con los datos de entrenamiento de cada pliegue, sin fugas de información del test.
num = ["tenure", "MonthlyCharges", "TotalCharges"]
cat = [c for c in X.columns if c not in num]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
prep = ColumnTransformer([
("num", StandardScaler(), num),
("cat", OneHotEncoder(handle_unknown="ignore", drop="if_binary"), cat),
])05Modelos candidatos
Cuatro candidatos, de menos a más complejo, comparados con validación cruzada estratificada de 5 pliegues sobre el conjunto de entrenamiento. La línea base siempre predice «se queda» y sirve para saber de dónde partimos.
| Modelo | ROC-AUC (5 pliegues) | PR-AUC |
|---|---|---|
| Línea base | 0,500 | 0,265 |
| Regresión logística | 0,846 ± 0,013 | 0,662 |
| Random Forest (400 árboles) | 0,844 ± 0,010 | 0,658 |
| XGBoost (300 árboles, profundidad 4) | 0,844 ± 0,009 | 0,660 |
Resultado importante: los tres modelos reales empatan. La diferencia entre ellos (0,002) es mucho menor que lo que varía cada uno de un pliegue a otro (±0,01). En datos tabulares pequeños y con relaciones casi lineales, un modelo sencillo suele bastar. Los artículos de regresión logística, Random Forest y gradient boosting explican cada uno.
06Optimización
Búsqueda en rejilla con la misma validación cruzada:
| Modelo | Hiperparámetros probados | Mejores | ROC-AUC |
|---|---|---|---|
| Regresión logística | C ∈ {0,01; 0,03; 0,1; 0,3; 1; 3} | C = 3 (poca regularización) | 0,846 |
| XGBoost | árboles {200, 400} · tasa {0,02; 0,05} · profundidad {2, 3, 4} | 400 árboles, tasa 0,02, profundidad 2 | 0,850 |
Afinar XGBoost gana 0,006 de AUC, y el ganador usa árboles de profundidad 2, casi lineales: otra pista de que el problema no necesita un modelo muy complejo.
07Evaluación en test
El conjunto de test se usa una sola vez, con los dos finalistas:
| Modelo | ROC-AUC | PR-AUC | Precisión (umbral 0,5) | Recall (umbral 0,5) |
|---|---|---|---|---|
| Regresión logística | 0,842 | 0,631 | 66,1 % | 56,4 % |
| XGBoost | 0,845 | 0,662 | 66,2 % | 51,3 % |
Rinden prácticamente igual. Nos quedamos con la regresión logística: da probabilidades bien calibradas (Brier 0,138), se explica fácilmente al equipo comercial y se despliega en unos pocos kilobytes.
¿Sirve para ordenar a los clientes?
El 20 % de clientes con más riesgo contiene casi la mitad de todas las bajas (49,7 %). Esa capacidad de ordenar es lo que mide el ROC-AUC y lo que hace útil al modelo.
Con el umbral por defecto (0,5)
| Predice «se queda» | Predice «se va» | |
|---|---|---|
| Se quedó | 927 ✓ | 108 (falsa alarma) |
| Se fue | 163 (baja no detectada) | 211 ✓ |
Se escapan 163 de las 374 bajas del test. ¿Es el umbral correcto? Depende de lo que cueste cada error.
08Umbral de negocio
El 0,5 es arbitrario. El umbral correcto sale de comparar costes. Con estos supuestos (cámbialos por los de tu empresa en el cuaderno):
- Contactar a un cliente con una oferta cuesta 50 $.
- De los clientes que iban a irse, la oferta retiene al 30 %.
- Un cliente retenido conserva 12 meses de su cuota.
Llamar a quien no se iba a ir cuesta 50 $; no llamar a quien se va cuesta, de media, un 30 % de un año de cuota. Los dos errores no valen lo mismo, y la curva de beneficio lo refleja:
| Estrategia | Clientes contactados | Bajas alcanzadas | Beneficio |
|---|---|---|---|
| No hacer nada | 0 | 0 | 0 $ |
| Contactar a todos | 1.409 | 374 | 27.524 $ |
| Modelo, umbral 0,5 | 319 | 211 | 43.841 $ |
| Modelo, umbral 0,25 | 610 | 304 | 51.954 $ |
| Predice «se queda» | Predice «se va» | |
|---|---|---|
| Se quedó | 729 ✓ | 306 (falsa alarma) |
| Se fue | 70 (baja no detectada) | 304 ✓ |
Con el umbral 0,25 se detecta el 81,3 % de las bajas, a cambio de que la mitad de las llamadas sean a clientes que no se iban a ir (precisión 49,8 %). Con estos costes compensa: el beneficio es un 18 % mayor que con 0,5 y casi el doble que llamando a todos.
Ojo: el 30 % de éxito es un supuesto. Antes de lanzar la campaña a gran escala hay que medirlo con un experimento: ofrecer la promoción a un grupo al azar de clientes de riesgo y compararlo con otro grupo al que no se le ofrece (un test A/B, como en el nivel 3 de estadística).
09Interpretación
¿Qué pesa más en la predicción? La importancia por permutación desordena una variable cada vez y mide cuánto empeora el ROC-AUC en test:
Cuidado al leer los coeficientes. En el modelo completo, la cuota mensual tiene un coeficiente negativo (−1,32), como si pagar más retuviera. No es así: la cuota es casi la suma de los servicios contratados, que también están en el modelo, y la fibra sola ya suma +0,90. Cuando las variables se solapan (colinealidad), los coeficientes se reparten el efecto de formas poco intuitivas. La importancia por permutación y la exploración de la etapa 3 son más fiables para explicar el modelo.
Traducción para el equipo comercial: el riesgo se concentra en clientes nuevos, sin permanencia, con fibra, sin servicios de seguridad o soporte y que pagan con cheque electrónico. Acciones posibles: ofrecer permanencia con descuento en los primeros meses, incluir soporte técnico en la oferta y facilitar el paso a domiciliación.
10Calculadora de riesgo
Prueba un modelo simplificado con 7 variables clave (ROC-AUC en test 0,835, frente a 0,842 del completo). Se calcula en tu navegador con los coeficientes del modelo entrenado.
La marca de la barra es el umbral de 0,25 de la etapa 8. Sin internet, la seguridad y el soporte no se aplican y se ignoran.
11Despliegue
El modelo (el Pipeline completo, con el preprocesado incluido) se guarda con joblib y se sirve con una API de FastAPI. Pydantic valida cada petición: un tipo de contrato mal escrito devuelve un error 422 en vez de una predicción absurda.
@app.post("/prediccion", response_model=Prediccion)
def prediccion(cliente: Cliente):
p = float(MODELO.predict_proba(pd.DataFrame([cliente.model_dump()]))[0, 1])
return Prediccion(probabilidad_baja=round(p, 4), contactar=p >= UMBRAL,
umbral=UMBRAL, version_modelo=VERSION)POST /prediccion {"tenure": 3, "Contract": "Month-to-month", "InternetService": "Fiber optic", ...}
→ {"probabilidad_baja": 0.689, "contactar": true, "umbral": 0.25, "version_modelo": "churn-logistica-1.0"}
POST /prediccion {"tenure": 60, "Contract": "Two year", "TechSupport": "Yes", ...}
→ {"probabilidad_baja": 0.0419, "contactar": false, ...}En producción
- Uso por lotes: la campaña no necesita tiempo real. Un proceso nocturno puntúa a todos los clientes y deja la lista priorizada en el CRM.
- Monitorización: vigilar cada mes la distribución de las variables (deriva de datos) y comparar la tasa real de bajas por decil con la predicha.
- Reentrenamiento trimestral, o cuando cambien las tarifas: el modelo aprendió con los precios y las ofertas de un momento concreto.
- Retroalimentación: los clientes contactados cambian su comportamiento, así que hay que guardar quién recibió oferta para no contaminar el siguiente entrenamiento.
12Resultados y conclusiones
| Resultado | |
|---|---|
| Modelo elegido | Regresión logística (C = 3) con preprocesado en Pipeline |
| Calidad en test | ROC-AUC 0,842 · PR-AUC 0,631 |
| Ordenación | El 20 % de más riesgo concentra el 49,7 % de las bajas |
| Decisión | Contactar si la probabilidad ≥ 0,25: se alcanza el 81,3 % de las bajas |
| Impacto estimado | 51.954 $ en 1.409 clientes (unos 36.900 $ por cada 1.000), con los supuestos de la etapa 8 |
| Despliegue | API FastAPI validada con Pydantic; puntuación por lotes nocturna |
Lo que enseña este proyecto
- Un modelo sencillo bien evaluado vale más que uno complejo sin medir. Regresión logística, Random Forest y XGBoost empatan.
- El umbral es una decisión de negocio, no técnica. Cambiarlo de 0,5 a 0,25 aporta más que cualquier ajuste de hiperparámetros.
- Explicar con cuidado. Las variables solapadas hacen engañosos los coeficientes: hay que validarlos con la exploración y la importancia por permutación.
- Predecir no es actuar. El valor real de la campaña solo se sabe con un experimento controlado.
Código completo, ejecutable en Colab con los datos incluidos: