🏭 Caso de Uso

Autoencoder para Detección de Anomalías en FashionMNIST

Agente detector de anomalías basado en autoencoder MLP entrenado con PyTorch: entrenamiento solo con datos normales, selección de umbral y evaluación con ROC-AUC y PR-AUC.

🐍 Python 📓 Jupyter Notebook

Autoencoders con PyTorch para detección de anomalías (FashionMNIST)

Objetivo del notebook

En este notebook construiremos un agente detector de anomalías basado en un autoencoder entrenado con PyTorch. La idea central es muy utilizada en IA generativa: aprender una representación comprimida (espacio latente) de los datos "normales" para que el modelo reconstruya bien lo esperado, pero falle cuando ve patrones fuera de distribución.

Trabajaremos con FashionMNIST (dataset incluido en torchvision.datasets) y plantearemos una tarea de detección de anomalías donde:

  • Clase normal: T-shirt/top (etiqueta 0).
  • Clase anómala: el resto de clases (1–9).

Al final tendrás:

  1. Un flujo completo reproducible (EDA → entrenamiento → selección de umbral → evaluación).
  2. Gráficas de entrenamiento (loss y accuracy de detección por época).
  3. Métricas de desempeño (ROC-AUC, PR-AUC, matriz de confusión, informe de clasificación).
  4. Una demostración visual del agente entrenado tomando decisiones sobre ejemplos reales.

Fundamentos matemáticos y computacionales

1) Autoencoder

Un autoencoder tiene dos partes:

  • Encoder: transforma la entrada $x \in \mathbb{R}^d$ en una representación latente $z \in \mathbb{R}^k$, con $k < d$.
  • Decoder: reconstruye $\hat{x}$ a partir de $z$.

$$ z = f_\theta(x), \qquad \hat{x} = g_\phi(z) $$

El entrenamiento minimiza el error de reconstrucción:

$$ \mathcal{L}_{rec}(x,\hat{x}) = \lVert x - \hat{x} \rVert_2^2 $$

(En este notebook usaremos MSE).

2) Detección de anomalías por error de reconstrucción

Si entrenamos exclusivamente con ejemplos normales, el autoencoder aprende su estructura interna. Para una muestra nueva, calculamos el error de reconstrucción por píxel:

$$ e(x) = \text{MSE}(x,\hat{x}) $$

Si $e(x)$ supera un umbral $\tau$ (calibrado sobre datos normales de entrenamiento), declaramos anomalía:

$$ \hat{y}=\begin{cases} 1 & \text{si } e(x)>\tau \ 0 & \text{si } e(x)\le\tau \end{cases} $$

La intuición es que el autoencoder solo sabe reconstruir lo que ha visto (T-shirts). Cuando recibe una prenda diferente (pantalón, bolso, bota...), la reconstrucción será pobre y el error alto.

3) Castigos y recompensas (explicación didáctica)

Aunque no estamos en aprendizaje por refuerzo, es útil pensar la función objetivo como un sistema de castigos/recompensas:

  • Castigo principal: error de reconstrucción alto (MSE grande).
    • Si el modelo reconstruye mal un patrón normal, recibe un castigo fuerte.
  • Recompensa implícita: reconstrucción fiel en datos normales (MSE bajo).
    • Cuanto mejor representa lo normal, mayor "recompensa".
  • Castigo de complejidad (regularización L2):
    • Penaliza pesos excesivos para evitar memorizar ruido y mejorar generalización.

Formalmente, optimizamos una pérdida total:

$$ \mathcal{L}{total}=\mathcal{L}{rec}+\lambda,\mathcal{L}_{reg} $$

donde $\mathcal{L}_{reg}$ es la norma L2 de los pesos (weight decay del optimizador).

Interpretación práctica del hiperparámetro $\lambda$:

  • Si $\lambda$ es muy baja, el modelo puede sobreajustar (poca disciplina, memoriza ruido).
  • Si $\lambda$ es muy alta, puede infraajustar (castigo excesivo, no aprende la estructura).

4) ¿Por qué encaja en IA generativa?

Un autoencoder aprende la estructura generativa de los datos (cómo reconstruir muestras plausibles), aunque no sea un generador explícito como un VAE o GAN. Por eso suele introducirse en submódulos de IA generativa como base para espacios latentes, compresión semántica y detección de outliers.


Modelos y dataset utilizados

  • Dataset: FashionMNIST (28×28, escala de grises, 10 categorías de prendas).
  • Modelo: autoencoder totalmente conectado (MLP) con cuello de botella latente de 32 dimensiones.
  • Framework principal: PyTorch.
  • Métricas: loss de reconstrucción, accuracy de detección por umbral, ROC-AUC, PR-AUC, matriz de confusión.
[1]
# Librerías base
import os
import random
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

from sklearn.metrics import (
    roc_auc_score,
    average_precision_score,
    confusion_matrix,
    classification_report,
    roc_curve,
    precision_recall_curve,
)

# Configuración estética para gráficas
sns.set(style="whitegrid", context="notebook")
plt.rcParams["figure.figsize"] = (8, 4)
[2]
# Reproducibilidad
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(SEED)

# Dispositivo de cómputo
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Dispositivo en uso: {device}")
Dispositivo en uso: cuda

1) Carga de datos y mini-EDA

Vamos a descargar FashionMNIST y revisar:

  • Distribución de clases (para verificar que el dataset está balanceado).
  • Ejemplos visuales (para entender la variabilidad de cada categoría).
  • Estadísticas básicas de píxeles (rango, media, desviación).

Este paso es clave para entender si el problema de anomalía está bien planteado: la clase normal (T-shirt/top) debe tener suficientes muestras y una estructura visual coherente. Además, nos permite anticipar qué categorías podrían ser más difíciles de distinguir como anomalías (p.ej. Shirt comparte silueta con T-shirt).

[3]
# Transformación a tensor en rango [0, 1]
transform = transforms.ToTensor()

train_raw = datasets.FashionMNIST(root="./data", train=True, download=True, transform=transform)
test_raw = datasets.FashionMNIST(root="./data", train=False, download=True, transform=transform)

class_names = train_raw.classes
print("Clases:", class_names)
print(f"Tamaño train: {len(train_raw)} | Tamaño test: {len(test_raw)}")
Clases: ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']
Tamaño train: 60000 | Tamaño test: 10000
[4]
# Distribución de clases en train
train_targets = torch.tensor(train_raw.targets)
counts = torch.bincount(train_targets)

plt.figure(figsize=(10, 4))
plt.bar(class_names, counts.numpy())
plt.xticks(rotation=45, ha="right")
plt.title("Distribución de clases - FashionMNIST (train)")
plt.ylabel("Número de muestras")
plt.show()

for i, c in enumerate(counts.tolist()):
    print(f"Clase {i} ({class_names[i]}): {c}")
/tmp/ipykernel_3501175/885426641.py:2: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.detach().clone() or sourceTensor.detach().clone().requires_grad_(True), rather than torch.tensor(sourceTensor).
  train_targets = torch.tensor(train_raw.targets)
Output
Clase 0 (T-shirt/top): 6000
Clase 1 (Trouser): 6000
Clase 2 (Pullover): 6000
Clase 3 (Dress): 6000
Clase 4 (Coat): 6000
Clase 5 (Sandal): 6000
Clase 6 (Shirt): 6000
Clase 7 (Sneaker): 6000
Clase 8 (Bag): 6000
Clase 9 (Ankle boot): 6000
[5]
# Visualización de ejemplos por clase
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
axes = axes.flatten()

for cls in range(10):
    idx = (train_targets == cls).nonzero(as_tuple=False)[0].item()
    img, label = train_raw[idx]
    axes[cls].imshow(img.squeeze(), cmap="gray")
    axes[cls].set_title(class_names[label])
    axes[cls].axis("off")

plt.suptitle("Un ejemplo por clase")
plt.tight_layout()
plt.show()
Output
[6]
# Estadísticas globales de intensidad de píxel
all_pixels = train_raw.data.float() / 255.0
print(f"Media global de píxel: {all_pixels.mean().item():.4f}")
print(f"Desviación estándar global: {all_pixels.std().item():.4f}")
print(f"Mínimo: {all_pixels.min().item():.4f} | Máximo: {all_pixels.max().item():.4f}")
Media global de píxel: 0.2860
Desviación estándar global: 0.3530
Mínimo: 0.0000 | Máximo: 1.0000

2) Planteamiento de anomalía y preparación de conjuntos

Definimos como normal la clase T-shirt/top (etiqueta 0). Entrenaremos solo con normales para que el autoencoder aprenda exclusivamente su estructura.

Para evaluar la detección necesitamos mezclas de normal/anómalo en validación y test:

  • $y=0$: normal (T-shirt/top)
  • $y=1$: anómalo (cualquier otra prenda)

Además creamos un conjunto de monitorización (subconjunto de train mixto) para calcular accuracy de detección durante el entrenamiento, sin usarlo para actualizar pesos.

[7]
# Parámetros del experimento
NORMAL_CLASS = 0
BATCH_SIZE = 128

# Convertimos imágenes a float [0,1] y luego a vector (784)
X_train = train_raw.data.float() / 255.0
y_train = train_targets
X_test = test_raw.data.float() / 255.0
y_test = torch.tensor(test_raw.targets)

X_train_flat = X_train.view(-1, 28 * 28)
X_test_flat = X_test.view(-1, 28 * 28)

# Índices por clase
train_normal_idx = (y_train == NORMAL_CLASS).nonzero(as_tuple=False).squeeze()
train_anom_idx = (y_train != NORMAL_CLASS).nonzero(as_tuple=False).squeeze()

# Split de normales train/val
n_norm = len(train_normal_idx)
perm = torch.randperm(n_norm)
train_norm_idx = train_normal_idx[perm[: int(0.8 * n_norm)]]
val_norm_idx = train_normal_idx[perm[int(0.8 * n_norm):]]

# Tomamos anomalías para val y para monitor train
n_val_norm = len(val_norm_idx)
perm_anom = train_anom_idx[torch.randperm(len(train_anom_idx))]
val_anom_idx = perm_anom[:n_val_norm]
monitor_anom_idx = perm_anom[n_val_norm:n_val_norm + 2000]
monitor_norm_idx = train_norm_idx[:2000]

# Tensores finales
X_train_norm = X_train_flat[train_norm_idx]
X_val_norm = X_train_flat[val_norm_idx]

X_val_mix = torch.cat([X_train_flat[val_norm_idx], X_train_flat[val_anom_idx]], dim=0)
y_val_mix = torch.cat([
    torch.zeros(len(val_norm_idx), dtype=torch.long),
    torch.ones(len(val_anom_idx), dtype=torch.long)
], dim=0)

X_monitor_mix = torch.cat([X_train_flat[monitor_norm_idx], X_train_flat[monitor_anom_idx]], dim=0)
y_monitor_mix = torch.cat([
    torch.zeros(len(monitor_norm_idx), dtype=torch.long),
    torch.ones(len(monitor_anom_idx), dtype=torch.long)
], dim=0)

# Test mixto completo
test_norm_idx = (y_test == NORMAL_CLASS).nonzero(as_tuple=False).squeeze()
test_anom_idx = (y_test != NORMAL_CLASS).nonzero(as_tuple=False).squeeze()

X_test_mix = torch.cat([X_test_flat[test_norm_idx], X_test_flat[test_anom_idx]], dim=0)
y_test_mix = torch.cat([
    torch.zeros(len(test_norm_idx), dtype=torch.long),
    torch.ones(len(test_anom_idx), dtype=torch.long)
], dim=0)

# DataLoaders
train_loader = DataLoader(TensorDataset(X_train_norm), batch_size=BATCH_SIZE, shuffle=True)
val_norm_loader = DataLoader(TensorDataset(X_val_norm), batch_size=BATCH_SIZE, shuffle=False)
val_mix_loader = DataLoader(TensorDataset(X_val_mix, y_val_mix), batch_size=BATCH_SIZE, shuffle=False)
monitor_loader = DataLoader(TensorDataset(X_monitor_mix, y_monitor_mix), batch_size=BATCH_SIZE, shuffle=False)
test_mix_loader = DataLoader(TensorDataset(X_test_mix, y_test_mix), batch_size=BATCH_SIZE, shuffle=False)

print(f"Train normal: {len(X_train_norm)}")
print(f"Val normal: {len(X_val_norm)}")
print(f"Val mixto (normal+anómalo): {len(X_val_mix)}")
print(f"Monitor train mixto: {len(X_monitor_mix)}")
print(f"Test mixto: {len(X_test_mix)}")
Train normal: 4800
Val normal: 1200
Val mixto (normal+anómalo): 2400
Monitor train mixto: 4000
Test mixto: 10000
/tmp/ipykernel_3501175/3383630414.py:9: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.detach().clone() or sourceTensor.detach().clone().requires_grad_(True), rather than torch.tensor(sourceTensor).
  y_test = torch.tensor(test_raw.targets)

3) Modelo: autoencoder MLP

Definimos un cuello de botella latente de 32 dimensiones para obligar al modelo a capturar únicamente la estructura esencial de los datos normales (T-shirt/top). La activación Sigmoid en la salida garantiza que la reconstrucción esté en $[0,1]$, coherente con la normalización de los datos de entrada.

[8]
class AutoencoderMLP(nn.Module):
    def __init__(self, input_dim=784, latent_dim=32):
        super().__init__()
        # Encoder: comprime de 784 a latent_dim
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, latent_dim),
        )
        # Decoder: reconstruye de latent_dim a 784
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim, 128),
            nn.ReLU(),
            nn.Linear(128, 256),
            nn.ReLU(),
            nn.Linear(256, input_dim),
            nn.Sigmoid(),
        )

    def forward(self, x):
        z = self.encoder(x)
        x_hat = self.decoder(z)
        return x_hat


model = AutoencoderMLP(input_dim=28*28, latent_dim=32).to(device)
print(model)
AutoencoderMLP(
  (encoder): Sequential(
    (0): Linear(in_features=784, out_features=256, bias=True)
    (1): ReLU()
    (2): Linear(in_features=256, out_features=128, bias=True)
    (3): ReLU()
    (4): Linear(in_features=128, out_features=32, bias=True)
  )
  (decoder): Sequential(
    (0): Linear(in_features=32, out_features=128, bias=True)
    (1): ReLU()
    (2): Linear(in_features=128, out_features=256, bias=True)
    (3): ReLU()
    (4): Linear(in_features=256, out_features=784, bias=True)
    (5): Sigmoid()
  )
)

4) Funciones de entrenamiento, castigos/recompensas y métricas

Aquí hacemos explícita la lógica de castigos/recompensas:

  • Castigo por reconstrucción mala: MSE entre entrada y salida.
  • Castigo por complejidad: regularización L2 (weight decay en el optimizador Adam).
  • Recompensa implícita: minimizar la pérdida total $\mathcal{L}{total} = \mathcal{L}{rec} + \lambda \mathcal{L}_{reg}$.

También calcularemos una accuracy de detección basada en un umbral sobre el error de reconstrucción por muestra: si el error supera el percentil 95 de los errores en normales de entrenamiento, la muestra se clasifica como anomalía.

[9]
# Hiperparámetros
EPOCHS = 20
LR = 1e-3
WEIGHT_DECAY_L2 = 1e-5  # castigo por complejidad

criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY_L2)


def reconstruction_errors(model, loader):
    """Devuelve errores de reconstrucción por muestra para un loader."""
    model.eval()
    errs, labels = [], []
    with torch.no_grad():
        for batch in loader:
            # Soporta loaders con (X,) o (X,y)
            if len(batch) == 1:
                x = batch[0].to(device)
                y = None
            else:
                x, y = batch
                x, y = x.to(device), y.to(device)

            x_hat = model(x)
            # Error medio por muestra (promedio sobre pixeles)
            e = ((x - x_hat) ** 2).mean(dim=1)
            errs.append(e.cpu())
            if y is not None:
                labels.append(y.cpu())

    errs = torch.cat(errs).numpy()
    labels = torch.cat(labels).numpy() if labels else None
    return errs, labels


def detection_accuracy(errors, labels, threshold):
    preds = (errors > threshold).astype(int)
    return (preds == labels).mean()
[10]
history = {
    'train_loss': [],
    'val_loss': [],
    'train_det_acc': [],
    'val_det_acc': [],
    'threshold': [],
}

for epoch in range(1, EPOCHS + 1):
    # ===== Entrenamiento =====
    model.train()
    train_losses = []

    for (x_batch,) in train_loader:
        x_batch = x_batch.to(device)

        optimizer.zero_grad()
        x_hat = model(x_batch)
        loss = criterion(x_hat, x_batch)
        loss.backward()
        optimizer.step()

        train_losses.append(loss.item())

    mean_train_loss = float(np.mean(train_losses))

    # ===== Validación (solo normales para loss) =====
    model.eval()
    val_losses = []
    with torch.no_grad():
        for (x_val,) in val_norm_loader:
            x_val = x_val.to(device)
            x_val_hat = model(x_val)
            vloss = criterion(x_val_hat, x_val)
            val_losses.append(vloss.item())

    mean_val_loss = float(np.mean(val_losses))

    # ===== Umbral + accuracy de detección =====
    # Umbral a partir del percentil 95 de error en datos normales de entrenamiento
    train_norm_errors, _ = reconstruction_errors(model, train_loader)
    threshold = np.percentile(train_norm_errors, 95)

    monitor_errors, monitor_labels = reconstruction_errors(model, monitor_loader)
    val_errors, val_labels = reconstruction_errors(model, val_mix_loader)

    train_acc = detection_accuracy(monitor_errors, monitor_labels, threshold)
    val_acc = detection_accuracy(val_errors, val_labels, threshold)

    # Guardamos histórico
    history['train_loss'].append(mean_train_loss)
    history['val_loss'].append(mean_val_loss)
    history['train_det_acc'].append(train_acc)
    history['val_det_acc'].append(val_acc)
    history['threshold'].append(threshold)

    print(
        f"Epoch {epoch:02d}/{EPOCHS} | "
        f"train_loss={mean_train_loss:.5f} | val_loss={mean_val_loss:.5f} | "
        f"train_acc={train_acc:.4f} | val_acc={val_acc:.4f} | "
        f"threshold={threshold:.5f}"
    )
Epoch 01/20 | train_loss=0.07632 | val_loss=0.04909 | train_acc=0.7522 | val_acc=0.7538 | threshold=0.10573
Epoch 02/20 | train_loss=0.04655 | val_loss=0.04180 | train_acc=0.7642 | val_acc=0.7575 | threshold=0.08894
Epoch 03/20 | train_loss=0.03561 | val_loss=0.03070 | train_acc=0.7913 | val_acc=0.7796 | threshold=0.06730
Epoch 04/20 | train_loss=0.02827 | val_loss=0.02708 | train_acc=0.7890 | val_acc=0.7717 | threshold=0.06117
Epoch 05/20 | train_loss=0.02560 | val_loss=0.02551 | train_acc=0.7695 | val_acc=0.7500 | threshold=0.05860
Epoch 06/20 | train_loss=0.02457 | val_loss=0.02453 | train_acc=0.7830 | val_acc=0.7617 | threshold=0.05629
Epoch 07/20 | train_loss=0.02370 | val_loss=0.02320 | train_acc=0.7790 | val_acc=0.7567 | threshold=0.05328
Epoch 08/20 | train_loss=0.02259 | val_loss=0.02138 | train_acc=0.7720 | val_acc=0.7562 | threshold=0.04965
Epoch 09/20 | train_loss=0.02038 | val_loss=0.02046 | train_acc=0.7650 | val_acc=0.7496 | threshold=0.04780
Epoch 10/20 | train_loss=0.01961 | val_loss=0.01974 | train_acc=0.7660 | val_acc=0.7538 | threshold=0.04640
Epoch 11/20 | train_loss=0.01929 | val_loss=0.01950 | train_acc=0.7635 | val_acc=0.7475 | threshold=0.04571
Epoch 12/20 | train_loss=0.01905 | val_loss=0.01937 | train_acc=0.7572 | val_acc=0.7392 | threshold=0.04541
Epoch 13/20 | train_loss=0.01898 | val_loss=0.01924 | train_acc=0.7585 | val_acc=0.7417 | threshold=0.04531
Epoch 14/20 | train_loss=0.01888 | val_loss=0.01908 | train_acc=0.7610 | val_acc=0.7442 | threshold=0.04429
Epoch 15/20 | train_loss=0.01870 | val_loss=0.01955 | train_acc=0.7568 | val_acc=0.7438 | threshold=0.04496
Epoch 16/20 | train_loss=0.01865 | val_loss=0.01873 | train_acc=0.7648 | val_acc=0.7479 | threshold=0.04349
Epoch 17/20 | train_loss=0.01849 | val_loss=0.01859 | train_acc=0.7590 | val_acc=0.7429 | threshold=0.04344
Epoch 18/20 | train_loss=0.01832 | val_loss=0.01847 | train_acc=0.7605 | val_acc=0.7417 | threshold=0.04305
Epoch 19/20 | train_loss=0.01816 | val_loss=0.01865 | train_acc=0.7612 | val_acc=0.7400 | threshold=0.04268
Epoch 20/20 | train_loss=0.01809 | val_loss=0.01863 | train_acc=0.7595 | val_acc=0.7408 | threshold=0.04289
[11]
# Curvas de entrenamiento: loss y accuracy de detección
epochs = np.arange(1, EPOCHS + 1)

fig, axes = plt.subplots(1, 2, figsize=(14, 4))

axes[0].plot(epochs, history['train_loss'], marker='o', label='Train loss')
axes[0].plot(epochs, history['val_loss'], marker='o', label='Val loss (normales)')
axes[0].set_title('Loss de reconstrucción vs época')
axes[0].set_xlabel('Época')
axes[0].set_ylabel('MSE')
axes[0].legend()

axes[1].plot(epochs, history['train_det_acc'], marker='o', label='Train detection accuracy (monitor)')
axes[1].plot(epochs, history['val_det_acc'], marker='o', label='Val detection accuracy')
axes[1].set_title('Accuracy de detección vs época')
axes[1].set_xlabel('Época')
axes[1].set_ylabel('Accuracy')
axes[1].set_ylim(0.0, 1.0)
axes[1].legend()

plt.tight_layout()
plt.show()
Output

5) Evaluación final en test

Usaremos el último umbral aprendido durante entrenamiento (percentil 95 del error en normales) para clasificar anomalías en el test mixto (1.000 normales + 9.000 anomalías).

Además, calcularemos métricas independientes del umbral (ROC-AUC y PR-AUC) para evaluar la capacidad de ranking del modelo: ¿asigna consistentemente errores más altos a las anomalías que a los normales?

[12]
# Errores y etiquetas en test
test_errors, test_labels = reconstruction_errors(model, test_mix_loader)

# Umbral final
final_threshold = history['threshold'][-1]
test_preds = (test_errors > final_threshold).astype(int)

# Métricas
test_acc = (test_preds == test_labels).mean()
roc_auc = roc_auc_score(test_labels, test_errors)
pr_auc = average_precision_score(test_labels, test_errors)

print(f"Threshold final: {final_threshold:.5f}")
print(f"Test accuracy: {test_acc:.4f}")
print(f"ROC-AUC: {roc_auc:.4f}")
print(f"PR-AUC: {pr_auc:.4f}")
Threshold final: 0.04289
Test accuracy: 0.5923
ROC-AUC: 0.8977
PR-AUC: 0.9853
[13]
# Curvas ROC y Precision-Recall
fpr, tpr, _ = roc_curve(test_labels, test_errors)
precision, recall, _ = precision_recall_curve(test_labels, test_errors)

fig, axes = plt.subplots(1, 2, figsize=(14, 4))

axes[0].plot(fpr, tpr, label=f'ROC-AUC = {roc_auc:.3f}')
axes[0].plot([0, 1], [0, 1], '--', color='gray')
axes[0].set_title('Curva ROC')
axes[0].set_xlabel('False Positive Rate')
axes[0].set_ylabel('True Positive Rate')
axes[0].legend()

axes[1].plot(recall, precision, label=f'PR-AUC = {pr_auc:.3f}')
axes[1].set_title('Curva Precision-Recall')
axes[1].set_xlabel('Recall')
axes[1].set_ylabel('Precision')
axes[1].legend()

plt.tight_layout()
plt.show()
Output
[14]
# Matriz de confusión + informe
cm = confusion_matrix(test_labels, test_preds)

plt.figure(figsize=(5, 4))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=['Pred normal', 'Pred anómalo'],
            yticklabels=['Real normal', 'Real anómalo'])
plt.title('Matriz de confusión - Test')
plt.show()

print(classification_report(test_labels, test_preds, target_names=['normal', 'anomalia']))
Output
              precision    recall  f1-score   support

      normal       0.19      0.95      0.32      1000
    anomalia       0.99      0.55      0.71      9000

    accuracy                           0.59     10000
   macro avg       0.59      0.75      0.51     10000
weighted avg       0.91      0.59      0.67     10000

6) Demostración del agente entrenado

Ahora mostramos decisiones individuales del agente: imagen original, reconstrucción y diagnóstico (error + predicción).

Esto ayuda a interpretar el comportamiento del detector más allá de métricas agregadas: podemos observar que el autoencoder reconstruye fielmente las T-shirts (errores bajos → predicción "normal") pero produce reconstrucciones borrosas o distorsionadas para prendas diferentes (errores altos → predicción "anomalía"). También podemos identificar los falsos negativos: anomalías con silueta similar a T-shirt que el modelo reconstruye demasiado bien.

[15]
# Seleccionamos ejemplos aleatorios de test mixto para visualizar decisiones
n_examples = 12
indices = np.random.choice(len(X_test_mix), size=n_examples, replace=False)

x_samples = X_test_mix[indices].to(device)
y_samples = y_test_mix[indices].numpy()

model.eval()
with torch.no_grad():
    x_hat_samples = model(x_samples)

errs = ((x_samples - x_hat_samples) ** 2).mean(dim=1).cpu().numpy()
preds = (errs > final_threshold).astype(int)

fig, axes = plt.subplots(n_examples, 2, figsize=(6, 2.2 * n_examples))

for i in range(n_examples):
    original = x_samples[i].cpu().view(28, 28)
    recon = x_hat_samples[i].cpu().view(28, 28)

    axes[i, 0].imshow(original, cmap='gray')
    axes[i, 0].axis('off')
    axes[i, 0].set_title(f"Original | y={y_samples[i]}")

    axes[i, 1].imshow(recon, cmap='gray')
    axes[i, 1].axis('off')
    axes[i, 1].set_title(
        f"Recon | err={errs[i]:.4f} | pred={preds[i]}"
    )

plt.tight_layout()
plt.show()
Output
[16]
# Resumen textual de la demostración
for i in range(n_examples):
    real = 'anomalia' if y_samples[i] == 1 else 'normal'
    pred = 'anomalia' if preds[i] == 1 else 'normal'
    print(
        f"Ejemplo {i:02d} -> real: {real:9s} | pred: {pred:9s} | error={errs[i]:.5f}"
    )
Ejemplo 00 -> real: anomalia  | pred: normal    | error=0.02671
Ejemplo 01 -> real: anomalia  | pred: normal    | error=0.03854
Ejemplo 02 -> real: anomalia  | pred: anomalia  | error=0.07459
Ejemplo 03 -> real: anomalia  | pred: anomalia  | error=0.11117
Ejemplo 04 -> real: anomalia  | pred: normal    | error=0.02510
Ejemplo 05 -> real: anomalia  | pred: anomalia  | error=0.05054
Ejemplo 06 -> real: normal    | pred: normal    | error=0.01598
Ejemplo 07 -> real: anomalia  | pred: anomalia  | error=0.04945
Ejemplo 08 -> real: anomalia  | pred: anomalia  | error=0.05369
Ejemplo 09 -> real: normal    | pred: normal    | error=0.02133
Ejemplo 10 -> real: anomalia  | pred: anomalia  | error=0.10215
Ejemplo 11 -> real: anomalia  | pred: normal    | error=0.02022

7) Conclusiones y siguientes experimentos

Conclusiones

  • El autoencoder MLP aprendió la estructura de la clase normal (T-shirt/top) y produce errores de reconstrucción sistemáticamente mayores para la mayoría de anomalías, validando el principio de detección por error de reconstrucción.
  • El ROC-AUC de ~0.90 demuestra que el modelo tiene una buena capacidad de ranking: los scores de error separan correctamente normales de anomalías en la mayoría de los casos, independientemente del umbral elegido.
  • El PR-AUC de ~0.99 es muy alto, lo cual se explica en parte por el fuerte desbalance del test (9.000 anomalías vs 1.000 normales): incluso con recall moderado, la precision se mantiene alta dada la abundancia de positivos.
  • La accuracy global de ~59% refleja una limitación conocida: prendas visualmente similares a T-shirt/top (como Pullover, Shirt o Coat) producen errores de reconstrucción bajos y no superan el umbral, generando falsos negativos (~4.000 de 9.000 anomalías clasificadas como normales). Esto es esperable, ya que el autoencoder generaliza parcialmente a siluetas similares.
  • El recall para la clase normal es excelente (~95%), lo que significa que el modelo rara vez clasifica erróneamente un T-shirt como anomalía (solo ~54 de 1.000). El umbral por percentil 95 cumple bien su función de mantener baja la tasa de falsos positivos.

Qué se podría probar después

  1. Autoencoder convolucional (ConvAE) para explotar la estructura espacial 2D y mejorar la discriminación entre siluetas similares.
  2. VAE (Variational Autoencoder) para modelado probabilístico del espacio latente, donde la anomalía se puede detectar también por baja probabilidad bajo el prior.
  3. Selección de umbral más robusta: optimizar $\tau$ sobre la curva ROC de validación o utilizar un criterio de coste asimétrico (ponderar más los falsos negativos o los falsos positivos según el caso de uso).
  4. Clases normales alternativas (por ejemplo Sneaker) para estudiar cómo varía la dificultad de detección según la clase elegida como "normal".
  5. Aumentación de datos normales para mejorar la robustez del encoder ante variaciones de posición, escala o ruido.
  6. Análisis de castigos/recompensas avanzados: añadir términos como sparsity penalty (L1 en latentes) o contractive loss para mejorar la selectividad del espacio latente.

Mensaje pedagógico final: en detección de anomalías, la calidad no depende solo del modelo, sino del diseño de datos (qué es "normal"), los umbrales (cómo se calibra la frontera de decisión) y los criterios de evaluación (qué errores importan más). Este notebook te da una base sólida para iterar con rigor sobre cada uno de estos ejes.