En 05-02 destilamos el principio de los autoencoders en una frase: «lo raro se reconstruye mal». Y prometimos aplicarlo al fraude. Este tercer proyecto lo cumple: construirás un detector de transacciones fraudulentas para TecnoMarket entrenando un autoencoder solo con transacciones normales y usando el error de reconstrucción como puntuación de anomalía. Además del modelo, este proyecto te enseña dos disciplinas imprescindibles en problemas reales: evaluar con métricas correctas cuando las clases están brutalmente desbalanceadas y traducir la matriz de confusión a coste de negocio.

Contenido

  1. Enunciado del proyecto y por qué no es clasificación supervisada
  2. Fase 1: el dataset de transacciones
  3. Fase 2: partición y escalado sin fuga
  4. Fase 3: el autoencoder, entrenado solo con lo normal
  5. Fase 4: error de reconstrucción como puntuación de anomalía
  6. Fase 5: elección del umbral
  7. Fase 6: métricas ante desbalance y matriz de confusión en coste de negocio
  8. Fase 7: entrega — cola de revisión y API

Enunciado del proyecto y por qué no es clasificación supervisada

Contexto de negocio. TecnoMarket procesa decenas de miles de pedidos al día; una fracción mínima es fraude (tarjetas robadas, cuentas comprometidas). El equipo antifraude no da abasto revisando a mano.

¿Por qué no entrenar un clasificador supervisado fraude/no-fraude como los de 04-03? Dos razones:

  • Casi no hay ejemplos de fraude (aquí, 0.5 %), y los que hay están sesgados hacia los fraudes que ya sabemos detectar.
  • El fraude muta: un patrón nuevo no se parece a los etiquetados. Un clasificador supervisado solo reconoce el fraude del pasado.

El enfoque no supervisado de 05-02 esquiva ambas: el autoencoder aprende a comprimir y reconstruir únicamente transacciones normales. Cualquier cosa que no encaje en ese molde — fraude viejo o nuevo — se reconstruirá mal y disparará la alarma.

Fase 1: el dataset de transacciones

Generamos con numpy un dataset ficticio pero con estructura realista: cinco variables por transacción, patrones normales coherentes y un 0.5 % de fraudes con patrones distintos (la misma filosofía de datos sintéticos controlados que usamos con la serie de ventas de 04-04):

import numpy as np

rng = np.random.default_rng(42)   # semilla: 06-04
N_NORMAL, N_FRAUDE = 20_000, 100  # 0.5 % de fraude

# --- Transacciones normales ---
importe   = rng.lognormal(mean=3.6, sigma=0.7, size=N_NORMAL)      # ~20-150 EUR
hora      = np.clip(rng.normal(15, 4, N_NORMAL) % 24, 0, 24)       # pico por la tarde
n_items   = rng.poisson(2, N_NORMAL) + 1                           # 1-5 articulos
dist_km   = rng.exponential(30, N_NORMAL)                          # envio cercano
antig_dias= rng.exponential(400, N_NORMAL)                         # cuentas veteranas

X_normal = np.column_stack([importe, hora, n_items, dist_km, antig_dias])

# --- Fraudes: patrones distintos, no una sola receta ---
mitad = N_FRAUDE // 2
# Patron A: importe alto, madrugada, cuenta recien creada
fraude_a = np.column_stack([
    rng.lognormal(6.2, 0.4, mitad),            # ~300-900 EUR
    rng.uniform(1, 5, mitad),                  # 1-5 de la madrugada
    rng.poisson(1, mitad) + 1,
    rng.exponential(30, mitad),
    rng.uniform(0, 10, mitad),                 # cuenta de dias
])
# Patron B: muchos articulos caros a direccion lejana
fraude_b = np.column_stack([
    rng.lognormal(5.8, 0.3, N_FRAUDE - mitad),
    np.clip(rng.normal(15, 4, N_FRAUDE - mitad) % 24, 0, 24),
    rng.poisson(8, N_FRAUDE - mitad) + 3,      # 8-15 articulos
    rng.exponential(600, N_FRAUDE - mitad),    # envio muy lejos
    rng.uniform(0, 60, N_FRAUDE - mitad),
])
X_fraude = np.vstack([fraude_a, fraude_b])

COLUMNAS = ["importe", "hora", "n_items", "dist_km", "antig_dias"]

Explora antes de modelar (fase 1 de 07-01): histogramas por variable superponiendo normal y fraude. Verás que ninguna variable separa el fraude por sí sola — es la combinación (importe alto + madrugada + cuenta nueva) lo que es anómalo. Justo lo que un autoencoder puede capturar y una regla manual por columnas no.

Fase 2: partición y escalado sin fuga

Dos reglas de oro, ambas ya conocidas:

  1. El autoencoder se entrena SOLO con transacciones normales. El fraude no entra en el entrenamiento: se reserva íntegro para evaluar.
  2. El scaler se ajusta solo con el train (la regla anti-fuga de 04-04): si el escalado viera el test — o los fraudes —, filtraríamos información del futuro.
from sklearn.preprocessing import StandardScaler

rng.shuffle(X_normal)
n_train = 14_000
X_train      = X_normal[:n_train]            # solo normales: entrenamiento
X_val_normal = X_normal[n_train:17_000]      # solo normales: umbral y parada
X_test = np.vstack([X_normal[17_000:], X_fraude])   # normales + TODO el fraude
y_test = np.concatenate([np.zeros(3_000), np.ones(N_FRAUDE)])

scaler = StandardScaler().fit(X_train)       # SOLO con train
X_train_s      = scaler.transform(X_train)
X_val_s        = scaler.transform(X_val_normal)
X_test_s       = scaler.transform(X_test)

Fíjate en la asimetría deliberada de la partición: el train es puro normal; el test concentra los 100 fraudes junto a 3 000 normales. Esta es la estructura estándar de un problema de detección de anomalías.

Fase 3: el autoencoder, entrenado solo con lo normal

La arquitectura es el autoencoder denso de 05-02, adaptado de imágenes a tabular: 5 → cuello de botella → 5. El cuello de 2-3 unidades obliga a comprimir; solo los patrones frecuentes (normales) sobreviven a la compresión:

import tensorflow as tf
from tensorflow.keras import layers, models

tf.random.set_seed(42)

autoencoder = models.Sequential([
    layers.Input(shape=(5,)),
    layers.Dense(16, activation="relu"),
    layers.Dense(3, activation="relu", name="cuello"),   # compresion 5 -> 3
    layers.Dense(16, activation="relu"),
    layers.Dense(5, activation="linear"),                # reconstruye las 5 vars
])

autoencoder.compile(optimizer="adam", loss="mse")

historia = autoencoder.fit(
    X_train_s, X_train_s,              # entrada = objetivo: aprender a copiar
    validation_data=(X_val_s, X_val_s),
    epochs=100, batch_size=64,
    callbacks=[tf.keras.callbacks.EarlyStopping(patience=10,
                                                restore_best_weights=True)],
    verbose=0,
)
print(f"MSE final val: {historia.history['val_loss'][-1]:.4f}")  # típico ~0.05-0.15

Detalles clave:

  • Salida linear, pérdida MSE: los datos escalados viven en toda la recta real, no en [0,1] como los píxeles de 05-02.
  • La validación aquí es de normales: mide si el modelo generaliza a normales no vistos, y gobierna el EarlyStopping (06-01). El fraude sigue sin aparecer.
  • Entrena en segundos: el valor de este proyecto no está en el tamaño del modelo sino en el protocolo.

Fase 4: error de reconstrucción como puntuación de anomalía

Ahora aplicamos el principio de 05-02: pasamos cada transacción por el autoencoder y medimos cuánto se parece la reconstrucción al original.

def puntuacion_anomalia(modelo, X):
    reconstruccion = modelo.predict(X, verbose=0)
    return np.mean((X - reconstruccion) ** 2, axis=1)   # MSE por transaccion

score_val  = puntuacion_anomalia(autoencoder, X_val_s)
score_test = puntuacion_anomalia(autoencoder, X_test_s)

print(f"Score medio normales (test): {score_test[y_test == 0].mean():.3f}")  # ~0.1
print(f"Score medio fraudes  (test): {score_test[y_test == 1].mean():.3f}")  # ~3-15

Pinta los dos histogramas (normal vs. fraude) en escala logarítmica: verás dos distribuciones separadas pero con solapamiento — algunos normales atípicos puntúan alto y algún fraude discreto puntúa bajo. Ese solapamiento es la razón de que elegir umbral sea un compromiso y no una frontera mágica.

Fase 5: elección del umbral

El umbral convierte la puntuación continua en decisión binaria. Enfoque práctico: fijarlo como un percentil de los scores de validación (solo normales) — por ejemplo, el percentil 99 significa «aceptamos señalar ~1 % de transacciones legítimas»:

umbral = np.percentile(score_val, 99)
y_pred = (score_test > umbral).astype(int)

Pero un percentil elegido a ciegas puede no ser el mejor compromiso. Explora varios y mide sus consecuencias:

from sklearn.metrics import precision_score, recall_score, f1_score

print(f"{'percentil':>9} | {'umbral':>7} | {'precision':>9} | {'recall':>6} | {'F1':>5}")
for p in [90, 95, 97, 99, 99.5]:
    u = np.percentile(score_val, p)
    pred = (score_test > u).astype(int)
    print(f"{p:>9} | {u:>7.2f} | {precision_score(y_test, pred):>9.2f} "
          f"| {recall_score(y_test, pred):>6.2f} | {f1_score(y_test, pred):>5.2f}")

Resultado típico con este dataset (tus cifras variarán algo):

Percentil Precision Recall Lectura de negocio
90 ~0.23 ~0.98 Caza casi todo el fraude, pero 3 de cada 4 alertas son falsas
95 ~0.38 ~0.96 Menos ruido, sigue cazando casi todo
99 ~0.72 ~0.88 Alertas mayormente ciertas; se escapa ~12 % del fraude
99.5 ~0.83 ~0.80 Muy fiable, pero 1 de cada 5 fraudes pasa

Subir el umbral compra precision (menos clientes molestados) vendiendo recall (más fraude colado). No existe el umbral correcto en abstracto: existe el umbral correcto para unos costes.

Fase 6: métricas ante desbalance y matriz de confusión en coste de negocio

Por qué la accuracy engaña aquí. Con 0.5 % de fraude, un «modelo» que responda siempre «normal» acierta el 99.5 %. Espectacular e inútil: recall 0. Con desbalance fuerte, la accuracy mide sobre todo el tamaño de la clase mayoritaria. Por eso este proyecto se evalúa con precision, recall y F1:

Métrica Pregunta que responde En TecnoMarket
Precision De lo que señalo, ¿cuánto es fraude de verdad? Cuántos clientes legítimos molesto
Recall Del fraude real, ¿cuánto cazo? Cuánto dinero dejo de perder
F1 Media armónica de ambas Compromiso en un solo número

La matriz de confusión, en euros. Supongamos el umbral del percentil 99 y su matriz típica sobre el test (3 000 normales + 100 fraudes):

Predicho normal Predicho fraude
Real normal 2 966 (TN) 34 (FP)
Real fraude 12 (FN) 88 (TP)

Traducción a coste, con supuestos explícitos (fraude medio no detectado ≈ 400 EUR perdidos; falsa alerta ≈ 5 EUR de coste de revisión + fricción con el cliente):

  • FN (fraude perdido): 12 × 400 = 4 800 EUR de pérdida directa.
  • FP (cliente molesto): 34 × 5 = 170 EUR + riesgo reputacional.

Con estos supuestos, un FN cuesta ~80 veces más que un FP: conviene bajar el umbral (percentil 95-97) y aceptar más alertas falsas. Si tu proceso de revisión fuera caro o bloqueara pedidos automáticamente, la balanza cambiaría. El umbral es una decisión de negocio informada por el modelo, no un hiperparámetro técnico.

Fase 7: entrega — cola de revisión y API

La decisión final no es binaria sino a tres bandas, reutilizando la cola de revisión humana de 03-04:

UMBRAL_REVISAR = np.percentile(score_val, 95)   # sospechoso: a revision
UMBRAL_BLOQUEAR = np.percentile(score_val, 99.9) # flagrante: retener pedido

def evaluar_transaccion(x_raw):
    x = scaler.transform(x_raw.reshape(1, -1))          # MISMO scaler del train
    score = float(puntuacion_anomalia(autoencoder, x)[0])
    if score > UMBRAL_BLOQUEAR:
        return {"score": score, "accion": "retener_y_verificar"}
    if score > UMBRAL_REVISAR:
        return {"score": score, "accion": "cola_revision"}
    return {"score": score, "accion": "aprobar"}

Para desplegar seguimos 06-05 al pie de la letra: scaler y modelo se guardan y versionan como una unidad (por ejemplo, scaler serializado junto al .keras, o integrado como capa Normalization dentro del modelo de inferencia), se valida sobre un conjunto congelado antes de cada actualización, y se sirve tras un endpoint FastAPI (POST /evaluar-transaccion) idéntico en estructura al del clasificador de reseñas. Punto operativo importante: lo «normal» cambia con las temporadas (recuerda el Black Friday de la serie de 04-04) — programa reentrenos periódicos con datos normales recientes para que la campaña de rebajas no se convierta en una tormenta de falsas alarmas.

Errores Comunes y Consejos

  • Colar fraudes en el entrenamiento: si el autoencoder aprende a reconstruir fraude, el fraude deja de puntuar alto. La pureza del train es la premisa de todo el método.
  • Ajustar el scaler con todos los datos: fuga clásica (04-04). El scaler se ajusta con el train normal y se aplica, congelado, a todo lo demás.
  • Presumir de accuracy: 99 % de accuracy aquí no significa nada. Reporta siempre precision, recall y F1, y la matriz de confusión.
  • Elegir el umbral sobre el test: el test (con sus fraudes) es el conjunto congelado. El umbral se fija con validación (percentiles de normales) o con costes de negocio; el test solo mide el resultado.
  • Umbral único e inamovible: la distribución de scores deriva con el tiempo. Monitoriza el porcentaje de alertas diario: si se dispara sin campaña que lo explique, toca reentrenar o recalibrar.

Ejercicios

  1. Reduce el cuello de botella a 2 unidades y auméntalo a 4. Compara la separación entre scores de normales y fraudes (por ejemplo, con la mediana de cada grupo) en los tres casos. ¿Qué pasa con un cuello demasiado ancho?
  2. Añade un tercer patrón de fraude que no exista en los datos actuales (por ejemplo, importe normal pero cientos de artículos) y evalúa el detector sin reentrenarlo. ¿Lo detecta? ¿Qué te dice esto frente a un clasificador supervisado?
  3. Define una función de coste total coste(umbral) = 400 * FN + 5 * FP, evalúala para percentiles de 90 a 99.9 y encuentra el umbral que la minimiza. Compara con el que elegiste a ojo en la fase 5.

Soluciones

  1. Con cuello de 2, la reconstrucción de normales empeora un poco (sube el score base) pero la separación relativa suele mantenerse o mejorar; con 4-5 unidades el autoencoder se acerca a la identidad y reconstruye también los fraudes razonablemente bien: la separación se hunde. Lección: el cuello estrecho no es un defecto, es el mecanismo.
  2. Genera por ejemplo np.column_stack([rng.lognormal(3.6,0.7,50), horas_normales, rng.poisson(200,50), dist_normales, antig_normales]), escala con el mismo scaler y puntúa. El detector típicamente lo caza con scores altísimos: n_items=200 está lejísimos de todo lo aprendido. Un clasificador supervisado entrenado solo con los patrones A y B no tendría ninguna garantía de detectarlo — esta es la ventaja estructural del enfoque no supervisado.
  3. Recorre percentiles, calcula la matriz de confusión con confusion_matrix(y_test, pred).ravel() (orden tn, fp, fn, tp) y evalúa 400*fn + 5*fp. Con los costes dados, el mínimo suele caer entre los percentiles 95 y 97 — más agresivo que el 99 «intuitivo», confirmando el análisis de la fase 6. Si repites con coste de FP de 50 EUR, el óptimo se desplaza hacia arriba: el umbral óptimo es función de los costes.

Conclusión

Tercer proyecto entregado: un detector de fraude que no necesita ejemplos de fraude para entrenarse. Has aplicado el autoencoder de 05-02 con la disciplina de datos de 04-04 (escalado sin fuga, particiones honestas), convertido el error de reconstrucción en puntuación de anomalía, elegido umbral como compromiso precision-recall y traducido la matriz de confusión a euros — el paso que convierte un ejercicio técnico en una herramienta de negocio con su cola de revisión (03-04) y su API (06-05). En la próxima lección volvemos a las imágenes y saldamos la deuda más esperada del módulo 5: el bucle de entrenamiento completo de una GAN con GradientTape, para el prototipo de generación de creatividades de TecnoMarket.

Curso de Deep Learning

Módulo 1: Introducción a Deep Learning

Módulo 2: Fundamentos de Redes Neuronales

Módulo 3: Redes Neuronales Convolucionales (CNN)

Módulo 4: Redes Neuronales Recurrentes (RNN)

Módulo 5: Técnicas Avanzadas en Deep Learning

Módulo 6: Herramientas y Frameworks

Módulo 7: Proyectos Prácticos

Módulo 8: Consideraciones Éticas y Futuro del Deep Learning

© Copyright 2026. Todos los derechos reservados