mlp cnn mnist

De MLP a CNN. Redes Neuronales para el Reconocimiento de Dígitos MNIST

Construimos y comparamos cuatro arquitecturas de redes neuronales en PyTorch, visualizamos el rendimiento, exploramos la complejidad frente a la precisión y mostramos por qué las CNN sobresalen en la clasificación de imágenes.

Daniel Gustaw

Daniel Gustaw

14 min read

De MLP a CNN. Redes Neuronales para el Reconocimiento de Dígitos MNIST

Introducción

El conjunto de datos MNIST es un clásico estándar en visión por computadora, que consiste en 70,000 imágenes en escala de grises de dígitos escritos a mano (28×28 píxeles). Es lo suficientemente pequeño como para entrenar rápidamente, pero lo suficientemente complejo como para revelar diferencias en el rendimiento del modelo, perfecto para experimentos con redes neuronales.

Mientras que los Perceptrones Multicapa (MLPs) pueden clasificar técnicamente los datos de imagen, tratan los píxeles como vectores planos, ignorando patrones espaciales. Las Redes Neuronales Convolucionales (CNNs), por otro lado, están diseñadas para aprovechar las estructuras locales en las imágenes: bordes, curvas, texturas, lo que las hace mucho más efectivas para tareas visuales.

En esta publicación, comparo cuatro arquitecturas: un MLP simple, un TinyCNN mínimo, un CNN equilibrado y un StrongCNN más pesado. Veremos la precisión, el tiempo de entrenamiento y los recuentos de parámetros para entender las compensaciones.

Preparación del Conjunto de Datos

Como se mencionó anteriormente, estamos utilizando el conjunto de datos MNIST, convenientemente disponible a través de torchvision.datasets. Con solo unas pocas líneas de código, descargamos y cargamos los datos, aplicamos una transformación básica y los preparamos para el entrenamiento:

from torchvision import datasets, transforms

transform = transforms.ToTensor()

train_data = datasets.MNIST(
    root="./data", train=True, download=True, transform=transform
)
test_data = datasets.MNIST(
    root="./data", train=False, download=True, transform=transform
)

El único paso de preprocesamiento aquí es transforms.ToTensor(), que convierte cada imagen en un tensor de PyTorch y normaliza sus valores de píxel al rango [0.0, 1.0].

from torch.utils.data import DataLoader

BATCH_SIZE = 64

train_loader = DataLoader(train_data, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_data, batch_size=BATCH_SIZE)

Mezclar los datos de entrenamiento evita memorizar el orden de los dígitos. Para el conjunto de prueba, omitimos la mezcla pero aún utilizamos el agrupamiento para la eficiencia.

Podemos mostrar algunas imágenes de muestra para visualizar el conjunto de datos:

import matplotlib.pyplot as plt

images, labels = next(iter(train_loader))

plt.figure(figsize=(6, 6))
for i in range(9):
    plt.subplot(3, 3, i + 1)
    plt.imshow(images[i][0], cmap="gray")
    plt.title(f"Label: {labels[i].item()}")
    plt.axis("off")
plt.tight_layout()
plt.savefig("mnist_digits.svg")
plt.show()

Entrenamiento y Evaluación

Ahora que nuestros datos están listos, es hora de enseñar a nuestros modelos cómo leer dígitos manuscritos. Para hacer esto, definimos un bucle de entrenamiento y evaluación estándar utilizando la estructura idiomática de PyTorch. También seguiremos la complejidad del modelo utilizando un simple contador de parámetros, útil al comparar diferentes arquitecturas.

Configuración del Dispositivo y Épocas

Primero, detectamos si hay una GPU disponible. Si es así, el entrenamiento ocurrirá en CUDA; de lo contrario, recurrimos a la CPU. También establecemos una duración de entrenamiento razonable:

import torch

EPOCHS = 5
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

Cinco épocas pueden no parecer mucho, pero en MNIST, a menudo es suficiente para obtener resultados sorprendentemente buenos, incluso con modelos básicos.

Bucle de Entrenamiento

Aquí está nuestra función train(). Es tan estándar como se puede: establece el modelo en modo de entrenamiento, recorre los lotes, calcula la pérdida y actualiza los pesos.

def train(model, loader, optimizer, criterion):
    model.train()
    for x, y in loader:
        x, y = x.to(DEVICE), y.to(DEVICE)
        optimizer.zero_grad()
        output = model(x)
        loss = criterion(output, y)
        loss.backward()
        optimizer.step()

Esta función no devuelve nada; solo actualiza los parámetros internos del modelo. Durante el entrenamiento, no nos importa la precisión todavía. Lo comprobamos más tarde.

Bucle de Evaluación

Después del entrenamiento, evaluamos en el conjunto de prueba. El modelo se establece en modo eval(), se desactivan los gradientes y recogemos dos métricas: precisión y pérdida media de entropía cruzada.

import torch.nn.functional as F

def test(model, loader):
    model.eval()
    correct = 0
    total = 0
    total_loss = 0.0
    with torch.no_grad():
        for x, y in loader:
            x, y = x.to(DEVICE), y.to(DEVICE)
            output = model(x)
            loss = F.cross_entropy(output, y)
            total_loss += loss.item()
            preds = output.argmax(dim=1)
            correct += (preds == y).sum().item()
            total += y.size(0)
    avg_loss = total_loss / len(loader)  # average over batches
    return correct / total, avg_loss

Tenga en cuenta que tomamos la pérdida media sobre lotes, no ejemplos individuales. Es un buen equilibrio entre el seguimiento del rendimiento y la simplicidad.

Conteo de Parámetros

Antes de comparar arquitecturas, es útil saber cuántos parámetros entrenables tiene cada una. Esta pequeña utilidad nos da el conteo:

def count_params(model):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)

Spoiler: el StrongCNN tiene más de 450,000 parámetros, mientras que TinyCNN se las arregla con solo unos pocos miles. Esa es una gran diferencia—y un gran punto de partida para un análisis más profundo.

Ejecutador de Experimentos

Finalmente, juntamos todo en una sola función que entrena un modelo, cronometra el proceso, evalúa en el conjunto de prueba y imprime un breve resumen:

import time
import torch.optim as optim
import torch.nn as nn

def run_experiment(model_class, name):
    model = model_class().to(DEVICE)
    optimizer = optim.Adam(model.parameters())
    criterion = nn.CrossEntropyLoss()

    print(f"\n{name} ({count_params(model)} parameters)")
    start = time.time()
    for epoch in range(EPOCHS):
        train(model, train_loader, optimizer, criterion)
    duration = time.time() - start

    acc, loss = test(model, test_loader)
    print(f"Test Accuracy: {acc * 100:.2f}% | Loss: {loss:.2f} | Learning time: {duration:.1f}s")

Esta estructura es lo suficientemente flexible como para trabajar con cualquier clase de modelo que pases, desde MLPs simples hasta bestias de convolución profundas.

En la siguiente sección, definiremos y analizaremos las cuatro arquitecturas: MLP, TinyCNN, CNN y StrongCNN.

Modelo 1: Perceptrón Multicapa (MLP)

La arquitectura más simple que consideramos es el clásico Perceptrón Multicapa (MLP). Trata cada imagen de 28×28 como un vector plano de 784 píxeles, ignorando la estructura espacial pero aún capaz de aprender características útiles a través de capas completamente conectadas.

import torch.nn as nn

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        h = 32  # number of hidden units
        self.model = nn.Sequential(
            nn.Flatten(),          # Flatten 28x28 image into a vector of length 784
            nn.Linear(28 * 28, h), # Fully connected layer: 784 → 32
            nn.ReLU(),             # Non-linear activation
            nn.Linear(h, 10)       # Output layer: 32 → 10 classes
        )

    def forward(self, x):
        return self.model(x)

Explicación

Este pequeño MLP tiene relativamente pocos parámetros y entrena rápidamente, pero no captura las relaciones espaciales entre los píxeles, limitando su precisión en los datos de imagen.

run_experiment(MLP, "MLP")

deberías ver:

MLP (25450 parameters)
Test Accuracy: 95.96% | Loss: 0.14 | Learning time: 8.7s

Será nuestro punto de referencia para comparar modelos cnn.

Modelo 2: TinyCNN — Una Red Neuronal Convolucional Mínima

A continuación, presentamos una simple arquitectura TinyCNN que aprovecha las capas convolucionales para capturar patrones espaciales en imágenes. Este modelo es ligero pero mucho más poderoso que el MLP para tareas de imagen.

La figura a continuación ilustra la arquitectura TinyCNN:

import torch.nn as nn

class TinyCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 4, kernel_size=3, padding=1),   # 1x28x28 → 4x28x28
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 4x14x14
            nn.Conv2d(4, 8, kernel_size=3, padding=1),   # 8x14x14
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 8x7x7
            nn.Flatten(),
            nn.Linear(8 * 7 * 7, 10)                     # Direct to output layer
        )

    def forward(self, x):
        return self.net(x)

Descripción General de la Arquitectura

======================================================================
Layer (type:depth-idx)                   Output Shape          Param #
======================================================================
TinyCNN                                  [64, 10]                  --
├─Sequential: 1-1                        [64, 10]                  --
│    └─Conv2d: 2-1                       [64, 4, 28, 28]           40
│    └─ReLU: 2-2                         [64, 4, 28, 28]           --
│    └─MaxPool2d: 2-3                    [64, 4, 14, 14]           --
│    └─Conv2d: 2-4                       [64, 8, 14, 14]           296
│    └─ReLU: 2-5                         [64, 8, 14, 14]           --
│    └─MaxPool2d: 2-6                    [64, 8, 7, 7]             --
│    └─Flatten: 2-7                      [64, 392]                 --
│    └─Linear: 2-8                       [64, 10]                3,930
======================================================================
Total params: 4,266
Trainable params: 4,266
Non-trainable params: 0
Total mult-adds (Units.MEGABYTES): 5.97
======================================================================
Input size (MB): 0.20
Forward/backward pass size (MB): 2.41
Params size (MB): 0.02
Estimated Total Size (MB): 2.63
======================================================================

A veces, cnn se presentan gráficamente como el siguiente flujo de trabajo:

Lo que es más interesante es que estamos superando los resultados de mlp con solo 4266 parámetros en lugar de 25450.

Tiny CNN (4266 parameters)
Test Accuracy: 97.96% | Loss: 0.06 | Learning time: 12.3s

Con una red mucho más pequeña, podemos esperar la mitad de errores en comparación con el modelo anterior.

Verifiquemos cómo mejoraría nuestra red si mantuviéramos una cantidad similar de parámetros al MLP original.

Modelo 3: CNN — Una Red Neuronal Convolucional Balanceada

Ahora que hemos visto lo que un modelo convolucional mínimo puede hacer, escalemos un poco las cosas.

El modelo CNN a continuación está diseñado para mantener un equilibrio adecuado entre la cantidad de parámetros y el rendimiento. Expande las capacidades de extracción de características del TinyCNN utilizando más filtros y una capa lineal oculta antes de la salida final.

class CNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 8, kernel_size=3, padding=1),   # 1x28x28 → 8x28x28
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 8x14x14
            nn.Conv2d(8, 16, kernel_size=3, padding=1),  # 16x14x14
            nn.ReLU(),
            nn.MaxPool2d(2),                             # 16x7x7
            nn.Flatten(),
            nn.Linear(16 * 7 * 7, 32),                   # Dense layer with 32 units
            nn.ReLU(),
            nn.Linear(32, 10)                            # Final output layer
        )

    def forward(self, x):
        return self.net(x)

Desglose de Arquitectura

En comparación con TinyCNN, este modelo:

En la tabla a continuación se encuentran todas las capas, formas de salida y parámetros sin la dimensión del lote:

CapaForma de SalidaParámetros
Conv2d (1→8, 3×3)8×28×2880
ReLU8×28×280
MaxPool2d8×14×140
Conv2d (8→16, 3×3)16×14×141,168
ReLU16×14×140
MaxPool2d16×7×70
Flatten7840
Linear (784 → 32)3225,120
ReLU320
Linear (32 → 10)10330
Total26,698

Con 26,698 parámetros, este CNN tiene un tamaño similar al de la MLP (25,450) pero significativamente más potente.

CNN (26698 parameters)
Test Accuracy: 98.22% | Loss: 0.05 | Learning time: 14.3s

Observaciones Clave

Este modelo demuestra un punto óptimo: buena profundidad, tamaño de parámetros razonable y excelente precisión. Pero, ¿y si no nos importara el tamaño en absoluto y quisiéramos llevar el rendimiento aún más lejos?

Veamos en la siguiente sección.

Modelo 4: StrongCNN — Una Potencia Convolucional Profunda

Hasta ahora, hemos examinado modelos que equilibran rendimiento y simplicidad. Pero, ¿qué pasaría si eliminamos las restricciones y nos enfocamos completamente en el rendimiento?

El StrongCNN es una arquitectura más profunda y expresiva que incorpora múltiples capas convolucionales, mayor recuento de canales y técnicas de regularización como Dropout para prevenir el sobreajuste. Se inspira en las mejores prácticas de modelos de visión más grandes, pero aún es lo suficientemente compacto como para entrenarse rápidamente en MNIST.

class StrongCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(1, 32, 3, padding=1),   # 1x28x28 → 32x28x28
            nn.ReLU(),
            nn.Conv2d(32, 32, 3, padding=1),  # 32x28x28
            nn.ReLU(),
            nn.MaxPool2d(2),                  # 32x14x14
            nn.Dropout(0.25),

            nn.Conv2d(32, 64, 3, padding=1),  # 64x14x14
            nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1),  # 64x14x14
            nn.ReLU(),
            nn.MaxPool2d(2),                  # 64x7x7
            nn.Dropout(0.25),

            nn.Flatten(),
            nn.Linear(64 * 7 * 7, 128),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(128, 10)
        )

    def forward(self, x):
        return self.net(x)

Desglose de Arquitectura

Este modelo apila cuatro capas convolucionales en dos bloques, con un aumento en el conteo de filtros (32 → 64). Después de cada bloque:

======================================================================
Layer (type:depth-idx)                   Output Shape          Param #
======================================================================
StrongCNN                                [64, 10]                  --
├─Sequential: 1-1                        [64, 10]                  --
│    └─Conv2d: 2-1                       [64, 32, 28, 28]          320
│    └─ReLU: 2-2                         [64, 32, 28, 28]          --
│    └─Conv2d: 2-3                       [64, 32, 28, 28]        9,248
│    └─ReLU: 2-4                         [64, 32, 28, 28]          --
│    └─MaxPool2d: 2-5                    [64, 32, 14, 14]          --
│    └─Dropout: 2-6                      [64, 32, 14, 14]          --
│    └─Conv2d: 2-7                       [64, 64, 14, 14]       18,496
│    └─ReLU: 2-8                         [64, 64, 14, 14]          --
│    └─Conv2d: 2-9                       [64, 64, 14, 14]       36,928
│    └─ReLU: 2-10                        [64, 64, 14, 14]          --
│    └─MaxPool2d: 2-11                   [64, 64, 7, 7]            --
│    └─Dropout: 2-12                     [64, 64, 7, 7]            --
│    └─Flatten: 2-13                     [64, 3136]                --
│    └─Linear: 2-14                      [64, 128]             401,536
│    └─ReLU: 2-15                        [64, 128]                 --
│    └─Dropout: 2-16                     [64, 128]                 --
│    └─Linear: 2-17                      [64, 10]                1,290
======================================================================
Total params: 467,818
Trainable params: 467,818
Non-trainable params: 0
Total mult-adds (Units.GIGABYTES): 1.20
======================================================================
Input size (MB): 0.20
Forward/backward pass size (MB): 38.61
Params size (MB): 1.87
Estimated Total Size (MB): 40.68
======================================================================

Con casi medio millón de parámetros, este modelo eclipsa a los demás en capacidad. Pero vale la pena.

Strong CNN (467818 parameters)
Test Accuracy: 99.09% | Loss: 0.03 | Learning time: 75.0s

Observaciones Clave

Este modelo es excesivo para MNIST, pero ese es el punto. Ilustra hasta dónde puedes llegar cuando la precisión es el único objetivo.

Resumen: Comparando los Cuatro Modelos

Terminemos con un resumen lado a lado:

ModeloParámetrosPrecisión de PruebaPérdidaTiempo de Entrenamiento
MLP25,45095.96%0.148.7s
TinyCNN4,26697.96%0.0612.3s
CNN26,69898.22%0.0514.3s
StrongCNN467,81899.09%0.0375.0s

Conclusión

Este experimento demuestra cómo las elecciones de arquitectura afectan el rendimiento en redes neuronales. Incluso para un conjunto de datos simple como MNIST:

Los modelos convolucionales superan a los MLPs no porque sean “más profundos” o “más sofisticados”, sino porque entienden cómo funcionan las imágenes.

Estos resultados reflejan tendencias más amplias observadas en la investigación de vanguardia:

Nuestros experimentos reafirman que los CNNs no solo son más precisos que los MLPs, sino que también son más eficientes y están mejor adaptados a tareas basadas en imágenes. Mientras que los modelos SOTA continúan empujando los límites, nuestros modelos prácticos ya logran alta precisión con una fracción de la complejidad.

https://github.com/gustawdaniel/cnn-mnist

Other articles

You can find interesting also.