Saltar a contenido

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

  1. Crea una carpeta por laboratorio (lab1/, lab2/...) y un entorno virtual por módulo.
  2. Escribe el código tú mismo en lugar de copiar y pegar: la memoria muscular importa.
  3. Compara tu salida con la salida esperada en cada paso antes de continuar.
  4. 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.
pip install numpy pandas scikit-learn matplotlib

Crea la carpeta de trabajo:

mkdir lab1
cd lab1

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:

python datos.py

Salida esperada:

Dataset guardado: 400 filas, 6 columnas

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:

Train: 320 filas | Test: 80 filas
Baseline (media) -> MAE: 55,143 € | R²: -0.006

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

  1. Imprime formas en cada frontera: print(X_train.shape, X_test.shape). La mayoría de errores de sklearn son de dimensiones.
  2. Verifica la fuga de datos: si tu R² es sospechosamente perfecto (>0.999), busca el objetivo dentro de X con print(X.columns).
  3. Compara siempre contra el Dummy: si tu modelo no bate al baseline, el problema no es el algoritmo: es el dato o el preprocesado.
  4. Reproduce el error en pequeño: prueba con df.head(20) para aislar si el fallo depende del volumen de datos.
  5. Lee el traceback de abajo hacia arriba: la última línea dice el qué; las anteriores, el dónde.

Para ir más lejos

  1. Añade una feature no lineal: crea df["m2_cuadrado"] = df["m2"]**2 y comprueba si mejora el MAE (spoiler: casi nada, porque la relación real es lineal).
  2. 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 subir alpha.
  3. 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

pip install numpy pandas scikit-learn matplotlib
mkdir lab2
cd lab2

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:

python datos_churn.py

Salida esperada:

Clientes: 600 | Tasa de churn: 15.2%

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

  1. 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á.
  2. Verifica el estratificado: y_train.mean() y y_test.mean() deben ser ~0.15 ambas.
  3. Cuadra la matriz de confusión: tn + fp + fn + tp debe ser igual a len(y_test) (150).
  4. Testea el barrido con un umbral manual: calcula el coste para u=0.5 a mano y compáralo con la fila de la tabla.
  5. 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

  1. 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.
  2. Calibración: usa CalibratedClassifierCV y comprueba con calibration_curve si las probabilidades son fiables (clave cuando decides por umbral de coste).
  3. 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

pip install numpy pandas scikit-learn matplotlib
mkdir lab3
cd lab3

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:

python transacciones.py

Salida esperada (aproximada):

Transacciones: 2205 | Clientes únicos: 200

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:

=== Reparto final ===
segmento
Ocasional    79
Habitual     61
Dormido      45
VIP          15
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

  1. Verifica el escalado: X.mean(axis=0) debe dar ~0 y X.std(axis=0) ~1 en las tres columnas.
  2. Cuenta clientes antes y después del groupby: df["cliente_id"].nunique() debe coincidir con len(rfm) (200).
  3. Visualiza en 2D: grafica recency vs monetary coloreando por clúster; los segmentos deben verse como nubes separadas.
  4. Comprueba la asignación: rfm.groupby("cluster").size().sum() debe ser 200 (nadie sin clúster).
  5. 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

  1. RFM con scoring clásico: calcula quintiles (1-5) por cada métrica con pd.qcut y compara la segmentación 555/111 con la de K-Means.
  2. Prueba DBSCAN: detecta outliers (clientes anómalos) que K-Means fuerza dentro de algún clúster. ¿Aparecen los VIP como "ruido"?
  3. Pipeline de scoring de nuevos clientes: función asignar_segmento(recency, frequency, monetary) que escale con el scaler ya ajustado y use km_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

pip install numpy pandas scikit-learn matplotlib joblib scipy
mkdir lab4
cd lab4

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:

Preprocesador construido.
Train: (720, 8) | Test: (180, 8)

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):

Pipeline guardado en modelo_prestamos.joblib
Probabilidad de impago del cliente nuevo: 62.4%

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

  1. Inspecciona el preprocesado aislado: preprocesador.fit_transform(X_train).shape te dice cuántas columnas salen tras el one-hot (deberían ser 6 numéricas + 4 empleos + 3 viviendas = 13).
  2. Nombres de las features transformadas: preprocesador.get_feature_names_out() es tu mapa cuando algo no cuadra.
  3. Valida el pipeline con un modelo trivial primero: si DummyClassifier dentro del pipeline falla, el problema es el preprocesado, no el modelo.
  4. Mira los resultados de la búsqueda: pd.DataFrame(busqueda.cv_results_) ordenado por rank_test_score revela si el espacio de búsqueda está bien centrado.
  5. Un fold a mano: si cross_validate da error, reproduce un solo fold con train_test_split para obtener un traceback más claro.

Para ir más lejos

  1. Añade TargetEncoder (sklearn ≥ 1.3) para empleo en lugar de one-hot y compara AUC: con alta cardinalidad suele ganar.
  2. Curvas de validación: grafica AUC vs learning_rate con validation_curve para entender la sensibilidad del boosting.
  3. 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:
pip install fastapi "uvicorn[standard]" pydantic scikit-learn joblib pandas

Verifica Docker antes de empezar:

docker --version
docker compose version

Salida esperada (las versiones pueden variar):

Docker version 27.x.x, build xxxxxxx
Docker Compose version v2.xx.x

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:

mkdir lab5
cd lab5
mkdir app models

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:

uvicorn app.main:app --reload --port 8000

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:

curl http://127.0.0.1:8000/health

Salida esperada:

{"status":"ok","modelo_cargado":true}

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:

{"probabilidad_impago":0.6238,"riesgo":"alto","version_modelo":"prestamos-hgb-1.0.0"}

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:

docker build -t api-prestamos:1.0 .

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:

docker run --rm -p 8000:8000 api-prestamos:1.0

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:

docker compose up -d --build

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:

docker compose ps
docker compose logs api

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:

docker compose down

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

  1. Primero fuera de Docker: si la API falla, depúrala con uvicorn --reload en local. Docker solo debe añadir empaquetado, nunca lógica nueva.
  2. Logs del contenedor en vivo: docker compose logs -f api es tu consola; el traceback de Python aparece ahí íntegro.
  3. Entra al contenedor: docker compose exec api bash y comprueba a mano que models/ existe (ls models/) y que Python carga el joblib (python -c "import joblib; joblib.load('models/modelo_prestamos.joblib')").
  4. Aísla la red: si curl falla 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.
  5. 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.
  6. Inspecciona la imagen: docker image ls y docker history api-prestamos:1.0 muestran 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

  1. Endpoint por lotes: añade POST /predict/batch que reciba list[SolicitudPrestamo] y devuelva una lista de predicciones; mide con time la mejora frente a N llamadas individuales.
  2. Tests automáticos: escribe tests con pytest y fastapi.testclient.TestClient que 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.
  3. 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