💻 Tutorial paso a paso

Primeros pasos con PyTorch

Construiremos una red neuronal desde cero con PyTorch: datos, modelo, training loop manual, guardado, carga y predicción. Cada línea de código está explicada.

⏱️ ~30 min 📊 Nivel: principiante 🔥 Framework: PyTorch 2.x

Requisitos previos

  • Python 3.8 o superior instalado
  • Conocimientos básicos de Python (clases, funciones, bucles)
  • Saber qué es una red neuronal (consulta la teoría del perceptrón y el MLP)
  • Opcional: haber completado el tutorial de TensorFlow (verás las diferencias)
1

Instalación e imports

Instala PyTorch desde la terminal. La página oficial (pytorch.org) tiene un selector para tu sistema operativo y GPU:

Terminal
# CPU only (funciona en cualquier máquina)
pip install torch torchvision

# Con CUDA 12.1 (si tienes GPU NVIDIA)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121

Ahora importamos las librerías:

Python train_model.py
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

print(f"PyTorch version: {torch.__version__}")
print(f"CUDA disponible: {torch.cuda.is_available()}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Usando device: {device}")
L1torch — el paquete principal de PyTorch. Tensores, autograd, y todo lo básico.
L2torch.nn — capas, modelos y funciones de pérdida. Es el equivalente a keras.layers.
L3torch.optim — optimizadores: SGD, Adam, etc.
L4DataLoader — carga datos en batches automáticamente, con shuffle y paralelismo.
L5torchvision — datasets de visión (MNIST, CIFAR, ImageNet) y transformaciones de imágenes.
L9Detectamos si hay GPU y creamos el device. Todo tensor y modelo se moverá a este device.
Salida esperada PyTorch version: 2.5.1 CUDA disponible: True Usando device: cuda
⚔️
vs TensorFlow: En TF, la GPU se detecta automáticamente. En PyTorch, tú decides explícitamente dónde vive cada tensor con .to(device). Más control, pero requiere pensar en ello.
2

Cargar y preparar los datos

Usaremos el mismo dataset MNIST que en el tutorial de TensorFlow: 70,000 imágenes de dígitos 0-9. En PyTorch, la carga de datos se hace con Dataset + DataLoader:

Python cargar datos
# Transformaciones: convertir a tensor + normalizar
transform = transforms.Compose([
    transforms.ToTensor(),           # PIL → tensor, escala a [0,1]
    transforms.Lambda(lambda x: x.view(-1)),  # (1,28,28) → (784,)
])

# Descargar y crear datasets
train_dataset = datasets.MNIST(root="./data", train=True,
                               download=True, transform=transform)
test_dataset  = datasets.MNIST(root="./data", train=False,
                               download=True, transform=transform)

# Crear DataLoaders
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader  = DataLoader(test_dataset, batch_size=256, shuffle=False)

print(f"Train: {len(train_dataset)} imágenes")
print(f"Test:  {len(test_dataset)} imágenes")
print(f"Batches de train: {len(train_loader)}")
L2-5transforms.Compose — encadena transformaciones. ToTensor() convierte la imagen a un tensor [0,1]. La lambda aplana de (1,28,28) a (784,).
L8-11datasets.MNIST — descarga el dataset si no existe. transform se aplica a cada imagen al cargarla.
L14DataLoader — itera sobre el dataset en batches de 32. shuffle=True baraja los datos en cada epoch (importante para el entrenamiento).
L15Para test usamos batch_size más grande (256) porque no necesitamos gradientes → menos memoria → más rápido.
Salida Train: 60000 imágenes Test: 10000 imágenes Batches de train: 1875
⚔️
vs TensorFlow: En TF hicimos keras.datasets.mnist.load_data() y manipulamos arrays NumPy. En PyTorch, el patrón es Dataset + DataLoader: más código, pero mucho más flexible para datasets personalizados, augmentation, etc.
3

Diseñar el modelo (MLP)

En PyTorch, los modelos se definen como clases que heredan de nn.Module. Necesitas definir dos cosas: las capas (en __init__) y cómo se conectan (en forward).

Python definir modelo
class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(784, 128),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(64, 10),
        )

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

# Crear instancia y mover a GPU/CPU
model = MLP().to(device)
print(model)
print(f"\nParámetros totales: {sum(p.numel() for p in model.parameters()):,}")
L1class MLP(nn.Module) — todo modelo en PyTorch hereda de nn.Module. Esto le da autograd, serialización, etc.
L3super().__init__() — obligatorio. Inicializa la maquinaria interna de nn.Module.
L4-12nn.Sequential — igual que en Keras: capas en orden secuencial. Nota: en PyTorch, ReLU y Dropout se ponen como capas separadas, no como parámetros.
L11nn.Linear(64, 10) — capa de salida. Sin softmax: en PyTorch, CrossEntropyLoss ya incluye el softmax internamente.
L14-15forward(x) — define cómo fluyen los datos. PyTorch lo llama automáticamente cuando haces model(x).
L18.to(device) — mueve todos los pesos del modelo a GPU (o CPU). Imprescindible hacerlo antes de entrenar.
Salida MLP( (model): Sequential( (0): Linear(in_features=784, out_features=128, bias=True) (1): ReLU() (2): Dropout(p=0.2, inplace=False) (3): Linear(in_features=128, out_features=64, bias=True) (4): ReLU() (5): Dropout(p=0.2, inplace=False) (6): Linear(in_features=64, out_features=10, bias=True) ) ) Parámetros totales: 109,386
💡 Nota: El mismo número de parámetros (109,386) que en TensorFlow. La arquitectura es idéntica: 784→128→64→10 con ReLU y Dropout. La única diferencia es que aquí no ponemos softmax en la última capa.

En PyTorch, nn.CrossEntropyLoss combina LogSoftmax + NLLLoss internamente. Si pusieras nn.Softmax() al final del modelo y luego usaras CrossEntropyLoss, estarías aplicando softmax dos veces, lo cual produce resultados incorrectos.

Regla en PyTorch:

  • La última capa devuelve logits (valores sin normalizar)
  • CrossEntropyLoss aplica softmax + loss internamente
  • Si necesitas probabilidades para predicción: probs = torch.softmax(logits, dim=1)

En TF/Keras es diferente: usas activation="softmax" + sparse_categorical_crossentropy.

4

Definir loss y optimizador

En PyTorch, la loss y el optimizador se crean como objetos separados. No hay un model.compile() que agrupe todo:

Python loss + optimizer
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
L1CrossEntropyLoss() — la pérdida estándar para clasificación multiclase. Incluye softmax internamente.
L2Adam(model.parameters(), lr=1e-3) — Adam con learning rate 0.001. model.parameters() le dice al optimizador qué pesos debe actualizar.
⚔️
vs TensorFlow: En TF esto era model.compile(optimizer="adam", loss="sparse_categorical_crossentropy"). En PyTorch, creas los objetos explícitamente. Más verbose, pero más control.
5

Escribir el training loop

Esta es la gran diferencia respecto a TensorFlow. En PyTorch, tú escribes el loop de entrenamiento completo. Esto te da total control, pero requiere más código:

Python training loop
def train_one_epoch(model, loader, criterion, optimizer, device):
    model.train()  # Activa Dropout y BatchNorm en modo entrenamiento
    total_loss = 0
    correct = 0
    total = 0

    for batch_x, batch_y in loader:
        batch_x, batch_y = batch_x.to(device), batch_y.to(device)

        # Forward pass
        outputs = model(batch_x)
        loss = criterion(outputs, batch_y)

        # Backward pass
        optimizer.zero_grad()   # Limpiar gradientes anteriores
        loss.backward()         # Calcular gradientes
        optimizer.step()        # Actualizar pesos

        # Estadísticas
        total_loss += loss.item() * batch_x.size(0)
        _, predicted = outputs.max(1)
        correct += predicted.eq(batch_y).sum().item()
        total += batch_y.size(0)

    return total_loss / total, correct / total


# Entrenar 10 epochs
EPOCHS = 10
for epoch in range(EPOCHS):
    train_loss, train_acc = train_one_epoch(
        model, train_loader, criterion, optimizer, device
    )
    print(f"Epoch {epoch+1:2d}/{EPOCHS} | "
          f"Loss: {train_loss:.4f} | Accuracy: {train_acc:.4f}")
L2model.train() — activa el modo entrenamiento. Dropout funciona, BatchNorm usa estadísticas del batch.
L7Iteramos por batches. DataLoader nos da tuplas (inputs, labels).
L8.to(device) — mueve cada batch a GPU. Imprescindible si el modelo está en GPU.
L15optimizer.zero_grad()crítico. PyTorch acumula gradientes por defecto. Si no los limpias, los gradientes del batch anterior se suman a los del actual.
L16loss.backward() — backpropagation. Calcula ∂loss/∂w para todos los parámetros.
L17optimizer.step() — actualiza los pesos: w ← w − lr × ∂loss/∂w.
Salida (ejemplo) Epoch 1/10 | Loss: 0.3512 | Accuracy: 0.8963 Epoch 2/10 | Loss: 0.1589 | Accuracy: 0.9524 Epoch 3/10 | Loss: 0.1178 | Accuracy: 0.9644 ... Epoch 10/10 | Loss: 0.0524 | Accuracy: 0.9838

Todo training loop en PyTorch sigue este patrón de 3 líneas mágicas:

  1. optimizer.zero_grad() — limpia los gradientes acumulados del paso anterior
  2. loss.backward() — calcula los gradientes de todos los parámetros mediante backpropagation
  3. optimizer.step() — actualiza los pesos usando los gradientes calculados

Si olvidas zero_grad(): los gradientes se acumulan y el entrenamiento diverge.

Si olvidas backward(): no hay gradientes → step() no hace nada.

Si olvidas step(): los gradientes se calculan pero los pesos nunca se actualizan.

En TensorFlow, todo esto lo hace model.fit() internamente. En PyTorch, tú lo controlas.

6

Evaluar el modelo

Para evaluar, usamos el test set. Es importante activar model.eval() y desactivar el cálculo de gradientes con torch.no_grad():

Python evaluar
def evaluate(model, loader, criterion, device):
    model.eval()  # Desactiva Dropout
    total_loss = 0
    correct = 0
    total = 0

    with torch.no_grad():  # No calcular gradientes → más rápido, menos memoria
        for batch_x, batch_y in loader:
            batch_x, batch_y = batch_x.to(device), batch_y.to(device)
            outputs = model(batch_x)
            loss = criterion(outputs, batch_y)

            total_loss += loss.item() * batch_x.size(0)
            _, predicted = outputs.max(1)
            correct += predicted.eq(batch_y).sum().item()
            total += batch_y.size(0)

    return total_loss / total, correct / total


test_loss, test_acc = evaluate(model, test_loader, criterion, device)
print(f"🎯 Test Loss: {test_loss:.4f}")
print(f"🎯 Test Accuracy: {test_acc:.4f}")
print(f"Acierta {test_acc*100:.1f}% de los dígitos que nunca ha visto")
L2model.eval() — modo evaluación. Dropout se desactiva, BatchNorm usa estadísticas globales.
L7torch.no_grad() — no rastrear operaciones para autograd. Ahorra memoria y es más rápido. Siempre usarlo en evaluación/inferencia.
Salida 🎯 Test Loss: 0.0701 🎯 Test Accuracy: 0.9790 Acierta 97.9% de los dígitos que nunca ha visto

97.9% de accuracy — prácticamente el mismo resultado que TensorFlow. Misma arquitectura, mismo dataset, misma calidad.

7

Guardar el modelo

PyTorch ofrece dos formas de guardar un modelo. La recomendada es guardar solo el state_dict (los pesos):

Python guardar
# Opción 1 (RECOMENDADA): guardar solo los pesos
torch.save(model.state_dict(), "mi_modelo_mnist.pth")
print("✅ Pesos guardados como mi_modelo_mnist.pth")

# Opción 2: guardar todo (modelo + pesos + optimizer)
checkpoint = {
    "model_state": model.state_dict(),
    "optimizer_state": optimizer.state_dict(),
    "epoch": EPOCHS,
    "test_acc": test_acc,
}
torch.save(checkpoint, "checkpoint_mnist.pth")
print("✅ Checkpoint completo guardado")
L2model.state_dict() — diccionario con todos los pesos y biases. Es lo mínimo para reconstruir el modelo.
L6-12Checkpoint: incluye estado del optimizer (momentum, etc.), epoch actual y métricas. Útil para reanudar el entrenamiento.
💡 Nota: La extensión .pth o .pt es la convención de PyTorch. Internamente usa pickle de Python, así que puedes guardar cualquier objeto serializable.
8

Cargar y usar el modelo guardado

Para cargar el modelo, primero necesitas crear la misma arquitectura y luego cargar los pesos:

Python cargar modelo (nuevo script)
# En un nuevo script o sesión:
import torch
import torch.nn as nn

# 1. Recrear la MISMA arquitectura
class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(784, 128), nn.ReLU(), nn.Dropout(0.2),
            nn.Linear(128, 64),  nn.ReLU(), nn.Dropout(0.2),
            nn.Linear(64, 10),
        )
    def forward(self, x):
        return self.model(x)

# 2. Crear instancia y cargar pesos
loaded_model = MLP()
loaded_model.load_state_dict(torch.load("mi_modelo_mnist.pth", weights_only=True))
loaded_model.eval()  # Modo evaluación
print("✅ Modelo cargado correctamente")
L6-15Debes definir la misma clase MLP. Los pesos no contienen la arquitectura, solo los valores numéricos.
L19load_state_dict() — carga los pesos en el modelo. weights_only=True es una buena práctica de seguridad (evita ejecutar código arbitrario con pickle).
L20.eval() — no olvides poner en modo evaluación para desactivar Dropout.
⚔️
vs TensorFlow: En TF, keras.models.load_model("archivo.keras") carga todo (arquitectura + pesos) en una línea. En PyTorch, necesitas definir la clase antes de cargar los pesos. Más manual, pero más transparente.
9

Hacer predicciones

Usamos el modelo cargado para clasificar dígitos nuevos:

Python predecir
# Tomar 5 imágenes del test set
test_images, test_labels = [], []
for img, label in test_dataset:
    test_images.append(img)
    test_labels.append(label)
    if len(test_images) == 5:
        break

# Stack en un batch (5, 784)
batch = torch.stack(test_images)

# Predecir
with torch.no_grad():
    logits = loaded_model(batch)
    probs  = torch.softmax(logits, dim=1)
    preds  = probs.argmax(dim=1)

# Mostrar resultados
for i in range(5):
    confidence = probs[i][preds[i]].item() * 100
    real = test_labels[i]
    status = "✅" if preds[i].item() == real else "❌"
    print(f"{status} Imagen {i}: predicho={preds[i].item()} "
          f"(confianza: {confidence:.1f}%) | real={real}")
L10torch.stack() — une una lista de tensores en un batch. De 5 tensores (784,) a un tensor (5, 784).
L15torch.softmax(logits, dim=1) — convierte logits en probabilidades. dim=1 aplica softmax sobre las 10 clases (no sobre el batch).
L16.argmax(dim=1) — índice de la probabilidad más alta = dígito predicho.
Salida ✅ Imagen 0: predicho=7 (confianza: 99.8%) | real=7 ✅ Imagen 1: predicho=2 (confianza: 99.7%) | real=2 ✅ Imagen 2: predicho=1 (confianza: 99.6%) | real=1 ✅ Imagen 3: predicho=0 (confianza: 99.9%) | real=0 ✅ Imagen 4: predicho=4 (confianza: 99.3%) | real=4
10

Script completo

Aquí tienes el script completo: imports → datos → modelo → training loop → evaluación → guardado → carga → predicción. Cópialo y ejecútalo directamente.

📄 Script: mnist_pytorch.py

Python mnist_pytorch.py
"""
Primeros pasos con PyTorch: MLP para clasificar dígitos MNIST.
"""
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

# ── Config ──────────────────────────────────────────────
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
EPOCHS = 10
BATCH_SIZE = 32
LR = 1e-3

# ── 1. Datos ────────────────────────────────────────────
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Lambda(lambda x: x.view(-1)),
])
train_dataset = datasets.MNIST("./data", train=True,  download=True, transform=transform)
test_dataset  = datasets.MNIST("./data", train=False, download=True, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
test_loader  = DataLoader(test_dataset,  batch_size=256, shuffle=False)

# ── 2. Modelo ───────────────────────────────────────────
class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(784, 128), nn.ReLU(), nn.Dropout(0.2),
            nn.Linear(128, 64),  nn.ReLU(), nn.Dropout(0.2),
            nn.Linear(64, 10),
        )
    def forward(self, x):
        return self.model(x)

model = MLP().to(device)

# ── 3. Loss + Optimizer ────────────────────────────────
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=LR)

# ── 4. Entrenamiento ───────────────────────────────────
for epoch in range(EPOCHS):
    model.train()
    total_loss, correct, total = 0, 0, 0
    for bx, by in train_loader:
        bx, by = bx.to(device), by.to(device)
        out = model(bx)
        loss = criterion(out, by)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * bx.size(0)
        correct += out.argmax(1).eq(by).sum().item()
        total += by.size(0)
    print(f"Epoch {epoch+1:2d}/{EPOCHS} | "
          f"Loss: {total_loss/total:.4f} | Acc: {correct/total:.4f}")

# ── 5. Evaluación ──────────────────────────────────────
model.eval()
test_correct, test_total = 0, 0
with torch.no_grad():
    for bx, by in test_loader:
        bx, by = bx.to(device), by.to(device)
        preds = model(bx).argmax(1)
        test_correct += preds.eq(by).sum().item()
        test_total += by.size(0)
print(f"\n🎯 Test Accuracy: {test_correct/test_total:.4f}")

# ── 6. Guardar ─────────────────────────────────────────
torch.save(model.state_dict(), "mi_modelo_mnist.pth")
print("💾 Modelo guardado")

# ── 7. Cargar ──────────────────────────────────────────
loaded = MLP().to(device)
loaded.load_state_dict(torch.load("mi_modelo_mnist.pth", weights_only=True))
loaded.eval()

# ── 8. Predecir ────────────────────────────────────────
samples = [test_dataset[i] for i in range(5)]
batch = torch.stack([s[0] for s in samples]).to(device)
labels = [s[1] for s in samples]
with torch.no_grad():
    probs = torch.softmax(loaded(batch), dim=1)
    preds = probs.argmax(1)
for i in range(5):
    conf = probs[i][preds[i]].item() * 100
    print(f"{'✅' if preds[i].item() == labels[i] else '❌'} "
          f"Predicho: {preds[i].item()} ({conf:.1f}%) | Real: {labels[i]}")