Autoencoder para Clasificación No Supervisada en FashionMNIST
Pipeline completo de autoencoder en PyTorch para aprender representaciones sin etiquetas y realizar clasificación no supervisada con K-Means sobre FashionMNIST.
Autoencoders en PyTorch para clasificación no supervisada con FashionMNIST
En este notebook construiremos un pipeline completo y didáctico para usar un autoencoder como motor de aprendizaje de representaciones y, sobre ese espacio latente, realizar clasificación no supervisada (agrupación + interpretación de clusters) sobre FashionMNIST.
Objetivo de aprendizaje
Al terminar, deberías poder:
- Entender por qué un autoencoder puede aprender una representación útil sin etiquetas.
- Entrenar un autoencoder en PyTorch con un esquema robusto de train/validación.
- Aplicar un algoritmo de clustering (K-Means) sobre el espacio latente.
- Evaluar la calidad del agrupamiento con métricas no supervisadas (Silhouette) y métricas externas (NMI, ARI, Cluster-Accuracy).
- Interpretar la idea de castigos y recompensas en entrenamiento:
- Castigos: términos de la función de pérdida que penalizan comportamientos no deseados.
- Recompensas: reducción de pérdida y mejora de métricas que indican comportamientos deseados.
- Ver una demostración final del "agente" entrenado (el sistema autoencoder + clustering) sobre ejemplos nuevos.
Modelos y dataset utilizados
- Dataset: FashionMNIST (28×28 en escala de grises, 10 categorías de prendas: camisetas, pantalones, vestidos, etc.).
- Modelo principal: Autoencoder fully-connected (encoder + decoder) con espacio latente de 32 dimensiones.
- Algoritmo de clasificación no supervisada: K-Means sobre vectores latentes.
- Framework: PyTorch.
Nota didáctica: entrenamos sin usar etiquetas en la optimización del autoencoder. Las etiquetas se usan únicamente para EDA y para medir qué tan bien se alinean los clusters con clases reales (análisis externo, no entrenamiento).
Fundamento matemático/computacional
Dado un dato de entrada $x \in \mathbb{R}^{784}$ (imagen aplanada), un autoencoder aprende dos funciones complementarias:
- Encoder: $z = f_\theta(x)$, con $z \in \mathbb{R}^{d}$, $d \ll 784$ — comprime la imagen a un vector de baja dimensión.
- Decoder: $\hat{x} = g_\phi(z)$ — reconstruye la imagen a partir de la representación comprimida.
El objetivo básico es minimizar el error de reconstrucción (MSE):
$$ \mathcal{L}{rec} = \frac{1}{N}\sum{i=1}^{N} |x_i - \hat{x}_i|_2^2 $$
Castigos y recompensas en este notebook
Añadimos una penalización de esparsidad (norma L1) sobre el espacio latente, que fuerza al modelo a usar solo las dimensiones latentes realmente necesarias:
$$ \mathcal{L}{sparse} = \frac{1}{N}\sum{i=1}^{N} |z_i|_1 $$
La pérdida total que se minimiza es:
$$ \mathcal{L}{total} = \mathcal{L}{rec} + \lambda_{sparse},\mathcal{L}_{sparse} $$
- $\mathcal{L}_{rec}$ actúa como castigo si la reconstrucción es mala — obliga al encoder a preservar información relevante.
- $\mathcal{L}_{sparse}$ castiga representaciones latentes demasiado "densas" — favorece códigos con pocas activaciones distintas de cero, lo que facilita la separación por clustering.
- La recompensa (en sentido práctico de optimización) es que el modelo reduzca $\mathcal{L}_{total}$, logrando reconstrucciones más fieles y representaciones latentes más estructuradas.
Después del entrenamiento, aplicamos K-Means en el espacio $z$. Si el encoder ha aprendido una buena representación, prendas visualmente parecidas quedarán cercanas en el espacio latente y K-Means podrá separar grupos con significado semántico.
1) Importaciones y configuración reproducible
# Librerías principales
import random
import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, adjusted_rand_score, normalized_mutual_info_score, confusion_matrix
from scipy.optimize import linear_sum_assignment
# Configuración reproducible
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(SEED)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Dispositivo en uso: {device}')
print(f'PyTorch: {torch.__version__}')
Dispositivo en uso: cuda PyTorch: 2.10.0+cu128
2) Carga del dataset FashionMNIST
# Transformación: tensor + normalización simple a [0,1]
transform = transforms.Compose([
transforms.ToTensor(),
])
# Descargamos dataset
full_train = datasets.FashionMNIST(root='./data', train=True, download=True, transform=transform)
test_ds = datasets.FashionMNIST(root='./data', train=False, download=True, transform=transform)
class_names = full_train.classes
print('Clases:', class_names)
print(f'Tamaño train completo: {len(full_train)}')
print(f'Tamaño test: {len(test_ds)}')
Clases: ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot'] Tamaño train completo: 60000 Tamaño test: 10000
3) EDA rápido y visual
Aunque el entrenamiento sea no supervisado, conviene inspeccionar la distribución de clases y ejemplos visuales para entender la estructura del problema. Esto nos ayuda a anticipar qué categorías serán difíciles de separar (clases con siluetas similares como T-shirt, Shirt, Pullover y Coat) y a interpretar mejor los resultados del clustering posterior.
# Conteo de clases (solo para inspección)
labels = np.array(full_train.targets)
unique, counts = np.unique(labels, return_counts=True)
plt.figure(figsize=(10,4))
plt.bar([class_names[u] for u in unique], counts, color='steelblue')
plt.title('Distribución de clases en FashionMNIST (train)')
plt.xticks(rotation=45, ha='right')
plt.ylabel('Número de imágenes')
plt.grid(axis='y', alpha=0.3)
plt.show()
# Visualizamos ejemplos por clase para comprender variabilidad intraclase
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
axes = axes.ravel()
for c_idx in range(10):
idx = np.where(labels == c_idx)[0][0]
img, y = full_train[idx]
axes[c_idx].imshow(img.squeeze(), cmap='gray')
axes[c_idx].set_title(class_names[y])
axes[c_idx].axis('off')
plt.suptitle('Un ejemplo por clase')
plt.tight_layout()
plt.show()
4) Split train/validación y DataLoaders
No usamos test durante el entrenamiento. Creamos un conjunto de validación (20% de train) para monitorizar la generalización: tanto la calidad de reconstrucción como la estructura del espacio latente. Esto permite detectar sobreajuste y evaluar si las métricas de clustering mejoran con las épocas.
# Dividimos train en train/val
train_size = int(0.8 * len(full_train))
val_size = len(full_train) - train_size
train_ds, val_ds = random_split(full_train, [train_size, val_size], generator=torch.Generator().manual_seed(SEED))
batch_size = 256
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=2, pin_memory=True)
val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=2, pin_memory=True)
test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False, num_workers=2, pin_memory=True)
print(f'Train: {len(train_ds)} | Val: {len(val_ds)} | Test: {len(test_ds)}')
Train: 48000 | Val: 12000 | Test: 10000
5) Definición del autoencoder
Usamos una arquitectura densa (fully-connected) con un espacio latente de 32 dimensiones. El encoder comprime la imagen aplanada de 784 a 32 valores, y el decoder la reconstruye. La activación Sigmoid en la salida del decoder garantiza que los valores reconstruidos estén en $[0,1]$, igual que las imágenes normalizadas de entrada.
class Autoencoder(nn.Module):
def __init__(self, input_dim=784, latent_dim=32):
super().__init__()
# Encoder: comprime la imagen al espacio latente
self.encoder = nn.Sequential(
nn.Linear(input_dim, 256),
nn.ReLU(),
nn.Linear(256, 128),
nn.ReLU(),
nn.Linear(128, latent_dim)
)
# Decoder: reconstruye la imagen desde el latente
self.decoder = nn.Sequential(
nn.Linear(latent_dim, 128),
nn.ReLU(),
nn.Linear(128, 256),
nn.ReLU(),
nn.Linear(256, input_dim),
nn.Sigmoid() # salida en [0,1]
)
def forward(self, x):
x = x.view(x.size(0), -1)
z = self.encoder(x)
x_hat = self.decoder(z)
return x_hat, z
6) Funciones auxiliares de entrenamiento y evaluación
Aquí modelamos explícitamente la idea de castigos:
- Castigo de reconstrucción (MSE): penaliza reconstrucciones lejanas a la imagen original.
- Castigo de esparsidad (L1 en el código latente), controlado por $\lambda_{sparse}$: fuerza representaciones con pocas activaciones fuertes.
Además calculamos métricas de clustering (Cluster-Accuracy y NMI) por época usando K-Means sobre embeddings de train/val. Estas métricas no participan en la optimización, pero permiten monitorizar si el espacio latente adquiere estructura semántica durante el entrenamiento.
def clustering_accuracy(y_true, y_pred):
"""Calcula accuracy de clustering encontrando la mejor correspondencia cluster->clase (Hungarian)."""
cm = confusion_matrix(y_true, y_pred)
row_ind, col_ind = linear_sum_assignment(cm.max() - cm)
matched = cm[row_ind, col_ind].sum()
return matched / y_true.shape[0]
def extract_latents(model, loader, device):
"""Extrae latentes y etiquetas (etiquetas solo para evaluación externa)."""
model.eval()
all_z, all_y = [], []
with torch.no_grad():
for xb, yb in loader:
xb = xb.to(device)
_, zb = model(xb)
all_z.append(zb.cpu().numpy())
all_y.append(yb.numpy())
return np.concatenate(all_z), np.concatenate(all_y)
def evaluate_reconstruction(model, loader, criterion, lambda_sparse, device):
"""Evalúa pérdida total media en un DataLoader."""
model.eval()
total_loss = 0.0
n_samples = 0
with torch.no_grad():
for xb, _ in loader:
xb = xb.to(device)
x_hat, z = model(xb)
x_flat = xb.view(xb.size(0), -1)
rec_loss = criterion(x_hat, x_flat)
sparse_penalty = torch.mean(torch.abs(z))
loss = rec_loss + lambda_sparse * sparse_penalty
total_loss += loss.item() * xb.size(0)
n_samples += xb.size(0)
return total_loss / n_samples
7) Entrenamiento del autoencoder (no supervisado) + métricas por época
# Hiperparámetros principales
latent_dim = 32
lr = 1e-3
epochs = 20
lambda_sparse = 1e-3
model = Autoencoder(latent_dim=latent_dim).to(device)
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5)
history = {
'train_loss': [],
'val_loss': [],
'train_cluster_acc': [],
'val_cluster_acc': [],
'train_nmi': [],
'val_nmi': []
}
for epoch in range(1, epochs + 1):
model.train()
running_loss = 0.0
n_samples = 0
for xb, _ in train_loader:
xb = xb.to(device)
optimizer.zero_grad()
x_hat, z = model(xb)
x_flat = xb.view(xb.size(0), -1)
# Castigos: reconstrucción + esparsidad
rec_loss = criterion(x_hat, x_flat)
sparse_penalty = torch.mean(torch.abs(z))
loss = rec_loss + lambda_sparse * sparse_penalty
loss.backward()
optimizer.step()
running_loss += loss.item() * xb.size(0)
n_samples += xb.size(0)
train_loss = running_loss / n_samples
val_loss = evaluate_reconstruction(model, val_loader, criterion, lambda_sparse, device)
# Métricas de clustering (análisis externo)
z_train, y_train = extract_latents(model, train_loader, device)
z_val, y_val = extract_latents(model, val_loader, device)
kmeans = KMeans(n_clusters=10, random_state=SEED, n_init=20)
train_clusters = kmeans.fit_predict(z_train)
val_clusters = kmeans.predict(z_val)
train_acc = clustering_accuracy(y_train, train_clusters)
val_acc = clustering_accuracy(y_val, val_clusters)
train_nmi = normalized_mutual_info_score(y_train, train_clusters)
val_nmi = normalized_mutual_info_score(y_val, val_clusters)
history['train_loss'].append(train_loss)
history['val_loss'].append(val_loss)
history['train_cluster_acc'].append(train_acc)
history['val_cluster_acc'].append(val_acc)
history['train_nmi'].append(train_nmi)
history['val_nmi'].append(val_nmi)
print(
f"Epoch {epoch:02d}/{epochs} | "
f"Loss train={train_loss:.4f} val={val_loss:.4f} | "
f"C-Acc train={train_acc:.4f} val={val_acc:.4f} | "
f"NMI train={train_nmi:.4f} val={val_nmi:.4f}"
)
Epoch 01/20 | Loss train=0.0572 val=0.0329 | C-Acc train=0.4645 val=0.4595 | NMI train=0.4698 val=0.4654 Epoch 02/20 | Loss train=0.0271 val=0.0244 | C-Acc train=0.4783 val=0.4782 | NMI train=0.5134 val=0.5122 Epoch 03/20 | Loss train=0.0233 val=0.0226 | C-Acc train=0.5261 val=0.5292 | NMI train=0.5363 val=0.5366 Epoch 04/20 | Loss train=0.0218 val=0.0215 | C-Acc train=0.5300 val=0.5342 | NMI train=0.5419 val=0.5429 Epoch 05/20 | Loss train=0.0208 val=0.0205 | C-Acc train=0.5317 val=0.5323 | NMI train=0.5434 val=0.5420 Epoch 06/20 | Loss train=0.0199 val=0.0196 | C-Acc train=0.5288 val=0.5306 | NMI train=0.5456 val=0.5441 Epoch 07/20 | Loss train=0.0191 val=0.0189 | C-Acc train=0.5198 val=0.5159 | NMI train=0.5198 val=0.5180 Epoch 08/20 | Loss train=0.0186 val=0.0185 | C-Acc train=0.5257 val=0.5219 | NMI train=0.5244 val=0.5238 Epoch 09/20 | Loss train=0.0181 val=0.0181 | C-Acc train=0.5304 val=0.5278 | NMI train=0.5256 val=0.5267 Epoch 10/20 | Loss train=0.0177 val=0.0176 | C-Acc train=0.5350 val=0.5306 | NMI train=0.5251 val=0.5247 Epoch 11/20 | Loss train=0.0173 val=0.0173 | C-Acc train=0.5381 val=0.5329 | NMI train=0.5314 val=0.5287 Epoch 12/20 | Loss train=0.0170 val=0.0170 | C-Acc train=0.5384 val=0.5317 | NMI train=0.5317 val=0.5298 Epoch 13/20 | Loss train=0.0168 val=0.0168 | C-Acc train=0.5413 val=0.5334 | NMI train=0.5342 val=0.5323 Epoch 14/20 | Loss train=0.0165 val=0.0165 | C-Acc train=0.5436 val=0.5365 | NMI train=0.5354 val=0.5328 Epoch 15/20 | Loss train=0.0163 val=0.0164 | C-Acc train=0.5471 val=0.5410 | NMI train=0.5368 val=0.5341 Epoch 16/20 | Loss train=0.0161 val=0.0162 | C-Acc train=0.5461 val=0.5395 | NMI train=0.5377 val=0.5346 Epoch 17/20 | Loss train=0.0159 val=0.0160 | C-Acc train=0.5445 val=0.5373 | NMI train=0.5377 val=0.5350 Epoch 18/20 | Loss train=0.0158 val=0.0162 | C-Acc train=0.5466 val=0.5420 | NMI train=0.5381 val=0.5362 Epoch 19/20 | Loss train=0.0157 val=0.0158 | C-Acc train=0.5464 val=0.5398 | NMI train=0.5403 val=0.5368 Epoch 20/20 | Loss train=0.0155 val=0.0157 | C-Acc train=0.5481 val=0.5437 | NMI train=0.5399 val=0.5384
8) Curvas de entrenamiento: loss y "accuracy" de clustering
Las curvas de train/val permiten verificar que el entrenamiento converge sin sobreajuste. La pérdida total (reconstrucción + esparsidad) debe decrecer y estabilizarse. Las métricas de clustering (Cluster-Accuracy y NMI) se calculan en cada época como indicador externo de la calidad de la representación latente; no se usan para optimizar el modelo, sino para diagnosticar si el espacio latente está adquiriendo estructura semántica útil.
epochs_axis = np.arange(1, epochs + 1)
plt.figure(figsize=(14, 4))
plt.subplot(1, 3, 1)
plt.plot(epochs_axis, history['train_loss'], label='Train loss')
plt.plot(epochs_axis, history['val_loss'], label='Val loss')
plt.title('Pérdida total (recon + esparsidad)')
plt.xlabel('Época')
plt.ylabel('Loss')
plt.grid(alpha=0.3)
plt.legend()
plt.subplot(1, 3, 2)
plt.plot(epochs_axis, history['train_cluster_acc'], label='Train cluster-acc')
plt.plot(epochs_axis, history['val_cluster_acc'], label='Val cluster-acc')
plt.title('Accuracy de clustering (externa)')
plt.xlabel('Época')
plt.ylabel('Accuracy')
plt.grid(alpha=0.3)
plt.legend()
plt.subplot(1, 3, 3)
plt.plot(epochs_axis, history['train_nmi'], label='Train NMI')
plt.plot(epochs_axis, history['val_nmi'], label='Val NMI')
plt.title('NMI de clustering')
plt.xlabel('Época')
plt.ylabel('NMI')
plt.grid(alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()
9) Evaluación final en test (no supervisada + externa)
Evaluamos la calidad del espacio latente aprendido con métricas complementarias:
- Silhouette: métrica puramente no supervisada que mide compactación interna de cada cluster y separación respecto a los demás. Valores cercanos a 1 indican clusters bien definidos; cercanos a 0, solapamiento.
- ARI (Adjusted Rand Index): mide concordancia entre clusters y etiquetas reales, ajustada por azar. Rango: −0.5 a 1.0.
- NMI (Normalized Mutual Information): cuánta información comparten la asignación de clusters y las etiquetas reales. Rango: 0 a 1.
- Cluster-Accuracy: accuracy tras el mejor mapeo cluster→clase usando el algoritmo húngaro (Hungarian algorithm).
Importante: estas métricas externas usan etiquetas solo para diagnosticar la calidad del espacio latente, no para entrenar el modelo.
# Embeddings finales de train y test
z_train, y_train = extract_latents(model, train_loader, device)
z_test, y_test = extract_latents(model, test_loader, device)
# K-Means ajustado en train y aplicado en test
kmeans_final = KMeans(n_clusters=10, random_state=SEED, n_init=30)
train_clusters = kmeans_final.fit_predict(z_train)
test_clusters = kmeans_final.predict(z_test)
# Métricas
sil_train = silhouette_score(z_train, train_clusters)
sil_test = silhouette_score(z_test, test_clusters)
ari_test = adjusted_rand_score(y_test, test_clusters)
nmi_test = normalized_mutual_info_score(y_test, test_clusters)
acc_test = clustering_accuracy(y_test, test_clusters)
print('=== Métricas finales ===')
print(f'Silhouette train: {sil_train:.4f}')
print(f'Silhouette test : {sil_test:.4f}')
print(f'ARI test : {ari_test:.4f}')
print(f'NMI test : {nmi_test:.4f}')
print(f'Cluster-Acc test: {acc_test:.4f}')
=== Métricas finales === Silhouette train: 0.2028 Silhouette test : 0.2023 ARI test : 0.3618 NMI test : 0.5354 Cluster-Acc test: 0.5444
10) Visualización de reconstrucciones (calidad perceptual)
Una buena reconstrucción no garantiza clustering perfecto, pero suele indicar que el encoder capturó información útil. Si las reconstrucciones preservan la forma general y los detalles principales de cada prenda, el espacio latente contiene información semántica relevante para el clustering.
# Mostramos originales vs reconstrucciones
model.eval()
xb, yb = next(iter(test_loader))
xb = xb.to(device)
with torch.no_grad():
x_hat, _ = model(xb)
xb_np = xb.cpu().numpy()
xh_np = x_hat.view(-1, 1, 28, 28).cpu().numpy()
n = 8
fig, axes = plt.subplots(2, n, figsize=(2*n, 4))
for i in range(n):
axes[0, i].imshow(xb_np[i, 0], cmap='gray')
axes[0, i].set_title(f'Orig: {class_names[yb[i].item()]}', fontsize=9)
axes[0, i].axis('off')
axes[1, i].imshow(xh_np[i, 0], cmap='gray')
axes[1, i].set_title('Reconstrucción', fontsize=9)
axes[1, i].axis('off')
plt.suptitle('Fila superior: original | Fila inferior: reconstrucción')
plt.tight_layout()
plt.show()
11) Interpretación de clusters: mapa cluster → clase dominante
El mapa cluster→clase permite interpretar qué ha aprendido cada cluster. Es normal que algunas clases visualmente similares compartan cluster o que un cluster no tenga correspondencia unívoca. Esto revela las limitaciones y fortalezas de la representación latente aprendida.
# Mapa cluster -> clase dominante usando train
cluster_to_class = {}
for c_id in range(10):
idx = np.where(train_clusters == c_id)[0]
if len(idx) == 0:
cluster_to_class[c_id] = None
else:
majority = np.bincount(y_train[idx], minlength=10).argmax()
cluster_to_class[c_id] = majority
print('Mapa cluster -> clase dominante:')
for c_id, cls in cluster_to_class.items():
cls_name = class_names[cls] if cls is not None else 'Sin asignar'
print(f'Cluster {c_id:2d} -> {cls_name}')
Mapa cluster -> clase dominante: Cluster 0 -> T-shirt/top Cluster 1 -> Ankle boot Cluster 2 -> Trouser Cluster 3 -> Bag Cluster 4 -> Pullover Cluster 5 -> Bag Cluster 6 -> Shirt Cluster 7 -> Dress Cluster 8 -> Ankle boot Cluster 9 -> Sneaker
12) Demostración del agente entrenado
En este contexto, el "agente" es el sistema completo que ejecuta un pipeline de inferencia no supervisada:
- Recibe una imagen de una prenda.
- La codifica al espacio latente de 32 dimensiones mediante el encoder.
- K-Means asigna un cluster basándose en la proximidad del vector latente a los centroides aprendidos.
- Se interpreta el cluster con la clase dominante aprendida en train (mapeo por mayoría).
Mostramos predicciones sobre ejemplos aleatorios de test para ilustrar aciertos y errores típicos del sistema.
def agent_predict(images_tensor):
"""Devuelve cluster y clase interpretada para un batch de imágenes."""
model.eval()
with torch.no_grad():
_, z = model(images_tensor.to(device))
z_np = z.cpu().numpy()
clusters = kmeans_final.predict(z_np)
interpreted_class = [cluster_to_class[cid] for cid in clusters]
return clusters, interpreted_class
# Seleccionamos ejemplos aleatorios de test para demo
num_demo = 12
idx_demo = np.random.choice(len(test_ds), size=num_demo, replace=False)
imgs = torch.stack([test_ds[i][0] for i in idx_demo])
true_labels = [test_ds[i][1] for i in idx_demo]
pred_clusters, pred_classes = agent_predict(imgs)
cols = 6
rows = int(np.ceil(num_demo / cols))
fig, axes = plt.subplots(rows, cols, figsize=(3*cols, 3*rows))
axes = np.array(axes).reshape(rows, cols)
for i in range(rows * cols):
ax = axes[i // cols, i % cols]
if i < num_demo:
ax.imshow(imgs[i].squeeze(), cmap='gray')
pred_name = class_names[pred_classes[i]] if pred_classes[i] is not None else 'N/A'
true_name = class_names[true_labels[i]]
ax.set_title(f'True: {true_name}\nCluster: {pred_clusters[i]} | Pred*: {pred_name}', fontsize=9)
ax.axis('off')
plt.suptitle('Demostración del agente no supervisado (*predicción interpretada por clase dominante de cluster)')
plt.tight_layout()
plt.show()
13) Discusión: castigos y recompensas en detalle
Castigos (penalties)
- Error de reconstrucción (MSE): penaliza que la salida reconstruida se aleje de la entrada. Es el castigo principal que obliga al encoder a preservar la información visual relevante.
- Esparsidad latente (L1): penaliza activaciones excesivas en todos los ejes latentes, favoreciendo códigos más compactos e interpretables. Al forzar que muchas dimensiones estén próximas a cero, las dimensiones activas tienden a capturar patrones más diferenciados.
- Weight decay del optimizador (L2 sobre pesos): penaliza pesos demasiado grandes para mejorar estabilidad y prevenir sobreajuste.
Recompensas (señal de progreso)
No hay "recompensa" explícita como en RL, pero operativamente:
- Menor pérdida total = mejor cumplimiento del objetivo de compresión y reconstrucción.
- Mejores métricas de clustering (NMI, ARI, Cluster-Acc) = el espacio latente es más útil para separar patrones visuales con significado semántico.
- Mejor Silhouette = clusters más compactos internamente y más separados entre sí.
Interpretación de los resultados obtenidos
Los resultados muestran una Cluster-Accuracy en test cercana al 54% y un NMI de ~0.54, lo cual es un resultado razonable para un enfoque completamente no supervisado sobre FashionMNIST. Es importante tener en cuenta que:
- FashionMNIST contiene clases visualmente muy similares (p.ej. T-shirt/top, Pullover, Shirt y Coat comparten siluetas parecidas), lo que dificulta la separación en el espacio latente.
- El mapa cluster→clase muestra que algunos clusters se asignan a la misma clase dominante (por ejemplo Bag o Ankle boot aparecen duplicados), mientras que otras clases como Coat o Sandal no tienen un cluster dedicado. Esto refleja la ambigüedad visual entre ciertas categorías.
- El Silhouette score de ~0.20 indica solapamiento moderado entre clusters, coherente con la dificultad intrínseca del dataset.
Idea clave: en aprendizaje no supervisado, diseñamos castigos adecuados para que emerja una representación útil. Las métricas externas (NMI, ARI, Cluster-Acc) no se optimizan directamente, sino que actúan como evidencia de que los castigos de entrenamiento están incentivando el comportamiento deseado.
14) Conclusiones y siguientes experimentos
Conclusiones
- Un autoencoder fully-connected puede aprender una representación de FashionMNIST sin usar etiquetas, logrando una Cluster-Accuracy de ~54% y un NMI de ~0.54 sobre test. Esto confirma que el espacio latente captura estructura semántica relevante, aunque con limitaciones esperables dada la similitud visual entre varias categorías de prendas.
- El Silhouette score (~0.20) indica que los clusters tienen cierto solapamiento, coherente con la dificultad del dataset: categorías como T-shirt, Pullover, Shirt y Coat comparten siluetas similares y son difíciles de separar sin información supervisada.
- La penalización de esparsidad (L1) y el weight decay (L2) actúan como castigos complementarios que favorecen un espacio latente más estructurado y compacto, mejorando la calidad del clustering posterior.
- Las curvas de entrenamiento muestran convergencia estable de la pérdida y progresión coherente de las métricas de clustering, sin señales de sobreajuste significativo.
- El mapa cluster→clase revela que ciertas categorías visualmente ambiguas (como Bag y Ankle boot) atraen más de un cluster, mientras que otras (como Coat o Sandal) quedan subsumidas en clusters dominados por clases similares.
Sugerencias para seguir explorando
- Probar Convolutional Autoencoders (normalmente mejores para imágenes al explotar la estructura espacial 2D).
- Cambiar dimensión latente (8, 16, 64, 128) y comparar métricas de clustering para encontrar el punto óptimo.
- Sustituir K-Means por GMM (que permite clusters de diferente forma) o HDBSCAN (que no requiere fijar el número de clusters).
- Probar Denoising Autoencoders (añadir ruido a la entrada y reconstruir la imagen limpia) para mejorar la robustez de las representaciones.
- Evolucionar hacia Variational Autoencoders (VAE) para un espacio latente probabilístico y suave, con capacidad generativa.
- Visualizar latentes con UMAP o t-SNE para análisis cualitativo de la separación entre clases.