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.
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:
- Un flujo completo reproducible (EDA → entrenamiento → selección de umbral → evaluación).
- Gráficas de entrenamiento (loss y accuracy de detección por época).
- Métricas de desempeño (ROC-AUC, PR-AUC, matriz de confusión, informe de clasificación).
- 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.
# 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)
# 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).
# 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
# 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)
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
# 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()
# 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.
# 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.
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.
# 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()
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
# 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()
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?
# 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
# 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()
# 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']))
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.
# 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()
# 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
- Autoencoder convolucional (ConvAE) para explotar la estructura espacial 2D y mejorar la discriminación entre siluetas similares.
- 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.
- 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).
- Clases normales alternativas (por ejemplo Sneaker) para estudiar cómo varía la dificultad de detección según la clase elegida como "normal".
- Aumentación de datos normales para mejorar la robustez del encoder ante variaciones de posición, escala o ruido.
- 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.