🏭 Caso de Uso

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.

🐍 Python 📓 Jupyter Notebook

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:

  1. Entender por qué un autoencoder puede aprender una representación útil sin etiquetas.
  2. Entrenar un autoencoder en PyTorch con un esquema robusto de train/validación.
  3. Aplicar un algoritmo de clustering (K-Means) sobre el espacio latente.
  4. Evaluar la calidad del agrupamiento con métricas no supervisadas (Silhouette) y métricas externas (NMI, ARI, Cluster-Accuracy).
  5. 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.
  6. 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

[1]
# 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

[2]
# 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.

[3]
# 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()
Output
[4]
# 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()
Output

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.

[5]
# 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.

[6]
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.

[7]
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

[8]
# 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.

[9]
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()
Output

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.

[10]
# 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.

[11]
# 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()
Output

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.

[12]
# 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:

  1. Recibe una imagen de una prenda.
  2. La codifica al espacio latente de 32 dimensiones mediante el encoder.
  3. K-Means asigna un cluster basándose en la proximidad del vector latente a los centroides aprendidos.
  4. 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.

[13]
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()
Output

13) Discusión: castigos y recompensas en detalle

Castigos (penalties)

  1. 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.
  2. 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.
  3. 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

  1. Probar Convolutional Autoencoders (normalmente mejores para imágenes al explotar la estructura espacial 2D).
  2. Cambiar dimensión latente (8, 16, 64, 128) y comparar métricas de clustering para encontrar el punto óptimo.
  3. Sustituir K-Means por GMM (que permite clusters de diferente forma) o HDBSCAN (que no requiere fijar el número de clusters).
  4. Probar Denoising Autoencoders (añadir ruido a la entrada y reconstruir la imagen limpia) para mejorar la robustez de las representaciones.
  5. Evolucionar hacia Variational Autoencoders (VAE) para un espacio latente probabilístico y suave, con capacidad generativa.
  6. Visualizar latentes con UMAP o t-SNE para análisis cualitativo de la separación entre clases.