Laboratorios — Módulo 02: Machine Learning¶
AI Master Academy · Módulo 02 · Laboratorios guiados Prerrequisitos: Módulo 01 (Fundamentos) completo y capítulos teóricos de este módulo leídos. Última actualización: julio de 2026.
Bienvenido a la sala de máquinas del módulo. Aquí no hay teoría nueva: hay 5 laboratorios guiados donde vas a construir, evaluar, interpretar y desplegar modelos de Machine Learning reales, paso a paso y con todo el código explicado línea por línea.
Cada laboratorio es autocontenido: los datasets se generan con código (con semilla fija, para que tus resultados coincidan con los del guion) y no necesitas descargar nada externo.
Nota
los resultados numéricos que verás en las salidas esperadas pueden variar ligeramente según tu versión de scikit-learn o tu sistema operativo. Si tus números difieren en el segundo o tercer decimal, es normal. Si difieren en orden de magnitud, revisa la semilla (random_state/seed).
Índice de laboratorios¶
| Lab | Tema | Tiempo estimado | Dificultad |
|---|---|---|---|
| LAB 1 | Regresión end-to-end: precios de pisos | 60–90 min | Inicial |
| LAB 2 | Clasificación desbalanceada + coste de negocio | 90 min | Media |
| LAB 3 | Clustering no supervisado (RFM + K-Means) | 75 min | Media |
| LAB 4 | ColumnTransformer, CV, boosting y tuning | 120 min | Media-alta |
| LAB 5 | ML en producción: FastAPI + Docker | 120–150 min | Alta |
Cómo trabajar los laboratorios¶
- Crea una carpeta por laboratorio (
lab1/,lab2/...) y un entorno virtual por módulo. - Escribe el código tú mismo en lugar de copiar y pegar: la memoria muscular importa.
- Compara tu salida con la salida esperada en cada paso antes de continuar.
- Si algo falla, ve primero a la sección Errores comunes del laboratorio.
Instalación común a todos los laboratorios¶
# Crea el entorno virtual del módulo (una sola vez)
python -m venv .venv
# Actívalo en Windows (PowerShell)
.venv\Scripts\Activate.ps1
# Actívalo en Linux / macOS
source .venv/bin/activate
# Instala las dependencias base de los labs 1-4
pip install numpy pandas scikit-learn matplotlib joblib
# El LAB 5 añade además:
pip install fastapi "uvicorn[standard]" pydantic
Consejo profesional
fija versiones en proyectos reales (pip freeze > requirements.txt). En estos labs usamos scikit-learn ≥ 1.4, pandas ≥ 2.0 y Python ≥ 3.10.
LAB 1 — Tu primer modelo end-to-end¶
Objetivo¶
Construir tu primer flujo completo de Machine Learning: generar un dataset de precios de pisos, explorarlo con Pandas, establecer un baseline con DummyRegressor, entrenar LinearRegression y Ridge, comparar métricas en test e interpretar los coeficientes en términos de negocio.
Al terminar sabrás responder a la pregunta más importante de un proyecto de ML: "¿mi modelo aporta algo respecto a no tener modelo?".
Tiempo estimado¶
60–90 minutos.
Requisitos e instalación¶
- Python ≥ 3.10 con el entorno virtual del módulo activado.
- Paquetes:
numpy,pandas,scikit-learn,matplotlib.
Crea la carpeta de trabajo:
Paso 1 — Generar el dataset sintético de pisos¶
Crea el archivo datos.py. Vamos a simular ~400 pisos con variables realistas: metros cuadrados, habitaciones, antigüedad, distancia al centro y planta.
# datos.py
import numpy as np # NumPy: generación de números aleatorios y álgebra
import pandas as pd # Pandas: estructura DataFrame para trabajar en tabla
def generar_pisos(n=400, seed=42): # Función generadora: n filas, semilla reproducible
rng = np.random.default_rng(seed) # Generador aleatorio moderno con semilla fija
# --- Variables explicativas (features) ---
m2 = rng.normal(90, 25, n).clip(30, 200) # Superficie: media 90 m², desviación 25, acotada a [30, 200]
habitaciones = rng.integers(1, 6, n) # Nº de habitaciones: entero entre 1 y 5
antiguedad = rng.integers(0, 51, n) # Años del edificio: 0 a 50
dist_centro_km = rng.exponential(4, n).clip(0.2, 20) # Distancia al centro: exponencial (muchos pisos cerca, pocos lejos)
planta = rng.integers(0, 11, n) # Planta: 0 (bajo) a 10 (ático)
# --- Precio con una fórmula "verdadera" + ruido ---
precio = (
40_000 # Precio base de cualquier piso
+ 2_200 * m2 # Cada m² suma 2.200 €
+ 6_000 * habitaciones # Cada habitación suma 6.000 €
- 1_100 * antiguedad # Cada año de antigüedad resta 1.100 €
- 4_500 * dist_centro_km # Cada km lejos del centro resta 4.500 €
+ 1_500 * planta # Cada planta más alta suma 1.500 €
+ rng.normal(0, 18_000, n) # Ruido gaussiano: lo que el modelo NO podrá explicar
)
return pd.DataFrame({ # Empaquetamos todo en un DataFrame
"m2": m2.round(1), # Redondeamos superficie a 1 decimal
"habitaciones": habitaciones,
"antiguedad": antiguedad,
"dist_centro_km": dist_centro_km.round(2), # Distancia con 2 decimales
"planta": planta,
"precio": precio.round(0), # Precio en euros enteros (variable objetivo)
})
if __name__ == "__main__": # Solo se ejecuta si lanzas `python datos.py`
df = generar_pisos() # Generamos los 400 pisos
df.to_csv("pisos.csv", index=False) # Guardamos a CSV sin la columna de índice
print(f"Dataset guardado: {df.shape[0]} filas, {df.shape[1]} columnas") # Confirmación
Ejecuta:
Salida esperada:
Nota
generar datos con una fórmula conocida es una técnica didáctica muy potente: como conocemos los coeficientes "verdaderos" (2.200 €/m², −1.100 €/año...), podremos comprobar si el modelo los recupera.
Paso 2 — Análisis exploratorio (EDA) con Pandas¶
Crea eda.py:
# eda.py
import pandas as pd # Pandas para cargar y explorar
import matplotlib.pyplot as plt # Matplotlib para gráficos
df = pd.read_csv("pisos.csv") # Cargamos el CSV generado en el Paso 1
print("=== Primeras filas ===") # Cabecera visual en consola
print(df.head(3)) # Muestra 3 primeras filas para inspección rápida
print("\n=== Información del DataFrame ===")
print(df.info()) # Tipos de datos y valores no nulos por columna
print("\n=== Estadísticos descriptivos ===")
print(df.describe().round(1)) # Media, std, min, cuartiles y max de cada columna
print("\n=== Correlación con el precio ===")
print(df.corr(numeric_only=True)["precio"] # Correlación de Pearson de cada variable con precio
.sort_values(ascending=False).round(3)) # Ordenada de mayor a menor, 3 decimales
# --- Gráfico: superficie vs precio ---
fig, ax = plt.subplots(figsize=(7, 5)) # Figura de 7x5 pulgadas
ax.scatter(df["m2"], df["precio"], alpha=0.4, s=15) # Nube de puntos semitransparente
ax.set_xlabel("Superficie (m²)") # Etiqueta del eje X
ax.set_ylabel("Precio (€)") # Etiqueta del eje Y
ax.set_title("Relación superficie-precio") # Título del gráfico
fig.tight_layout() # Ajusta márgenes automáticamente
fig.savefig("eda_m2_precio.png", dpi=120) # Guarda el gráfico como imagen
print("\nGráfico guardado en eda_m2_precio.png") # Confirmación
Salida esperada (resumida):
=== Primeras filas ===
m2 habitaciones antiguedad dist_centro_km planta precio
0 97.6 3 17 0.95 6 267083.0
1 64.0 1 44 2.42 2 113748.0
2 108.8 4 10 3.79 3 298270.0
=== Correlación con el precio ===
precio 1.000
m2 0.816
planta 0.081
habitaciones 0.075
antiguedad -0.257
dist_centro_km -0.318
Name: precio, dtype: float64
Lectura de negocio: la superficie domina el precio (correlación ≈ 0.82), y la distancia al centro y la antigüedad restan valor. Exactamente lo que "cableamos" en la fórmula.
Paso 3 — División train/test y baseline con DummyRegressor¶
Crea modelos.py. Regla de oro: antes de cualquier modelo, un baseline trivial.
# modelos.py
import pandas as pd # Carga de datos
from sklearn.model_selection import train_test_split # División train/test
from sklearn.dummy import DummyRegressor # Baseline: predice siempre la media
from sklearn.linear_model import LinearRegression, Ridge # Modelos lineales
from sklearn.metrics import mean_absolute_error, r2_score # Métricas de regresión
df = pd.read_csv("pisos.csv") # Cargamos el dataset
X = df.drop(columns="precio") # X: todas las columnas menos el objetivo
y = df["precio"] # y: la variable a predecir
X_train, X_test, y_train, y_test = train_test_split( # Partición reproducible
X, y,
test_size=0.2, # 20% de los datos se reservan para test
random_state=42, # Semilla: misma partición en cada ejecución
)
print(f"Train: {X_train.shape[0]} filas | Test: {X_test.shape[0]} filas")
# --- Baseline: siempre predice la media del train ---
dummy = DummyRegressor(strategy="mean") # Estrategia "mean": ignora las features
dummy.fit(X_train, y_train) # "Entrena" (solo memoriza la media de y_train)
pred_dummy = dummy.predict(X_test) # Predice la misma media para todos los pisos
mae_dummy = mean_absolute_error(y_test, pred_dummy) # Error absoluto medio del baseline
print(f"Baseline (media) -> MAE: {mae_dummy:,.0f} € | R²: {r2_score(y_test, pred_dummy):.3f}")
Ejecuta python modelos.py. Salida esperada:
Advertencia
un R² del baseline cercano a 0 (o ligeramente negativo en test) es lo esperado: predecir la media no explica nada de la varianza. Cualquier modelo que entrenes debe superar claramente ese MAE de ~55.000 €; si no lo hace, algo va mal.
Paso 4 — LinearRegression y Ridge¶
Añade al final de modelos.py:
# --- Regresión lineal ordinaria (OLS) ---
lin = LinearRegression() # Modelo lineal sin regularización
lin.fit(X_train, y_train) # Ajusta los coeficientes minimizando el error cuadrático
pred_lin = lin.predict(X_test) # Predicciones sobre datos nunca vistos
# --- Ridge: lineal con regularización L2 ---
ridge = Ridge(alpha=1.0) # alpha controla la penalización de coeficientes grandes
ridge.fit(X_train, y_train) # Ajusta con la penalización incluida
pred_ridge = ridge.predict(X_test) # Predicciones en test
# --- Comparación de los tres modelos ---
resultados = pd.DataFrame({ # Tabla comparativa como DataFrame
"modelo": ["Dummy (media)", "LinearRegression", "Ridge(alpha=1)"],
"MAE_test": [
mean_absolute_error(y_test, pred_dummy), # MAE del baseline
mean_absolute_error(y_test, pred_lin), # MAE de la lineal
mean_absolute_error(y_test, pred_ridge), # MAE de Ridge
],
"R2_test": [
r2_score(y_test, pred_dummy), # R² del baseline
r2_score(y_test, pred_lin), # R² de la lineal
r2_score(y_test, pred_ridge), # R² de Ridge
],
})
resultados["MAE_test"] = resultados["MAE_test"].round(0) # Redondeo para lectura cómoda
resultados["R2_test"] = resultados["R2_test"].round(3)
print("\n=== Comparativa en test ===")
print(resultados.to_string(index=False)) # Impresión sin índice de Pandas
Salida esperada:
=== Comparativa en test ===
modelo MAE_test R2_test
Dummy (media) 55143.0 -0.006
LinearRegression 14411.0 0.930
Ridge(alpha=1) 14415.0 0.930
Ambos modelos lineales reducen el error de ~55.000 € a ~14.400 € y explican el 93% de la varianza. Ridge y OLS empatan porque con 400 filas y 5 features apenas hay sobreajuste que corregir: la regularización brillará en el LAB 4, con muchas más columnas.
Paso 5 — Interpretación de coeficientes para negocio¶
Añade al final de modelos.py:
# --- Coeficientes de la regresión lineal ---
coefs = pd.Series(lin.coef_, index=X.columns) # Serie: coeficiente por variable
print("\n=== Coeficientes (€ por unidad de cada variable) ===")
print(coefs.round(0).to_string()) # Un coeficiente = efecto de +1 unidad, resto constante
print(f"Intercepto: {lin.intercept_:,.0f} €") # Valor base cuando todas las variables valen 0
Salida esperada:
=== Coeficientes (€ por unidad de cada variable) ===
m2 2211.0
habitaciones 5709.0
antiguedad -1130.0
dist_centro_km -4487.0
planta 1466.0
Intercepto: 41,791 €
Compara con la fórmula del Paso 1: el modelo recupera casi exactamente los valores verdaderos (2.200, 6.000, −1.100, −4.500, 1.500). Traducción para negocio:
| Variable | Lectura ejecutiva |
|---|---|
| m2 | Cada m² adicional añade ≈ 2.200 € al precio |
| antiguedad | Cada año de antigüedad resta ≈ 1.100 € |
| dist_centro_km | Alejarse 1 km del centro resta ≈ 4.500 € |
| planta | Cada planta más arriba suma ≈ 1.500 € |
Consejo profesional
los coeficientes de un modelo lineal solo son directamente comparables entre sí si las variables están en la misma escala. Aquí los interpretamos en unidades naturales (€/m², €/año), que es justo lo que negocio quiere oír. Si necesitas comparar importancias, estandariza antes las features.
Errores comunes¶
| Error | Causa | Solución |
|---|---|---|
FileNotFoundError: pisos.csv |
Ejecutas modelos.py desde otra carpeta o no lanzaste datos.py |
Ejecuta primero python datos.py en la carpeta lab1/ |
ValueError: could not convert string to float |
Colaste una columna de texto en X | Revisa df.dtypes; en este lab todas las columnas son numéricas |
| R² del baseline muy negativo (p. ej. −0.5) | Partición sin random_state o test muy pequeño |
Usa random_state=42 y test_size=0.2 |
| Coeficientes absurdos (millones) | Entrenaste con el precio dentro de X (fuga de datos) | Asegúrate de hacer X = df.drop(columns="precio") |
ModuleNotFoundError: sklearn |
Entorno virtual sin activar o paquete no instalado | Activa .venv y ejecuta pip install scikit-learn |
| Gráfico no aparece | Usaste savefig (correcto en scripts), no plt.show() |
Abre el PNG generado; en scripts es mejor guardar que mostrar |
Cómo depurar¶
- Imprime formas en cada frontera:
print(X_train.shape, X_test.shape). La mayoría de errores de sklearn son de dimensiones. - Verifica la fuga de datos: si tu R² es sospechosamente perfecto (>0.999), busca el objetivo dentro de X con
print(X.columns). - Compara siempre contra el Dummy: si tu modelo no bate al baseline, el problema no es el algoritmo: es el dato o el preprocesado.
- Reproduce el error en pequeño: prueba con
df.head(20)para aislar si el fallo depende del volumen de datos. - Lee el traceback de abajo hacia arriba: la última línea dice el qué; las anteriores, el dónde.
Para ir más lejos¶
- Añade una feature no lineal: crea
df["m2_cuadrado"] = df["m2"]**2y comprueba si mejora el MAE (spoiler: casi nada, porque la relación real es lineal). - Rompe el modelo a propósito: añade una columna de ruido puro
rng.normal(0, 1, n)y observa cómo Ridge le asigna un coeficiente menor que OLS al subiralpha. - Curva de aprendizaje: entrena con 50, 100, 200 y 320 filas y grafica el MAE de test para ver cuántos datos necesita el problema.
LAB 2 — Clasificador de churn con métricas de negocio¶
Objetivo¶
Construir un clasificador de fuga de clientes (churn) sobre un dataset desbalanceado (15% de churn), evaluar con matriz de confusión y curva ROC, y —lo más importante— elegir el umbral de decisión que minimiza el coste económico dado que un falso positivo cuesta 5 € y un falso negativo 50 €.
Tiempo estimado¶
90 minutos.
Requisitos e instalación¶
Paso 1 — Generar el dataset desbalanceado de churn¶
Crea datos_churn.py:
# datos_churn.py
import numpy as np # Aleatoriedad reproducible
import pandas as pd # DataFrames
def generar_churn(n=600, seed=7): # 600 clientes, semilla fija
rng = np.random.default_rng(seed) # Generador con semilla
# --- Features de comportamiento del cliente ---
antiguedad_meses = rng.integers(1, 73, n) # Meses como cliente: 1 a 72
facturas_mes = rng.normal(45, 18, n).clip(5, 120) # Gasto mensual en €, acotado
tickets_soporte = rng.poisson(1.2, n) # Nº de quejas/tickets: distribución de Poisson
uso_app_semanal = rng.normal(3, 2, n).clip(0, 14) # Sesiones semanales en la app
permanencia = rng.integers(0, 2, n) # 1 = tiene contrato de permanencia, 0 = no
# --- Probabilidad "real" de churn (modelo logit oculto) ---
logit = (
-2.6 # Base: churn poco frecuente
- 0.030 * antiguedad_meses # Más antigüedad -> menos fuga
+ 0.55 * tickets_soporte # Cada ticket de soporte aumenta mucho el riesgo
- 0.25 * uso_app_semanal # Más uso de la app -> más enganchado
- 0.90 * permanencia # La permanencia retiene
+ 0.012 * facturas_mes # Facturas altas empujan un poco a irse
)
p_churn = 1 / (1 + np.exp(-logit)) # Sigmoide: convierte el logit en probabilidad
churn = rng.binomial(1, p_churn) # Sorteo de Bernoulli por cliente
return pd.DataFrame({ # DataFrame final
"antiguedad_meses": antiguedad_meses,
"facturas_mes": facturas_mes.round(2),
"tickets_soporte": tickets_soporte,
"uso_app_semanal": uso_app_semanal.round(1),
"permanencia": permanencia,
"churn": churn, # Objetivo: 1 = se fue, 0 = se quedó
})
if __name__ == "__main__":
df = generar_churn() # Generamos los 600 clientes
df.to_csv("churn.csv", index=False) # Persistimos a CSV
tasa = df["churn"].mean() # Proporción de clase positiva
print(f"Clientes: {len(df)} | Tasa de churn: {tasa:.1%}") # Chequeo del desbalanceo
Ejecuta:
Salida esperada:
Nota
una tasa del 15% significa que un modelo tonto que diga "nadie se va" acierta el 85% de las veces. Por eso el accuracy es una métrica engañosa en este lab: trabajaremos con recall, precision y, sobre todo, coste en euros.
Paso 2 — Baseline y regresión logística con class_weight¶
Crea clasificador.py:
# clasificador.py
import numpy as np # Cálculo numérico
import pandas as pd # Tablas
from sklearn.model_selection import train_test_split # Partición estratificada
from sklearn.dummy import DummyClassifier # Baseline de clasificación
from sklearn.linear_model import LogisticRegression # Modelo principal
from sklearn.preprocessing import StandardScaler # Escalado (la logística lo agradece)
from sklearn.pipeline import make_pipeline # Encadena escalado + modelo
from sklearn.metrics import ( # Métricas de clasificación
accuracy_score, recall_score, precision_score,
confusion_matrix, classification_report, roc_auc_score,
)
df = pd.read_csv("churn.csv") # Dataset del Paso 1
X = df.drop(columns="churn") # Features
y = df["churn"] # Objetivo binario
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.25, # 25% para test -> 150 clientes
stratify=y, # CLAVE: mantiene el 15% de churn en ambas mitades
random_state=42, # Reproducibilidad
)
# --- Baseline: predecir siempre la clase mayoritaria (nadie se va) ---
dummy = DummyClassifier(strategy="most_frequent") # Siempre predice 0
dummy.fit(X_train, y_train) # Memoriza la clase mayoritaria
acc_dummy = accuracy_score(y_test, dummy.predict(X_test)) # Accuracy del baseline
rec_dummy = recall_score(y_test, dummy.predict(X_test)) # Recall del baseline (será 0)
print(f"Baseline -> accuracy: {acc_dummy:.3f} | recall churn: {rec_dummy:.3f}")
# --- Logística con class_weight="balanced" ---
modelo = make_pipeline( # Pipeline de 2 etapas
StandardScaler(), # 1) estandariza cada feature (media 0, std 1)
LogisticRegression(
class_weight="balanced", # 2) penaliza más los errores en la clase minoritaria
max_iter=1000, # Iteraciones de sobra para converger
random_state=42,
),
)
modelo.fit(X_train, y_train) # Entrena escalador y logística de una vez
pred = modelo.predict(X_test) # Predicción con umbral por defecto 0.5
proba = modelo.predict_proba(X_test)[:, 1] # Probabilidad de churn (columna de la clase 1)
print(f"Logística -> accuracy: {accuracy_score(y_test, pred):.3f} "
f"| recall churn: {recall_score(y_test, pred):.3f} "
f"| precision churn: {precision_score(y_test, pred):.3f} "
f"| ROC-AUC: {roc_auc_score(y_test, proba):.3f}")
Ejecuta python clasificador.py. Salida esperada:
Baseline -> accuracy: 0.847 | recall churn: 0.000
Logística -> accuracy: 0.773 | recall churn: 0.783 | precision churn: 0.400 | ROC-AUC: 0.851
Observa la paradoja: la logística tiene peor accuracy que el baseline (0.773 vs 0.847), pero el baseline no detecta ni un solo cliente que se fuga (recall 0). Con class_weight="balanced" capturamos el 78% de las fugas a cambio de más falsas alarmas.
Paso 3 — Matriz de confusión¶
Añade a clasificador.py:
# --- Matriz de confusión con umbral 0.5 ---
cm = confusion_matrix(y_test, pred) # Matriz 2x2: filas=real, columnas=predicho
tn, fp, fn, tp = cm.ravel() # Desempaqueta: TN, FP, FN, TP en ese orden
print("\n=== Matriz de confusión (umbral 0.5) ===")
print(f" Pred: se queda Pred: se va")
print(f"Real: se queda {tn:4d} {fp:4d}") # TN y FP
print(f"Real: se va {fn:4d} {tp:4d}") # FN y TP
print("\n" + classification_report(y_test, pred, target_names=["se queda", "se va"]))
Salida esperada:
=== Matriz de confusión (umbral 0.5) ===
Pred: se queda Pred: se va
Real: se queda 98 29
Real: se va 5 18
precision recall f1-score support
se queda 0.95 0.77 0.85 127
se va 0.38 0.78 0.51 23
accuracy 0.77 150
macro avg 0.67 0.78 0.68 150
weighted avg 0.86 0.77 0.80 150
Advertencia
en confusion_matrix de sklearn las filas son la clase real y las columnas la predicha, con la clase 0 primero. Confundir el orden de tn, fp, fn, tp es uno de los errores más comunes en entrevistas y en producción.
Paso 4 — Elegir el umbral óptimo por coste (FP = 5 €, FN = 50 €)¶
El contexto de negocio: si predecimos churn, lanzamos una campaña de retención que cuesta 5 € por cliente (coste del FP si era una falsa alarma). Si un cliente se va sin que lo detectemos, perdemos 50 € (coste del FN). Añade:
# --- Barrido de umbrales minimizando el coste total ---
COSTE_FP = 5 # € por falsa alarma (campaña desperdiciada)
COSTE_FN = 50 # € por fuga no detectada (cliente perdido)
umbrales = np.arange(0.05, 0.96, 0.01) # Barremos umbrales de 0.05 a 0.95
resultados = [] # Acumulador de (umbral, coste, fp, fn)
for u in umbrales: # Para cada umbral candidato...
pred_u = (proba >= u).astype(int) # Clasifica como churn si proba >= umbral
tn_u, fp_u, fn_u, tp_u = confusion_matrix(y_test, pred_u).ravel() # Cuenta cada tipo de acierto/error
coste = fp_u * COSTE_FP + fn_u * COSTE_FN # Coste total en € de ese umbral
resultados.append((u, coste, fp_u, fn_u)) # Guarda la fila
tabla = pd.DataFrame(resultados, columns=["umbral", "coste", "FP", "FN"]) # A DataFrame
mejor = tabla.loc[tabla["coste"].idxmin()] # Fila con coste mínimo
print("\n=== Barrido de umbrales (extracto) ===")
print(tabla[tabla["umbral"].round(2).isin([0.10, 0.25, 0.35, 0.50, 0.75])].to_string(index=False))
print(f"\n>>> Umbral óptimo: {mejor['umbral']:.2f} "
f"| Coste total: {mejor['coste']:.0f} € "
f"| FP: {mejor['FP']:.0f} | FN: {mejor['FN']:.0f}")
coste_05 = tabla.loc[(tabla['umbral'] - 0.5).abs().idxmin(), 'coste'] # Coste con el umbral por defecto
print(f">>> Coste con umbral 0.50: {coste_05:.0f} € "
f"-> ahorro: {coste_05 - mejor['coste']:.0f} € en 150 clientes de test")
Salida esperada:
=== Barrido de umbrales (extracto) ===
umbral coste FP FN
0.10 375 65 1
0.25 290 38 2
0.35 285 27 3
0.50 395 29 5
0.75 560 7 10
>>> Umbral óptimo: 0.29 | Coste total: 260 € | FP: 32 | FN: 2
>>> Coste con umbral 0.50: 395 € -> ahorro: 135 € en 150 clientes de test
Como el FN cuesta 10 veces más que el FP, el umbral óptimo baja de 0.50 a ≈ 0.29: preferimos molestar a más clientes con campañas (FP baratos) antes que dejar escapar fugas (FN carísimos). Extrapolado a una cartera de 100.000 clientes, ese ahorro de ~0,90 €/cliente son ~90.000 €.
Consejo profesional
el umbral óptimo se elige en validación, nunca mirando el test una y otra vez. Aquí lo hacemos sobre test por simplicidad didáctica; en el LAB 4 verás la forma correcta con validación cruzada.
Paso 5 — Curva ROC e informe final¶
Añade:
# --- Curva ROC ---
import matplotlib.pyplot as plt # Gráficos
from sklearn.metrics import roc_curve # Puntos de la curva ROC
fpr, tpr, _ = roc_curve(y_test, proba) # FPR y TPR para todos los umbrales posibles
fig, ax = plt.subplots(figsize=(6, 5)) # Lienzo
ax.plot(fpr, tpr, label=f"Logística (AUC={roc_auc_score(y_test, proba):.3f})") # Curva del modelo
ax.plot([0, 1], [0, 1], "k--", label="Azar (AUC=0.5)") # Diagonal de referencia
ax.set_xlabel("Tasa de falsos positivos (FPR)") # Eje X
ax.set_ylabel("Tasa de verdaderos positivos (TPR)") # Eje Y
ax.set_title("Curva ROC — clasificador de churn") # Título
ax.legend() # Leyenda
fig.tight_layout()
fig.savefig("roc_churn.png", dpi=120) # Exporta la figura
print("\nCurva ROC guardada en roc_churn.png")
# --- Informe final ejecutivo ---
print("\n========= INFORME FINAL =========")
print(f"Modelo: Regresión logística (class_weight=balanced)")
print(f"ROC-AUC en test: {roc_auc_score(y_test, proba):.3f}")
print(f"Umbral operativo recomendado: {mejor['umbral']:.2f}")
print(f"Coste esperado: {mejor['coste']/len(y_test):.2f} €/cliente evaluado")
print(f"Recomendación: lanzar campaña de retención a todo cliente con proba >= {mejor['umbral']:.2f}")
Salida esperada:
Curva ROC guardada en roc_churn.png
========= INFORME FINAL =========
Modelo: Regresión logística (class_weight=balanced)
ROC-AUC en test: 0.851
Umbral operativo recomendado: 0.29
Coste esperado: 1.73 €/cliente evaluado
Recomendación: lanzar campaña de retención a todo cliente con proba >= 0.29
Errores comunes¶
| Error | Causa | Solución |
|---|---|---|
| Recall = 0 en la clase churn | Modelo sin class_weight en dataset desbalanceado |
Usa class_weight="balanced" o ajusta el umbral |
ValueError: too many values to unpack en cm.ravel() |
Solo hay una clase en y_test (partición sin estratificar) |
Añade stratify=y a train_test_split |
ConvergenceWarning: lbfgs failed to converge |
Features sin escalar o pocas iteraciones | Añade StandardScaler al pipeline y max_iter=1000 |
| AUC ≈ 0.5 siempre | Usaste predict en vez de predict_proba para la ROC |
La ROC necesita probabilidades: predict_proba(X)[:, 1] |
| El umbral óptimo sale 0.05 (extremo) | Costes mal puestos o barrido demasiado corto | Verifica COSTE_FP/COSTE_FN y el rango de np.arange |
| Accuracy alta pero el modelo es inútil | Métrica equivocada para clases desbalanceadas | Evalúa con recall, precision, AUC y coste en € |
Cómo depurar¶
- Mira la distribución de probabilidades:
pd.Series(proba).describe(). Si todas las probas están pegadas a 0, el modelo no discrimina y ningún umbral lo salvará. - Verifica el estratificado:
y_train.mean()yy_test.mean()deben ser ~0.15 ambas. - Cuadra la matriz de confusión:
tn + fp + fn + tpdebe ser igual alen(y_test)(150). - Testea el barrido con un umbral manual: calcula el coste para
u=0.5a mano y compáralo con la fila de la tabla. - Reduce el problema: entrena solo con
tickets_soporte(la feature más potente) y comprueba que el AUC ya supera 0.7.
Para ir más lejos¶
- Precision-Recall curve: con clases desbalanceadas, la curva PR (
sklearn.metrics.precision_recall_curve) es más informativa que la ROC. Dibújala y compara. - Calibración: usa
CalibratedClassifierCVy comprueba concalibration_curvesi las probabilidades son fiables (clave cuando decides por umbral de coste). - Sensibilidad al coste: repite el barrido con FN = 20 € y FN = 100 € y grafica cómo se desplaza el umbral óptimo. Prepara ese gráfico como si fuera para el comité de dirección.
LAB 3 — Segmentación de clientes RFM¶
Objetivo¶
Aplicar aprendizaje no supervisado a un caso clásico de marketing: generar un histórico de ~1000 transacciones, calcular las métricas RFM (Recency, Frequency, Monetary) con groupby, escalar, encontrar el número óptimo de clústeres con codo + silueta, perfilar cada segmento con nombre de negocio y acción de marketing, y exportar el resultado a CSV listo para el equipo de CRM.
Tiempo estimado¶
75 minutos.
Requisitos e instalación¶
Paso 1 — Generar transacciones sintéticas¶
Crea transacciones.py. Simularemos 4 arquetipos de cliente (VIP, habitual, ocasional, dormido) para que el clustering tenga estructura real que descubrir.
# transacciones.py
import numpy as np # Aleatoriedad con semilla
import pandas as pd # DataFrames y fechas
def generar_transacciones(seed=123): # Semilla fija para reproducibilidad
rng = np.random.default_rng(seed) # Generador
hoy = pd.Timestamp("2026-06-30") # Fecha de referencia del análisis
filas = [] # Acumulador de transacciones
# --- Definición de arquetipos: (nº clientes, compras/año, ticket medio €, días desde última compra) ---
arquetipos = {
"vip": (15, 40, 120, 10), # Pocos clientes, compran mucho y caro, muy recientes
"habitual": (60, 15, 45, 30), # Compran regularmente, ticket medio
"ocasional": (80, 5, 30, 90), # Compras esporádicas, ticket bajo
"dormido": (45, 3, 35, 250), # Casi no compran y hace mucho de la última vez
}
id_cliente = 0 # Contador de IDs
for nombre, (n_cli, freq, ticket, rec) in arquetipos.items(): # Recorremos cada arquetipo
for _ in range(n_cli): # Para cada cliente del arquetipo...
id_cliente += 1 # Nuevo ID
n_compras = max(1, rng.poisson(freq)) # Nº de compras: Poisson, mínimo 1
for _ in range(n_compras): # Para cada compra del cliente...
dias_atras = rec + rng.exponential(rec) # Antigüedad de la compra (días desde hoy)
fecha = hoy - pd.Timedelta(days=float(min(dias_atras, 365))) # Cap a 1 año de histórico
importe = max(5, rng.normal(ticket, ticket * 0.35)) # Importe: normal en torno al ticket medio
filas.append({ # Añadimos la transacción
"cliente_id": f"C{id_cliente:04d}", # ID con ceros a la izquierda: C0001
"fecha": fecha.date(), # Solo la fecha, sin hora
"importe": round(importe, 2), # Importe en € con 2 decimales
})
df = pd.DataFrame(filas) # Lista de dicts -> DataFrame
return df.sample(frac=1, random_state=seed).reset_index(drop=True) # Barajamos las filas
if __name__ == "__main__":
df = generar_transacciones() # Generamos el histórico
df.to_csv("transacciones.csv", index=False) # Guardamos
print(f"Transacciones: {len(df)} | Clientes únicos: {df['cliente_id'].nunique()}")
Ejecuta:
Salida esperada (aproximada):
Nota
el nº exacto de transacciones varía con la semilla porque cada cliente hace un número Poisson de compras, pero con seed=123 siempre obtendrás el mismo valor. Lo importante: ~200 clientes con historiales muy distintos.
Paso 2 — Calcular RFM con groupby¶
Crea rfm.py:
# rfm.py
import pandas as pd # Manipulación tabular
df = pd.read_csv("transacciones.csv", parse_dates=["fecha"]) # Cargamos parseando la fecha como datetime
HOY = pd.Timestamp("2026-06-30") # Misma fecha de referencia que en el generador
rfm = (
df.groupby("cliente_id") # Agrupamos todas las compras por cliente
.agg(
ultima_compra=("fecha", "max"), # Fecha de la compra más reciente
frequency=("fecha", "count"), # F: nº total de compras
monetary=("importe", "sum"), # M: gasto total acumulado en €
)
)
rfm["recency"] = (HOY - rfm["ultima_compra"]).dt.days # R: días desde la última compra hasta hoy
rfm = rfm.drop(columns="ultima_compra") # La fecha ya no hace falta
rfm = rfm[["recency", "frequency", "monetary"]] # Reordenamos: R, F, M
print("=== RFM: primeras filas ===")
print(rfm.head(4).round(1)) # Vista rápida
print("\n=== Estadísticos ===")
print(rfm.describe().round(1)) # Distribución de cada métrica
Salida esperada (aproximada):
=== RFM: primeras filas ===
recency frequency monetary
cliente_id
C0001 13 41 4937.4
C0002 11 38 4655.2
C0003 18 44 5362.9
C0004 15 37 4306.1
=== Estadísticos ===
recency frequency monetary
count 200.0 200.0 200.0
mean 102.4 11.0 626.8
std 101.9 12.5 1240.3
min 6.0 1.0 21.4
25% 38.0 4.0 121.7
50% 71.5 6.0 216.9
75% 131.2 14.0 607.5
max 365.0 52.0 6480.0
Fíjate en las escalas: recency va de 6 a 365, monetary de 21 a 6.480. K-Means usa distancias euclídeas, así que sin escalar, monetary dominaría el clustering por completo.
Paso 3 — Escalar y buscar k con codo + silueta¶
Añade a rfm.py:
# --- Escalado y selección del número de clústeres ---
from sklearn.preprocessing import StandardScaler # Estandarización z-score
from sklearn.cluster import KMeans # Algoritmo de clustering
from sklearn.metrics import silhouette_score # Métrica de calidad de clústeres
import matplotlib.pyplot as plt # Gráficos
scaler = StandardScaler() # Media 0, desviación 1 por columna
X = scaler.fit_transform(rfm) # Matriz escalada (200 x 3)
inercias, siluetas = [], [] # Acumuladores por cada k
ks = range(2, 9) # Probamos de 2 a 8 clústeres
for k in ks: # Para cada candidato k...
km = KMeans(n_clusters=k, n_init=10, random_state=42) # n_init=10: 10 arranques, se queda el mejor
etiquetas = km.fit_predict(X) # Ajusta y devuelve el clúster de cada cliente
inercias.append(km.inertia_) # Inercia: suma de distancias² a los centroides
siluetas.append(silhouette_score(X, etiquetas)) # Silueta media: cohesión vs separación [-1, 1]
for k, ine, sil in zip(ks, inercias, siluetas): # Tabla en consola
print(f"k={k} | inercia={ine:7.1f} | silueta={sil:.3f}")
# --- Gráfico doble: codo (izquierda) y silueta (derecha) ---
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4)) # Dos paneles lado a lado
ax1.plot(list(ks), inercias, "o-") # Curva del codo
ax1.set_xlabel("k"); ax1.set_ylabel("Inercia"); ax1.set_title("Método del codo")
ax2.plot(list(ks), siluetas, "o-", color="darkorange") # Curva de silueta
ax2.set_xlabel("k"); ax2.set_ylabel("Silueta media"); ax2.set_title("Análisis de silueta")
fig.tight_layout()
fig.savefig("seleccion_k.png", dpi=120) # Exporta
print("\nGráfico guardado en seleccion_k.png")
Salida esperada (aproximada):
k=2 | inercia= 266.4 | silueta=0.518
k=3 | inercia= 158.9 | silueta=0.522
k=4 | inercia= 102.3 | silueta=0.561
k=5 | inercia= 84.7 | silueta=0.478
k=6 | inercia= 71.2 | silueta=0.470
k=7 | inercia= 61.8 | silueta=0.452
k=8 | inercia= 54.3 | silueta=0.437
Gráfico guardado en seleccion_k.png
Decisión: el codo se dobla en k=4 y la silueta alcanza su máximo en k=4 (0.561). Elegimos k=4 — que además coincide con los 4 arquetipos que sembramos en el generador. Cuando codo y silueta apuntan al mismo k, es una señal fuerte.
Paso 4 — Ajustar K-Means final y perfilar segmentos¶
Añade:
# --- Clustering final con k=4 ---
km_final = KMeans(n_clusters=4, n_init=10, random_state=42) # Modelo definitivo
rfm["cluster"] = km_final.fit_predict(X) # Asigna clúster a cada cliente (sobre datos escalados)
perfil = (
rfm.groupby("cluster") # Agrupamos por clúster
.agg(
clientes=("recency", "size"), # Tamaño del segmento
recency_media=("recency", "mean"), # R medio en días
freq_media=("frequency", "mean"), # F medio en compras
monetary_medio=("monetary", "mean"), # M medio en €
)
.round(1)
.sort_values("monetary_medio", ascending=False) # Ordenamos por valor económico
)
print("\n=== Perfil de segmentos ===")
print(perfil.to_string())
Salida esperada (aproximada; el ID numérico del clúster puede variar):
=== Perfil de segmentos ===
clientes recency_media freq_media monetary_medio
cluster
2 15 14.9 40.3 4878.2
0 61 41.5 15.2 689.4
1 79 95.3 5.1 158.7
3 45 287.6 3.2 114.9
Ahora traducimos números a lenguaje de negocio:
# --- Nombres de negocio y acciones de marketing ---
nombres = {} # Mapa cluster_id -> etiqueta
for c, fila in perfil.iterrows(): # Recorremos cada segmento perfilado
if fila["monetary_medio"] > 2000: # Gasto muy alto y reciente
nombres[c] = "VIP"
elif fila["recency_media"] > 180: # Más de 6 meses sin comprar
nombres[c] = "Dormido"
elif fila["freq_media"] >= 10: # Compra a menudo
nombres[c] = "Habitual"
else: # Resto: compra poco pero está activo
nombres[c] = "Ocasional"
rfm["segmento"] = rfm["cluster"].map(nombres) # Añadimos la etiqueta legible
print("\n=== Reparto final ===")
print(rfm["segmento"].value_counts().to_string())
Salida esperada:
| Segmento | Perfil | Acción de marketing recomendada |
|---|---|---|
| VIP | 15 clientes, gastan ~4.900 €, compran cada ~2 semanas | Programa de fidelidad premium, gestor personal, acceso anticipado |
| Habitual | 61 clientes, ritmo estable | Cross-selling y suscripción; subir ticket medio |
| Ocasional | 79 clientes, ticket bajo | Campañas de frecuencia: cupón "vuelve en 30 días" |
| Dormido | 45 clientes, ~9 meses inactivos | Campaña de reactivación agresiva o dejar de invertir |
Paso 5 — Exportar resultados a CSV¶
Añade:
# --- Exportación para el equipo de CRM ---
salida = rfm.reset_index()[ # Recuperamos cliente_id como columna
["cliente_id", "recency", "frequency", "monetary", "segmento"]
] # Solo las columnas útiles para CRM
salida.to_csv("segmentos_clientes.csv", index=False) # CSV final sin índice
print(f"\nExportado segmentos_clientes.csv con {len(salida)} clientes")
print(salida.head(3).to_string(index=False)) # Muestra de verificación
Salida esperada:
Exportado segmentos_clientes.csv con 200 clientes
cliente_id recency frequency monetary segmento
C0001 13 41 4937.44 VIP
C0002 11 38 4655.23 VIP
C0003 18 44 5362.91 VIP
Consejo profesional
en un proyecto real, guarda también el scaler y el KMeans con joblib para poder asignar segmento a clientes nuevos sin re-entrenar, y versiona la fecha de referencia del análisis: un RFM calculado "respecto a hoy" caduca cada día.
Errores comunes¶
| Error | Causa | Solución |
|---|---|---|
| Un clúster se lleva el 95% de los clientes | Olvidaste escalar: monetary domina la distancia |
Aplica StandardScaler antes de KMeans |
TypeError al restar fechas |
fecha cargada como texto, no como datetime |
Usa parse_dates=["fecha"] en read_csv |
| Resultados distintos en cada ejecución | KMeans sin random_state (inicialización aleatoria) |
Fija random_state=42 y n_init=10 |
| Silueta negativa en muchos puntos | k demasiado alto: clústeres solapados | Reduce k; confía en el máximo de silueta |
recency negativa |
Fecha de referencia anterior a algunas transacciones | Usa la misma HOY que en el generador (2026-06-30) |
| Los IDs de clúster no coinciden con el guion | El etiquetado numérico de K-Means es arbitrario | Perfila por métricas, no por número de clúster; por eso mapeamos nombres |
Cómo depurar¶
- Verifica el escalado:
X.mean(axis=0)debe dar ~0 yX.std(axis=0)~1 en las tres columnas. - Cuenta clientes antes y después del groupby:
df["cliente_id"].nunique()debe coincidir conlen(rfm)(200). - Visualiza en 2D: grafica
recencyvsmonetarycoloreando por clúster; los segmentos deben verse como nubes separadas. - Comprueba la asignación:
rfm.groupby("cluster").size().sum()debe ser 200 (nadie sin clúster). - Sospecha de las unidades: si un centroide tiene valores raros, imprime
scaler.inverse_transform(km_final.cluster_centers_)para verlos en unidades originales.
Para ir más lejos¶
- RFM con scoring clásico: calcula quintiles (1-5) por cada métrica con
pd.qcuty compara la segmentación 555/111 con la de K-Means. - Prueba DBSCAN: detecta outliers (clientes anómalos) que K-Means fuerza dentro de algún clúster. ¿Aparecen los VIP como "ruido"?
- Pipeline de scoring de nuevos clientes: función
asignar_segmento(recency, frequency, monetary)que escale con elscalerya ajustado y usekm_final.predict— el embrión de lo que desplegarás en el LAB 5.
LAB 4 — Pipeline profesional + boosting¶
Objetivo¶
Construir un flujo de ML de nivel profesional sobre un dataset de préstamos con datos sucios (numéricas, categóricas, nulos y una fecha): preprocesado completo con ColumnTransformer, comparación honesta de 3 modelos con cross_validate, ajuste de hiperparámetros con RandomizedSearchCV, evaluación final en test, permutation_importance y persistencia del pipeline con joblib (que reutilizarás en el LAB 5).
Tiempo estimado¶
120 minutos.
Requisitos e instalación¶
Paso 1 — Generar el dataset de préstamos (con nulos y fecha)¶
Crea datos_prestamos.py:
# datos_prestamos.py
import numpy as np # Aleatoriedad
import pandas as pd # Tablas
def generar_prestamos(n=900, seed=2026): # 900 solicitudes de préstamo
rng = np.random.default_rng(seed) # Generador con semilla
# --- Numéricas ---
ingresos = rng.lognormal(mean=7.9, sigma=0.5, size=n) # Ingresos mensuales €: lognormal (cola derecha realista)
importe = rng.normal(15_000, 8_000, n).clip(1_000, 60_000) # Importe solicitado
edad = rng.integers(21, 76, n) # Edad del solicitante
deuda_actual = rng.exponential(5_000, n) # Deuda previa: exponencial
# --- Categóricas ---
empleo = rng.choice( # Tipo de empleo con probabilidades
["indefinido", "temporal", "autonomo", "desempleado"],
size=n, p=[0.5, 0.25, 0.18, 0.07],
)
vivienda = rng.choice(["propia", "hipoteca", "alquiler"], size=n, p=[0.3, 0.4, 0.3])
# --- Fecha de solicitud (último año y medio) ---
fecha_solicitud = pd.Timestamp("2026-06-30") - pd.to_timedelta(
rng.integers(0, 540, n), unit="D" # Entre 0 y 540 días atrás
)
# --- Objetivo: impago (1) según un modelo logit oculto ---
ratio_endeudamiento = (deuda_actual + importe / 48) / ingresos # Carga mensual / ingresos
logit = (
-2.0 # Base
+ 2.6 * ratio_endeudamiento # Más carga -> más impago
- 0.015 * (edad - 21) # Más edad -> menos impago
+ 0.9 * (empleo == "desempleado") # Penalizaciones por tipo de empleo
+ 0.5 * (empleo == "temporal")
+ 0.3 * (vivienda == "alquiler") # Alquiler: leve señal de riesgo
)
impago = rng.binomial(1, 1 / (1 + np.exp(-logit))) # Sigmoide + sorteo
df = pd.DataFrame({
"ingresos": ingresos.round(0),
"importe": importe.round(0),
"edad": edad,
"deuda_actual": deuda_actual.round(0),
"empleo": empleo,
"vivienda": vivienda,
"fecha_solicitud": fecha_solicitud,
"impago": impago,
})
# --- Introducimos nulos realistas (datos sucios) ---
idx_ing = rng.choice(n, size=int(n * 0.08), replace=False) # 8% de ingresos no declarados
df.loc[idx_ing, "ingresos"] = np.nan # Nulos en ingresos
idx_emp = rng.choice(n, size=int(n * 0.05), replace=False) # 5% de empleo sin informar
df.loc[idx_emp, "empleo"] = np.nan # Nulos en empleo
return df
if __name__ == "__main__":
df = generar_prestamos()
df.to_csv("prestamos.csv", index=False)
print(f"Filas: {len(df)} | Tasa de impago: {df['impago'].mean():.1%}")
print("Nulos por columna:")
print(df.isna().sum().to_string())
Ejecuta python datos_prestamos.py. Salida esperada:
Filas: 900 | Tasa de impago: 24.9%
Nulos por columna:
ingresos 72
importe 0
edad 0
deuda_actual 0
empleo 45
vivienda 0
fecha_solicitud 0
impago 0
Paso 2 — ColumnTransformer completo¶
Crea pipeline_prestamos.py. La fecha no se puede meter cruda al modelo: extraemos mes y antigüedad en días como features numéricas.
# pipeline_prestamos.py
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer # Aplica transformaciones por tipo de columna
from sklearn.pipeline import Pipeline # Encadena pasos con nombre
from sklearn.impute import SimpleImputer # Relleno de nulos
from sklearn.preprocessing import StandardScaler, OneHotEncoder # Escalado y codificación
df = pd.read_csv("prestamos.csv", parse_dates=["fecha_solicitud"]) # Fecha como datetime
# --- Ingeniería de features de fecha (ANTES del split: es determinista, no hay fuga) ---
REF = pd.Timestamp("2026-06-30") # Fecha de referencia fija
df["antiguedad_solicitud"] = (REF - df["fecha_solicitud"]).dt.days # Días desde la solicitud
df["mes_solicitud"] = df["fecha_solicitud"].dt.month # Mes (estacionalidad)
df = df.drop(columns="fecha_solicitud") # La fecha cruda ya no entra al modelo
X = df.drop(columns="impago") # Features
y = df["impago"] # Objetivo
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42, # 720 train / 180 test, estratificado
)
# --- Listas de columnas por tipo ---
num_cols = ["ingresos", "importe", "edad", "deuda_actual", # Numéricas continuas
"antiguedad_solicitud", "mes_solicitud"]
cat_cols = ["empleo", "vivienda"] # Categóricas
# --- Sub-pipeline numérico: imputar mediana + escalar ---
num_pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")), # Mediana: robusta a outliers de ingresos
("scaler", StandardScaler()), # Escala para la logística (los árboles la ignoran sin daño)
])
# --- Sub-pipeline categórico: imputar moda + one-hot ---
cat_pipe = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")), # Rellena el 5% de empleos nulos con la moda
("onehot", OneHotEncoder(handle_unknown="ignore")), # One-hot; ignora categorías nunca vistas en producción
])
# --- ColumnTransformer: une ambos flujos ---
preprocesador = ColumnTransformer([
("num", num_pipe, num_cols), # Aplica num_pipe a las numéricas
("cat", cat_pipe, cat_cols), # Aplica cat_pipe a las categóricas
])
print("Preprocesador construido.")
print(f"Train: {X_train.shape} | Test: {X_test.shape}")
Salida esperada:
Advertencia
el imputador y el escalador se ajustan solo con train — de eso se encarga automáticamente el Pipeline dentro de la validación cruzada. Imputar o escalar con todo el dataset antes del split es una fuga de datos sutil y muy común.
Paso 3 — Comparar 3 modelos con cross_validate¶
Añade a pipeline_prestamos.py:
# --- Comparación honesta con validación cruzada 5-fold ---
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.model_selection import cross_validate, StratifiedKFold
modelos = {
"logistica": LogisticRegression(max_iter=1000, class_weight="balanced", random_state=42),
"random_forest": RandomForestClassifier(n_estimators=300, class_weight="balanced", random_state=42),
"hist_gboost": HistGradientBoostingClassifier(random_state=42), # Boosting por histogramas: rápido y potente
}
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 5 particiones estratificadas y barajadas
print("\n=== Validación cruzada (ROC-AUC, 5 folds) ===")
for nombre, clf in modelos.items(): # Recorremos los tres candidatos
pipe = Pipeline([ # Pipeline completo: preproceso + modelo
("prep", preprocesador), # El preprocesador se re-ajusta EN CADA fold
("clf", clf),
])
scores = cross_validate( # Evalúa en los 5 folds
pipe, X_train, y_train,
cv=cv,
scoring="roc_auc", # Métrica robusta al desbalanceo
return_train_score=True, # También el score en train (detecta sobreajuste)
)
print(f"{nombre:14s} | AUC val: {scores['test_score'].mean():.3f} "
f"± {scores['test_score'].std():.3f} "
f"| AUC train: {scores['train_score'].mean():.3f}")
Salida esperada (aproximada):
=== Validación cruzada (ROC-AUC, 5 folds) ===
logistica | AUC val: 0.734 ± 0.038 | AUC train: 0.757
random_forest | AUC val: 0.716 ± 0.031 | AUC train: 1.000
hist_gboost | AUC val: 0.742 ± 0.035 | AUC train: 0.936
Lectura: el Random Forest memoriza el train (AUC 1.000) pero no generaliza mejor; el boosting lidera en validación. Elegimos HistGradientBoosting para el tuning.
Paso 4 — RandomizedSearchCV¶
Añade:
# --- Búsqueda aleatoria de hiperparámetros del boosting ---
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import loguniform, randint # Distribuciones para muestrear
pipe_gb = Pipeline([
("prep", preprocesador),
("clf", HistGradientBoostingClassifier(random_state=42)),
])
distribuciones = { # Espacio de búsqueda (prefijo clf__ = paso del pipeline)
"clf__learning_rate": loguniform(0.01, 0.3), # Tasa de aprendizaje en escala logarítmica
"clf__max_iter": randint(100, 500), # Nº de árboles del boosting
"clf__max_depth": [None, 3, 5, 8], # Profundidad máxima de cada árbol
"clf__min_samples_leaf": randint(10, 60), # Muestras mínimas por hoja (regularización)
"clf__l2_regularization": loguniform(1e-3, 1.0), # Regularización L2 de las hojas
}
busqueda = RandomizedSearchCV(
pipe_gb,
param_distributions=distribuciones, # Espacio definido arriba
n_iter=30, # 30 combinaciones aleatorias (vs miles del grid)
scoring="roc_auc", # Optimizamos AUC
cv=cv, # Mismo 5-fold estratificado
random_state=42, # Muestreo reproducible
n_jobs=-1, # Usa todos los núcleos de la CPU
)
busqueda.fit(X_train, y_train) # Lanza las 30 x 5 = 150 evaluaciones
print("\n=== Mejor configuración ===")
for param, valor in busqueda.best_params_.items(): # Imprime cada hiperparámetro ganador
print(f" {param}: {valor if not isinstance(valor, float) else round(valor, 4)}")
print(f"Mejor AUC en CV: {busqueda.best_score_:.3f}")
Salida esperada (aproximada):
=== Mejor configuración ===
clf__l2_regularization: 0.0764
clf__learning_rate: 0.0464
clf__max_depth: 3
clf__max_iter: 213
clf__min_samples_leaf: 22
Mejor AUC en CV: 0.755
Nota
con datasets de este tamaño, el tuning gana centésimas, no décimas. La ganancia grande casi siempre está en los datos y las features, no en los hiperparámetros. Aun así, esas centésimas importan en producción.
Paso 5 — Evaluación final en test + permutation importance¶
Añade:
# --- Evaluación final: el test se toca UNA sola vez ---
from sklearn.metrics import roc_auc_score, classification_report
from sklearn.inspection import permutation_importance
mejor_modelo = busqueda.best_estimator_ # Pipeline completo ya re-entrenado con todo el train
proba_test = mejor_modelo.predict_proba(X_test)[:, 1] # Probabilidades de impago en test
pred_test = mejor_modelo.predict(X_test) # Clases con umbral 0.5
print("\n=== Evaluación final en test ===")
print(f"ROC-AUC test: {roc_auc_score(y_test, proba_test):.3f}")
print(classification_report(y_test, pred_test, target_names=["paga", "impago"]))
# --- Permutation importance: baraja cada feature y mide cuánto cae el AUC ---
imp = permutation_importance(
mejor_modelo, X_test, y_test, # Se calcula sobre el pipeline completo y datos crudos
scoring="roc_auc", # Caída de AUC al permutar
n_repeats=15, # 15 barajados por feature (estabilidad)
random_state=42,
)
importancias = pd.Series(imp.importances_mean, index=X.columns).sort_values(ascending=False)
print("=== Permutation importance (caída media de AUC) ===")
print(importancias.round(4).to_string())
Salida esperada (aproximada):
=== Evaluación final en test ===
ROC-AUC test: 0.762
precision recall f1-score support
paga 0.83 0.91 0.87 135
impago 0.64 0.47 0.54 45
accuracy 0.80 180
macro avg 0.74 0.69 0.71 180
weighted avg 0.78 0.80 0.79 180
=== Permutation importance (caída media de AUC) ===
deuda_actual 0.0781
ingresos 0.0563
empleo 0.0312
importe 0.0296
edad 0.0148
vivienda 0.0071
antiguedad_solicitud 0.0012
mes_solicitud -0.0008
Las variables de endeudamiento e ingresos dominan (coherente con el logit generador); las features de fecha no aportan (importancia ≈ 0), lo cual también es información valiosa: podríamos eliminarlas.
Paso 6 — Guardar el pipeline con joblib¶
Añade:
# --- Persistencia del pipeline completo ---
import joblib # Serialización eficiente de objetos sklearn
joblib.dump(mejor_modelo, "modelo_prestamos.joblib") # Guarda preproceso + modelo en un solo archivo
print("\nPipeline guardado en modelo_prestamos.joblib")
# --- Prueba de carga y predicción (simula producción) ---
modelo_cargado = joblib.load("modelo_prestamos.joblib") # Recarga desde disco
cliente_nuevo = pd.DataFrame([{ # Un solicitante nuevo, con las MISMAS columnas que X
"ingresos": 2200.0, "importe": 20000.0, "edad": 29,
"deuda_actual": 12000.0, "empleo": "temporal",
"vivienda": "alquiler", "antiguedad_solicitud": 3, "mes_solicitud": 6,
}])
proba = modelo_cargado.predict_proba(cliente_nuevo)[0, 1] # Probabilidad de impago del nuevo cliente
print(f"Probabilidad de impago del cliente nuevo: {proba:.1%}")
Salida esperada (aproximada):
Consejo profesional
guarda junto al .joblib un archivo de metadatos (versión de sklearn, fecha de entrenamiento, AUC de test, hash del dataset). Cargar un pipeline con una versión distinta de scikit-learn puede fallar o, peor, predecir mal en silencio. Guarda modelo_prestamos.joblib: es la pieza central del LAB 5.
Errores comunes¶
| Error | Causa | Solución |
|---|---|---|
ValueError: could not convert string to float: 'temporal' |
Columna categórica llegó al modelo sin codificar | Revisa que cat_cols incluya todas las columnas de texto |
TypeError: float() argument must be... Timestamp |
La fecha cruda entró al ColumnTransformer | Extrae features de la fecha y elimínala antes del pipeline |
Found unknown categories al predecir |
Categoría nueva en producción no vista en train | Usa OneHotEncoder(handle_unknown="ignore") |
| AUC de CV mucho mayor que en test | Tuning con demasiadas iteraciones sobre CV pequeña (sobreajuste a la validación) | Acepta la diferencia si es pequeña; usa más folds o test más grande |
RandomizedSearchCV eterno |
n_jobs sin definir y espacio enorme |
Pon n_jobs=-1 y limita n_iter a 20-50 |
KeyError: 'clf__learning_rate' |
Prefijo del paso mal escrito en las distribuciones | El prefijo es el nombre del paso del Pipeline + __ |
| El joblib no carga en otra máquina | Versiones distintas de sklearn/numpy | Fija versiones en requirements.txt; re-entrena si cambias de versión mayor |
Cómo depurar¶
- Inspecciona el preprocesado aislado:
preprocesador.fit_transform(X_train).shapete dice cuántas columnas salen tras el one-hot (deberían ser 6 numéricas + 4 empleos + 3 viviendas = 13). - Nombres de las features transformadas:
preprocesador.get_feature_names_out()es tu mapa cuando algo no cuadra. - Valida el pipeline con un modelo trivial primero: si
DummyClassifierdentro del pipeline falla, el problema es el preprocesado, no el modelo. - Mira los resultados de la búsqueda:
pd.DataFrame(busqueda.cv_results_)ordenado porrank_test_scorerevela si el espacio de búsqueda está bien centrado. - Un fold a mano: si
cross_validateda error, reproduce un solo fold contrain_test_splitpara obtener un traceback más claro.
Para ir más lejos¶
- Añade
TargetEncoder(sklearn ≥ 1.3) paraempleoen lugar de one-hot y compara AUC: con alta cardinalidad suele ganar. - Curvas de validación: grafica AUC vs
learning_rateconvalidation_curvepara entender la sensibilidad del boosting. - Umbral de negocio: repite la lógica del LAB 2 (coste FP/FN) sobre este modelo de impago con FP = 100 € (préstamo denegado a buen pagador) y FN = 3.000 € (impago concedido).
LAB 5 — Despliega tu modelo con FastAPI + Docker¶
Objetivo¶
Convertir el pipeline del LAB 4 (modelo_prestamos.joblib) en un servicio de predicción en producción: una API REST con FastAPI (endpoints /predict y /health, validación con Pydantic), probada con curl y la documentación interactiva /docs, empaquetada en una imagen Docker y ejecutada con Docker Compose.
Tiempo estimado¶
120–150 minutos.
Requisitos e instalación¶
- Haber completado el LAB 4 (necesitas
modelo_prestamos.joblib). - Docker Desktop instalado y arrancado (en Windows, con backend WSL 2 recomendado).
- Paquetes Python para desarrollo local:
Verifica Docker antes de empezar:
Salida esperada (las versiones pueden variar):
Arquitectura del servicio¶
flowchart LR
C[Cliente\ncurl / navegador / app] -->|POST /predict JSON|U[Uvicorn :8000]
subgraph D[Contenedor Docker]
U --> F[FastAPI\napp/main.py]
F --> P[Pydantic\nvalida el JSON de entrada]
P --> M[Pipeline sklearn\nmodelo_prestamos.joblib]
M -->|probabilidad de impago|F
end
F -->|JSON de respuesta|C
H[Monitorización] -->|GET /health|U
Paso 1 — Estructura del proyecto¶
Crea la carpeta y la estructura:
Copia el modelo entrenado en el LAB 4:
# Windows (PowerShell)
copy ..\lab4\modelo_prestamos.joblib models\
# Linux / macOS
cp ../lab4/modelo_prestamos.joblib models/
Estructura final que vamos a construir:
lab5/
├── app/
│ ├── __init__.py # Marca app/ como paquete Python (archivo vacío)
│ ├── main.py # La API FastAPI
│ └── schemas.py # Modelos Pydantic de entrada/salida
├── models/
│ └── modelo_prestamos.joblib # Pipeline del LAB 4
├── requirements.txt # Dependencias exactas
├── Dockerfile # Receta de la imagen
└── docker-compose.yml # Orquestación del servicio
Crea el archivo vacío app/__init__.py:
# Windows (PowerShell)
New-Item app\__init__.py -ItemType File
# Linux / macOS
touch app/__init__.py
Paso 2 — Esquemas Pydantic (app/schemas.py)¶
# app/schemas.py
from pydantic import BaseModel, Field # BaseModel: clase base; Field: validaciones y metadatos
from typing import Literal # Literal: restringe a un conjunto cerrado de valores
class SolicitudPrestamo(BaseModel):
"""Datos de entrada: una solicitud de préstamo. Mismas columnas que X del LAB 4."""
ingresos: float = Field( # Ingresos mensuales del solicitante
gt=0, le=100_000, # Debe ser > 0 y <= 100.000
description="Ingresos mensuales en euros",
examples=[2200.0], # Ejemplo que aparecerá en /docs
)
importe: float = Field( # Importe del préstamo solicitado
ge=1_000, le=60_000, # Rango permitido: mismo que en el entrenamiento
description="Importe solicitado en euros",
examples=[20000.0],
)
edad: int = Field( # Edad del solicitante
ge=18, le=100, # Mayor de edad, tope razonable
description="Edad en años",
examples=[29],
)
deuda_actual: float = Field( # Deuda viva actual
ge=0, # No puede ser negativa
description="Deuda actual en euros",
examples=[12000.0],
)
empleo: Literal["indefinido", "temporal", "autonomo", "desempleado"] = Field(
description="Situación laboral", # Solo las 4 categorías vistas en train
examples=["temporal"],
)
vivienda: Literal["propia", "hipoteca", "alquiler"] = Field(
description="Régimen de vivienda",
examples=["alquiler"],
)
antiguedad_solicitud: int = Field( # Días desde que se presentó la solicitud
ge=0, le=540,
description="Días desde la solicitud",
examples=[3],
)
mes_solicitud: int = Field( # Mes de la solicitud (estacionalidad)
ge=1, le=12,
description="Mes de la solicitud (1-12)",
examples=[6],
)
class RespuestaPrediccion(BaseModel):
"""Datos de salida: la predicción del modelo."""
probabilidad_impago: float = Field( # Probabilidad estimada de impago [0, 1]
description="Probabilidad de impago entre 0 y 1"
)
riesgo: Literal["bajo", "medio", "alto"] # Traducción a categoría de negocio
version_modelo: str # Trazabilidad: qué modelo respondió
class RespuestaSalud(BaseModel):
"""Datos de salida del healthcheck."""
status: Literal["ok", "degraded"] # Estado del servicio
modelo_cargado: bool # ¿El joblib está en memoria?
Nota
Pydantic rechaza automáticamente cualquier petición con tipos incorrectos, campos fuera de rango o categorías desconocidas, devolviendo un error 422 con el detalle exacto. Esa validación gratuita es media batalla de la robustez en producción.
Paso 3 — La API (app/main.py)¶
# app/main.py
from contextlib import asynccontextmanager # Para el ciclo de vida (cargar el modelo al arrancar)
from pathlib import Path # Rutas independientes del SO
import joblib # Carga del pipeline serializado
import pandas as pd # El pipeline espera un DataFrame con columnas nombradas
from fastapi import FastAPI, HTTPException # Framework web y errores HTTP
from app.schemas import ( # Nuestros contratos de entrada/salida
SolicitudPrestamo, RespuestaPrediccion, RespuestaSalud,
)
RUTA_MODELO = Path("models/modelo_prestamos.joblib") # Ruta relativa al directorio de trabajo del contenedor
VERSION_MODELO = "prestamos-hgb-1.0.0" # Versión que devolvemos para trazabilidad
estado = {"modelo": None} # Diccionario mutable donde vivirá el modelo cargado
@asynccontextmanager
async def lifespan(app: FastAPI): # Se ejecuta al arrancar y al apagar la app
estado["modelo"] = joblib.load(RUTA_MODELO) # Carga el pipeline UNA vez (no en cada petición)
print(f"Modelo cargado desde {RUTA_MODELO}") # Log de arranque (visible en docker logs)
yield # Aquí la app atiende peticiones
estado["modelo"] = None # Limpieza al apagar
app = FastAPI( # Instancia de la aplicación
title="API de riesgo de impago — AI Master Academy",
description="Sirve el pipeline HistGradientBoosting del LAB 4",
version="1.0.0",
lifespan=lifespan, # Conecta el ciclo de vida definido arriba
)
@app.get("/health", response_model=RespuestaSalud) # Endpoint de salud para monitorización/orquestadores
def health():
cargado = estado["modelo"] is not None # ¿Tenemos modelo en memoria?
return RespuestaSalud( # Respuesta tipada y validada por Pydantic
status="ok" if cargado else "degraded",
modelo_cargado=cargado,
)
@app.post("/predict", response_model=RespuestaPrediccion) # Endpoint principal de predicción
def predict(solicitud: SolicitudPrestamo): # FastAPI parsea y valida el JSON contra el esquema
if estado["modelo"] is None: # Defensa: el modelo debería estar cargado
raise HTTPException(status_code=503, detail="Modelo no disponible") # 503: servicio no disponible
datos = pd.DataFrame([solicitud.model_dump()]) # Dict del Pydantic -> DataFrame de 1 fila con columnas nombradas
proba = float(estado["modelo"].predict_proba(datos)[0, 1]) # Probabilidad de la clase 1 (impago)
if proba < 0.30: # Reglas de negocio para categorizar el riesgo
riesgo = "bajo"
elif proba < 0.60:
riesgo = "medio"
else:
riesgo = "alto"
return RespuestaPrediccion( # Respuesta validada contra el esquema de salida
probabilidad_impago=round(proba, 4), # 4 decimales son suficientes
riesgo=riesgo,
version_modelo=VERSION_MODELO,
)
Paso 4 — Probar en local con uvicorn, curl y /docs¶
Desde la carpeta lab5/, arranca el servidor de desarrollo:
Salida esperada:
INFO: Will watch for changes in these directories: ['...lab5']
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: Started reloader process [12345] using WatchFiles
Modelo cargado desde models\modelo_prestamos.joblib
INFO: Application startup complete.
En otra terminal, prueba el healthcheck:
Salida esperada:
Ahora una predicción (en Windows PowerShell, curl es un alias de Invoke-WebRequest; usa curl.exe como aquí):
curl.exe -X POST http://127.0.0.1:8000/predict ^
-H "Content-Type: application/json" ^
-d "{\"ingresos\": 2200, \"importe\": 20000, \"edad\": 29, \"deuda_actual\": 12000, \"empleo\": \"temporal\", \"vivienda\": \"alquiler\", \"antiguedad_solicitud\": 3, \"mes_solicitud\": 6}"
En Linux/macOS:
curl -X POST http://127.0.0.1:8000/predict \
-H "Content-Type: application/json" \
-d '{"ingresos": 2200, "importe": 20000, "edad": 29, "deuda_actual": 12000, "empleo": "temporal", "vivienda": "alquiler", "antiguedad_solicitud": 3, "mes_solicitud": 6}'
Salida esperada:
Prueba también la validación de Pydantic enviando una edad imposible ("edad": 15):
{"detail":[{"type":"greater_than_equal","loc":["body","edad"],"msg":"Input should be greater than or equal to 18","input":15,"ctx":{"ge":18}}]}
Por último, abre en el navegador http://127.0.0.1:8000/docs: FastAPI genera documentación interactiva (Swagger UI) donde puedes lanzar peticiones con el botón Try it out. Detén el servidor con CTRL+C antes de continuar.
Consejo profesional
el JSON de ejemplo que pusiste en examples=[...] de cada Field aparece precargado en /docs. Cuida esos ejemplos: son la primera prueba que hará cualquier consumidor de tu API.
Paso 5 — requirements.txt¶
# requirements.txt — versiones fijadas para builds reproducibles
fastapi==0.115.12 # Framework web de la API
uvicorn[standard]==0.34.0 # Servidor ASGI ([standard] añade watchfiles, httptools...)
pydantic==2.10.6 # Validación de esquemas
scikit-learn==1.6.1 # DEBE coincidir con la versión usada al entrenar en el LAB 4
pandas==2.2.3 # El pipeline recibe DataFrames
joblib==1.4.2 # Carga del modelo serializado
numpy==2.2.4 # Dependencia numérica de sklearn/pandas
Advertencia
la versión de scikit-learn del contenedor debe ser la misma con la que entrenaste el joblib. Compruébala en tu entorno del LAB 4 con pip show scikit-learn y ajusta la línea correspondiente. Un desajuste de versión mayor puede lanzar InconsistentVersionWarning o directamente fallar al deserializar.
Paso 6 — Dockerfile¶
# Dockerfile
FROM python:3.12-slim # Imagen base ligera con Python 3.12 (Debian slim)
WORKDIR /code # Directorio de trabajo dentro del contenedor
# Copiamos SOLO requirements primero: si el código cambia pero las deps no,
# Docker reutiliza la capa cacheada de pip install (builds mucho más rápidos)
COPY requirements.txt . # requirements.txt -> /code/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt # Instala deps sin cachear wheels (imagen más pequeña)
COPY app/ ./app/ # Copiamos el código de la API
COPY models/ ./models/ # Copiamos el modelo entrenado
EXPOSE 8000 # Documenta el puerto que usa el servicio (informativo)
# Arranque: uvicorn sin --reload (eso es solo para desarrollo)
# --host 0.0.0.0 es OBLIGATORIO en Docker: escucha en todas las interfaces del contenedor
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Construye la imagen:
Salida esperada (resumida):
[+] Building 45.2s (12/12) FINISHED
=> [1/6] FROM docker.io/library/python:3.12-slim
=> [2/6] WORKDIR /code
=> [3/6] COPY requirements.txt .
=> [4/6] RUN pip install --no-cache-dir -r requirements.txt
=> [5/6] COPY app/ ./app/
=> [6/6] COPY models/ ./models/
=> => naming to docker.io/library/api-prestamos:1.0
Prueba rápida sin compose:
Y en otra terminal, el mismo curl del Paso 4 debe funcionar igual. Para el contenedor con CTRL+C.
Paso 7 — docker-compose.yml¶
# docker-compose.yml
services: # Definición de servicios de la aplicación
api: # Nombre lógico de nuestro servicio
build: . # Construye con el Dockerfile de esta carpeta
image: api-prestamos:1.0 # Nombre y tag de la imagen resultante
container_name: api-prestamos # Nombre fijo del contenedor (más cómodo en logs)
ports:
- "8000:8000" # Mapea puerto_host:puerto_contenedor
environment:
- PYTHONUNBUFFERED=1 # Los prints/logs salen al instante (sin buffer)
healthcheck: # Docker vigilará la salud del servicio
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 30s # Comprueba cada 30 segundos
timeout: 5s # Falla si tarda más de 5 s en responder
retries: 3 # 3 fallos seguidos -> estado unhealthy
start_period: 10s # Margen de gracia al arrancar (carga del modelo)
restart: unless-stopped # Reinicia el contenedor si se cae (salvo parada manual)
Levanta el servicio:
Salida esperada:
[+] Building 2.1s (12/12) FINISHED
[+] Running 2/2
✅ Network lab5_default Created
✅ Container api-prestamos Started
Verifica el estado y los logs:
Salida esperada (resumida):
NAME IMAGE STATUS
api-prestamos api-prestamos:1.0 Up 40 seconds (healthy)
api-prestamos | Modelo cargado desde models/modelo_prestamos.joblib
api-prestamos | INFO: Uvicorn running on http://0.0.0.0:8000
api-prestamos | INFO: Application startup complete.
Lanza la predicción final contra el contenedor (mismo curl del Paso 4). Cuando termines:
Tu modelo del LAB 4 está sirviendo predicciones desde un contenedor reproducible. Este mismo artefacto se despliega tal cual en cualquier nube.
Troubleshooting específico de Docker en Windows¶
| Síntoma | Causa | Solución |
|---|---|---|
error during connect: ... dockerDesktopLinuxEngine: The system cannot find the file |
Docker Desktop no está arrancado | Abre Docker Desktop y espera al icono verde antes de usar docker |
WSL 2 installation is incomplete |
Falta el kernel de WSL 2 | wsl --update en PowerShell como administrador y reinicia |
| Build lentísimo o el contenedor no ve cambios | Proyecto en el sistema de archivos de Windows montado en WSL | Trabaja bien desde Windows nativo o bien dentro de \\wsl$\..., sin mezclar |
ports are not available: ... bind: Only one usage of each socket address |
El puerto 8000 ya está en uso (a menudo otro uvicorn olvidado) | Cierra el proceso (netstat -ano | findstr :8000 + taskkill /PID <pid> /F) o mapea "8001:8000" |
curl devuelve HTML raro o falla el -d |
En PowerShell curl es alias de Invoke-WebRequest |
Usa curl.exe y comillas escapadas, o prueba desde /docs |
| Saltos de línea rotos en scripts dentro del contenedor | Git convirtió LF en CRLF al clonar en Windows | git config core.autocrlf input y re-checkout; en .gitattributes: *.sh text eol=lf |
Contenedor unhealthy |
El healthcheck no puede resolver localhost o el modelo tardó en cargar |
Sube start_period a 30s y revisa docker compose logs |
no space left on device |
Imágenes y capas antiguas acumuladas | docker system prune -a (borra imágenes no usadas: revisa antes) |
Errores comunes¶
| Error | Causa | Solución |
|---|---|---|
FileNotFoundError: models/modelo_prestamos.joblib |
No copiaste el modelo del LAB 4 o el WORKDIR no coincide | Copia el joblib a lab5/models/ y verifica el COPY models/ del Dockerfile |
ModuleNotFoundError: No module named 'app' |
Lanzaste uvicorn desde dentro de app/ |
Ejecuta uvicorn app.main:app desde la raíz lab5/ |
| 422 Unprocessable Entity | El JSON no cumple el esquema Pydantic (tipo, rango o categoría) | Lee el campo detail de la respuesta: indica campo y regla exacta |
| La API funciona en local pero no en Docker | uvicorn escuchando en 127.0.0.1 dentro del contenedor | En el CMD del Dockerfile usa --host 0.0.0.0 |
InconsistentVersionWarning al cargar el joblib |
Versión de sklearn distinta entre entrenamiento y contenedor | Iguala la versión en requirements.txt con la del LAB 4 |
ValueError: columns are missing en predict |
El DataFrame de entrada no tiene las 8 columnas con el nombre exacto | Los nombres del esquema Pydantic deben calcar los de X del LAB 4 |
| Cambias código y el contenedor sigue igual | La imagen se construyó antes del cambio | Reconstruye: docker compose up -d --build |
Cómo depurar¶
- Primero fuera de Docker: si la API falla, depúrala con
uvicorn --reloaden local. Docker solo debe añadir empaquetado, nunca lógica nueva. - Logs del contenedor en vivo:
docker compose logs -f apies tu consola; el traceback de Python aparece ahí íntegro. - Entra al contenedor:
docker compose exec api bashy comprueba a mano quemodels/existe (ls models/) y que Python carga el joblib (python -c "import joblib; joblib.load('models/modelo_prestamos.joblib')"). - Aísla la red: si
curlfalla desde el host, prueba desde dentro:docker compose exec api python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:8000/health').read())". Si dentro funciona y fuera no, el problema es el mapeo de puertos. - Valida el JSON con /docs: la interfaz Swagger construye el cuerpo correcto por ti; si desde ahí funciona y con curl no, el error está en tus comillas/escapado.
- Inspecciona la imagen:
docker image lsydocker history api-prestamos:1.0muestran capas y tamaños; una imagen de más de ~1.5 GB indica que estás copiando de más (usa.dockerignore).
Para ir más lejos¶
- Endpoint por lotes: añade
POST /predict/batchque recibalist[SolicitudPrestamo]y devuelva una lista de predicciones; mide contimela mejora frente a N llamadas individuales. - Tests automáticos: escribe tests con
pytestyfastapi.testclient.TestClientque cubran/health, una predicción válida y un 422; ejecútalos en el build del Dockerfile para que una imagen rota nunca llegue a producción. - Observabilidad mínima: añade un middleware que loguee latencia y payload de cada petición en JSON, y expón un contador de predicciones por nivel de riesgo. Es el primer paso hacia el monitoreo de data drift que verás en el módulo de MLOps.
Cierre del módulo de laboratorios¶
Si has llegado hasta aquí, has recorrido el ciclo de vida completo de un proyecto de ML:
| Lab | Habilidad consolidada |
|---|---|
| LAB 1 | Flujo end-to-end, baselines e interpretación para negocio |
| LAB 2 | Métricas de clasificación y decisiones por coste económico |
| LAB 3 | Aprendizaje no supervisado aplicado a marketing |
| LAB 4 | Pipelines robustos, validación cruzada y tuning profesional |
| LAB 5 | Servir modelos en producción con FastAPI y Docker |
Consejo profesional
sube los 5 laboratorios a un repositorio de GitHub con un README por lab. Este material, bien documentado, es un portfolio real que puedes enseñar en una entrevista técnica.
| Anterior | Módulo | Siguiente |
|---|---|---|
| Ejercicios | 02-MACHINE-LEARNING | Proyectos |