Cuarto proyecto y un enemigo nuevo: el desbalanceo extremo. Vas a construir un detector de transacciones fraudulentas con tarjeta en el que solo el 1 % de los casos son fraude — el escenario donde la accuracy engaña, los umbrales se deciden en euros y las métricas del módulo 6 se ganan el sueldo. El problema te resultará familiar por partida doble: es el detector de spam del teorema de Bayes de 02-05 con dinero en juego, y el pariente supervisado de las anomalías en pedidos que cazaste con DBSCAN en 05-04 — solo que aquí sí tenemos etiquetas históricas de fraude, así que podemos entrenar clasificadores. Usaremos un dataset sintético generado en el propio código (los datasets reales de fraude son confidenciales por naturaleza; el clásico público de Kaggle llega anonimizado por PCA), lo que además nos da control total sobre el experimento.

Contenido

  1. Definición del problema, costes y métrica objetivo
  2. Generación del dataset sintético
  3. EDA con clases desbalanceadas
  4. Por qué la accuracy miente al 99 %
  5. Estrategias contra el desbalanceo
  6. Modelado y comparación con CV
  7. La curva precision-recall y el AUC-PR
  8. El umbral óptimo, en euros
  9. Evaluación final en test
  10. Conclusiones y conexión con producción

Definición del problema, costes y métrica objetivo

  • Problema: clasificación binaria — ¿esta transacción es fraudulenta (1) o legítima (0)?
  • La asimetría que lo gobierna todo: los dos errores no cuestan lo mismo. Un fraude no detectado (falso negativo) cuesta el importe defraudado más la gestión, digamos 150 € de media; bloquear una transacción legítima (falso positivo) cuesta la revisión manual y la fricción con el cliente, digamos 5 €. Esta matriz de costes, como aprendiste en 06-04 "Curva ROC y AUC", decidirá el umbral.
  • Métricas: precision, recall y F1 de la clase fraude (06-02 "Métricas de evaluación"), curva precision-recall con su AUC-PR como métrica de comparación entre modelos, y el coste total en euros como métrica final de negocio. La accuracy queda vetada, y enseguida verás por qué.
Predicho: legítima Predicho: fraude
Real: legítima 0 € 5 € (revisión + fricción)
Real: fraude 150 € (pérdida) 0 € (fraude evitado)

Generación del dataset sintético

make_classification genera un problema de clasificación con estructura controlada; con weights=[0.99, 0.01] imponemos el desbalanceo. Después vestimos las features abstractas con nombres y escalas realistas para poder razonar como analistas:

import numpy as np
import pandas as pd
from sklearn.datasets import make_classification

X_raw, y = make_classification(
    n_samples=50_000, n_features=8, n_informative=5, n_redundant=1,
    weights=[0.99, 0.01],          # 1% de fraude
    class_sep=1.0, flip_y=0.005,   # algo de ruido en etiquetas: realismo
    random_state=42,
)

columnas = [
    "importe_eur",          # importe de la transacción
    "hora_dia",             # hora (el fraude prefiere la madrugada)
    "dist_domicilio_km",    # distancia del comercio al domicilio del titular
    "frec_comercio_mes",    # veces que el titular compra en ese comercio al mes
    "ops_ultima_hora",      # transacciones de la tarjeta en la última hora
    "antiguedad_tarjeta",   # meses desde la emisión
    "ratio_importe_medio",  # importe / gasto medio histórico del titular
    "pago_online",          # señal de canal (mayor => más online)
]
df = pd.DataFrame(X_raw, columns=columnas)
df["fraude"] = y

print(df["fraude"].value_counts())          # ~49500 / ~500
print(f"Tasa de fraude: {y.mean():.3%}")

Qué estamos simulando y qué no: las columnas generadas son combinaciones gaussianas abstractas a las que ponemos nombres para razonar con ellas — la estructura del problema (pocas features informativas, 1 % de positivos, algo de ruido en etiquetas) sí es fiel a la realidad; los valores no son horas ni euros literales. En un caso real, features como dist_domicilio_km o ratio_importe_medio saldrían de la ingeniería de características de 03-06 sobre el historial del titular — exactamente como construiste el RFM de MercaFresh.

Dividimos estratificando, imprescindible con un 1 % de positivos (06-01):

from sklearn.model_selection import train_test_split

X = df.drop(columns="fraude")
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42
)
print("Fraudes en train:", y_train.sum(), "| en test:", y_test.sum())

EDA con clases desbalanceadas

Con desbalanceo, los histogramas globales son inútiles: los fraudes desaparecen bajo la masa legítima. La herramienta correcta es comparar distribuciones por clase:

import matplotlib.pyplot as plt

train = X_train.copy(); train["fraude"] = y_train

fig, ejes = plt.subplots(2, 4, figsize=(15, 7))
for eje, col in zip(ejes.ravel(), columnas):
    for clase, color in [(0, "tab:blue"), (1, "tab:red")]:
        eje.hist(train.loc[train.fraude == clase, col], bins=40,
                 density=True, alpha=0.5, color=color)
    eje.set_title(col)
plt.tight_layout(); plt.show()

print(train.groupby("fraude").mean().round(2).T)

El density=True es la clave: normaliza cada histograma para poder superponer 370 fraudes sobre 37.000 legítimas. Busca las features donde las dos campanas se separan (las informativas) y las que se solapan por completo (las que apenas aportarán). Este mismo gráfico, comparando "hoy" contra "el histórico", será tu detector de drift en producción — lo retomamos al final.

Por qué la accuracy miente al 99 %

El experimento que lo demuestra, con el DummyClassifier en el papel de estafador de métricas:

from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, recall_score, precision_score

vago = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
pred = vago.predict(X_test)

print("Accuracy:", round(accuracy_score(y_test, pred), 4))   # ~0.99
print("Recall fraude:", recall_score(y_test, pred))          # 0.0
print("Precision fraude:", precision_score(y_test, pred, zero_division=0))  # 0.0

Un modelo que dice "todo es legítimo" acierta el 99 % de las veces y no detecta un solo fraude: coste total = todos los fraudes pagados. Es el ejemplo canónico de 06-02: cuando las clases están desbalanceadas, la accuracy mide sobre todo la proporción de la clase mayoritaria. Desde aquí, precision/recall/F1 de la clase fraude y euros.

Estrategias contra el desbalanceo

Tres familias, de más simple a más invasiva:

  1. class_weight="balanced": el modelo pondera cada ejemplo de fraude ~99 veces más en su función de pérdida. No toca los datos, una línea, casi siempre lo primero que probar.
  2. Mover el umbral: entrenar normal, pero no cortar en 0,5 sino donde la matriz de costes diga (06-04). Es la estrategia más honesta porque separa el modelo (probabilidades) de la decisión (umbral de negocio).
  3. Remuestreo: cambiar el propio train. Sobremuestrear la minoría (duplicar fraudes, o generar sintéticos interpolados con SMOTE, disponible en la librería imbalanced-learn) o submuestrear la mayoría. En código lo ilustramos con resample de sklearn:
from sklearn.utils import resample

train_leg = train[train.fraude == 0]
train_fra = train[train.fraude == 1]

fra_sobre = resample(train_fra, replace=True,
                     n_samples=len(train_leg) // 10,  # ratio 10:1, no 1:1
                     random_state=42)
train_bal = pd.concat([train_leg, fra_sobre])
print(train_bal["fraude"].value_counts())

Regla de oro: el remuestreo se aplica solo al train, jamás al test (queremos evaluar contra la realidad del 1 %) y, si se combina con CV, dentro de cada partición — remuestrear antes de dividir duplica fraudes entre train y validación: la fuga de 06-01 en versión desbalanceada.

Modelado y comparación con CV

Tres modelos del curso con class_weight donde existe, comparados con CV estratificada (06-03) sobre AUC-PR (average_precision), recall y precision:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.model_selection import cross_validate, StratifiedKFold

modelos = {
    "Logística": Pipeline([
        ("esc", StandardScaler()),
        ("m", LogisticRegression(class_weight="balanced", max_iter=1000)),
    ]),
    "Random Forest": RandomForestClassifier(
        n_estimators=200, class_weight="balanced", n_jobs=-1, random_state=42),
    "HistGradientBoosting": HistGradientBoostingClassifier(random_state=42),
}

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scoring = {"ap": "average_precision", "recall": "recall", "precision": "precision"}

filas = []
for nombre, m in modelos.items():
    r = cross_validate(m, X_train, y_train, cv=cv, scoring=scoring)
    filas.append({"Modelo": nombre,
                  "AUC-PR": r["test_ap"].mean(),
                  "[email protected]": r["test_recall"].mean(),
                  "[email protected]": r["test_precision"].mean()})
print(pd.DataFrame(filas).round(3).to_string(index=False))

Resultados orientativos:

Modelo AUC-PR (CV) Recall @0,5 Precision @0,5 Lectura
HistGradientBoosting ≈ 0,80 ≈ 0,55 ≈ 0,85 Mejor ranking; su umbral 0,5 es conservador (sin class_weight)
Random Forest ≈ 0,75 ≈ 0,65 ≈ 0,70 Sólido; el peso de clase le sube el recall
Logística ≈ 0,65 ≈ 0,85 ≈ 0,10 El peso 99:1 la vuelca a gritar "fraude" con poca precision

Fíjate en que las columnas @0,5 son casi anecdóticas: dependen de un umbral que aún no hemos decidido. El AUC-PR, en cambio, evalúa la calidad del ranking de probabilidades en todos los umbrales a la vez — por eso es la métrica de comparación. Y una nota sobre ROC-AUC: con 1 % de positivos suele salir altísimo (0,95+) para todos, porque los verdaderos negativos abundantes lo inflan; la curva precision-recall, que ignora los verdaderos negativos, es mucho más exigente y realista aquí. Es el matiz final que quedó apuntado en 06-04.

La curva precision-recall y el AUC-PR

Elegido el HistGradientBoosting, dibujamos su curva con probabilidades de CV (sin tocar test):

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import precision_recall_curve, average_precision_score

mejor = HistGradientBoostingClassifier(random_state=42)
proba_cv = cross_val_predict(mejor, X_train, y_train, cv=cv,
                             method="predict_proba")[:, 1]

prec, rec, umbrales = precision_recall_curve(y_train, proba_cv)
print("AUC-PR:", round(average_precision_score(y_train, proba_cv), 3))

plt.plot(rec, prec)
plt.axhline(y_train.mean(), ls="--", color="gray",
            label=f"Azar = {y_train.mean():.1%}")
plt.xlabel("Recall (fraudes detectados)")
plt.ylabel("Precision (aciertos entre alertas)")
plt.title("Curva precision-recall (CV)"); plt.legend(); plt.show()

La línea gris es humillante y necesaria: un clasificador aleatorio tiene precision = 1 % en este problema. Toda la curva por encima es mérito del modelo. La curva es un menú de compromisos: cada punto es un umbral posible — más recall (cazar más fraudes) pagando precision (más falsas alarmas). ¿Cuál elegir? El que minimice euros.

El umbral óptimo, en euros

Aplicamos la matriz de costes a cada umbral candidato, como en 06-04 pero con moneda:

COSTE_FN = 150  # fraude no detectado
COSTE_FP = 5    # legítima bloqueada/revisada

resultados = []
for u in np.linspace(0.01, 0.99, 99):
    pred = (proba_cv >= u).astype(int)
    fn = ((y_train == 1) & (pred == 0)).sum()
    fp = ((y_train == 0) & (pred == 1)).sum()
    resultados.append({"umbral": u, "coste": fn * COSTE_FN + fp * COSTE_FP})

costes = pd.DataFrame(resultados)
optimo = costes.loc[costes.coste.idxmin()]
print(f"Umbral óptimo: {optimo.umbral:.2f} | coste CV: {optimo.coste:,.0f} €")

plt.plot(costes.umbral, costes.coste)
plt.axvline(optimo.umbral, color="red", ls="--")
plt.xlabel("Umbral"); plt.ylabel("Coste total (€)")
plt.title("Coste esperado según el umbral"); plt.show()

Con un falso negativo 30 veces más caro que un falso positivo, el umbral óptimo cae muy por debajo de 0,5 (típicamente entre 0,05 y 0,15): compensa revisar muchas transacciones legítimas con tal de cazar más fraudes. Compara el coste del umbral óptimo con dos referencias: el umbral 0,5 "por defecto" y la política "no bloquear nada" (= todos los fraudes pagados). La diferencia, en euros, es el argumento con el que este proyecto se defiende ante negocio — mucho más persuasivo que cualquier F1.

Evaluación final en test

Entrenamos con todo el train, aplicamos el umbral decidido y abrimos el test una única vez:

from sklearn.metrics import classification_report, confusion_matrix

mejor.fit(X_train, y_train)
proba_test = mejor.predict_proba(X_test)[:, 1]
pred_test = (proba_test >= optimo.umbral).astype(int)

print(confusion_matrix(y_test, pred_test))
print(classification_report(y_test, pred_test,
                            target_names=["legítima", "fraude"], digits=3))

fn = ((y_test == 1) & (pred_test == 0)).sum()
fp = ((y_test == 0) & (pred_test == 1)).sum()
coste_modelo = fn * COSTE_FN + fp * COSTE_FP
coste_sin_modelo = y_test.sum() * COSTE_FN
print(f"Coste con modelo: {coste_modelo:,.0f} € | sin modelo: {coste_sin_modelo:,.0f} €")
print(f"Ahorro: {coste_sin_modelo - coste_modelo:,.0f} €")

El informe final debe contar tres números: recall de fraude (qué fracción del fraude cazamos), precision (qué fracción de las alertas son reales — es la carga de trabajo del equipo de revisión) y el ahorro en euros frente a no hacer nada. Si coinciden razonablemente con lo estimado en CV, el protocolo fue limpio.

Errores Comunes y Consejos

  • Dividir sin estratificar. Con 1 % de positivos, una división aleatoria puede dejar el test casi sin fraudes y las métricas se vuelven ruido. stratify=y siempre.
  • Remuestrear antes de dividir. Duplicar fraudes y luego repartir train/test pone copias del mismo fraude a ambos lados: métricas infladas e ilusorias. Remuestreo solo en el train, y dentro de cada partición si hay CV.
  • Presumir de ROC-AUC. Un 0,95 de ROC-AUC con 1 % de positivos puede convivir con una precision del 8 %. Reporta AUC-PR y la curva precision-recall.
  • Fijar el umbral con el test. El umbral es un hiperparámetro de la decisión: se elige con CV sobre el train. Elegirlo mirando el test es sobreajustar la decisión final.
  • Dar la matriz de costes por eterna. Los 150 €/5 € son estimaciones de negocio que cambian (importes medios, coste del equipo de revisión). Documenta las cifras y recalcula el umbral cuando cambien.

Retos para ampliar

  1. IsolationForest, el primo no supervisado. Entrena sklearn.ensemble.IsolationForest ignorando las etiquetas y comprueba cuántos fraudes reales aparecen entre sus anomalías, conectando con el enfoque de 05-04 "Análisis de agrupamiento DBSCAN". Pista: usa contamination=0.01; es la herramienta para el arranque en frío, cuando aún no hay etiquetas históricas.
  2. Sensibilidad a la matriz de costes. Recalcula el umbral óptimo para varios escenarios (FN = 50 €, 300 €; FP = 1 €, 20 €) y dibuja cómo se mueve. Pista: si el umbral óptimo apenas cambia entre escenarios plausibles, tu decisión es robusta; si baila, negocio debe afinar sus estimaciones antes de desplegar.
  3. Drift temporal. Simula que el fraude evoluciona: genera un segundo dataset con random_state distinto y algo más de class_sep o un desplazamiento sumado a dos features, y evalúa el modelo antiguo sobre él. Pista: mide cuánto caen recall y AUC-PR y qué histogramas por clase delatan el cambio — estás reproduciendo en miniatura el ciclo de 08-03.

Conclusiones y conexión con producción

Este proyecto te ha enseñado a trabajar cuando la clase que importa es una aguja en un pajar: estratificar todo, desconfiar de la accuracy (y hasta del ROC-AUC), comparar modelos por AUC-PR, y sobre todo separar el modelo de la decisión — las probabilidades las da el algoritmo, el umbral lo dan los euros, exactamente el método de 06-04 llevado a sus últimas consecuencias. También has visto el arsenal contra el desbalanceo (pesos de clase, umbral, remuestreo) y sus trampas. Y queda la advertencia final, que conecta con 08-02 "Implementación de modelos en producción" y 08-03 "Mantenimiento y monitoreo de modelos": el fraude es el problema con más drift que existe, porque al otro lado hay adversarios que se adaptan a tu detector — el modelo que hoy ahorra miles de euros se degrada en meses, y el monitoreo por histogramas y el reentrenamiento periódico no son opcionales sino parte del producto. Con esto cierras el bloque de proyectos supervisados; en el último proyecto volvemos a casa, a MercaFresh, para cerrar el círculo con el aprendizaje no supervisado: segmentar a los clientes de principio a fin.

Curso de Machine Learning

Módulo 1: Introducción al Machine Learning

Módulo 2: Fundamentos de Estadística y Probabilidad

Módulo 3: Preprocesamiento de Datos

Módulo 4: Algoritmos de Machine Learning Supervisado

Módulo 5: Algoritmos de Machine Learning No Supervisado

Módulo 6: Evaluación y Validación de Modelos

Módulo 7: Técnicas Avanzadas y Optimización

Módulo 8: Implementación y Despliegue de Modelos

Módulo 9: Proyectos Prácticos

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados