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.
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)
Instalación e imports
Instala PyTorch desde la terminal. La página oficial (pytorch.org) tiene un selector para tu sistema operativo y GPU:
# 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:
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}")
torch — el paquete principal de PyTorch. Tensores, autograd, y todo lo básico.torch.nn — capas, modelos y funciones de pérdida. Es el equivalente a keras.layers.torch.optim — optimizadores: SGD, Adam, etc.DataLoader — carga datos en batches automáticamente, con shuffle y paralelismo.torchvision — datasets de visión (MNIST, CIFAR, ImageNet) y transformaciones de imágenes.device. Todo tensor y modelo se moverá a este device..to(device).
Más control, pero requiere pensar en ello.
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:
# 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)}")
transforms.Compose — encadena transformaciones. ToTensor() convierte la imagen a un tensor [0,1]. La lambda aplana de (1,28,28) a (784,).datasets.MNIST — descarga el dataset si no existe. transform se aplica a cada imagen al cargarla.DataLoader — itera sobre el dataset en batches de 32. shuffle=True baraja los datos en cada epoch (importante para el entrenamiento).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.
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).
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()):,}")
class MLP(nn.Module) — todo modelo en PyTorch hereda de nn.Module. Esto le da autograd, serialización, etc.super().__init__() — obligatorio. Inicializa la maquinaria interna de nn.Module.nn.Sequential — igual que en Keras: capas en orden secuencial. Nota: en PyTorch, ReLU y Dropout se ponen como capas separadas, no como parámetros.nn.Linear(64, 10) — capa de salida. Sin softmax: en PyTorch, CrossEntropyLoss ya incluye el softmax internamente.forward(x) — define cómo fluyen los datos. PyTorch lo llama automáticamente cuando haces model(x)..to(device) — mueve todos los pesos del modelo a GPU (o CPU). Imprescindible hacerlo antes de entrenar.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)
CrossEntropyLossaplica 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.
Definir loss y optimizador
En PyTorch, la loss y el optimizador se crean como objetos separados.
No hay un model.compile() que agrupe todo:
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
CrossEntropyLoss() — la pérdida estándar para clasificación multiclase. Incluye softmax internamente.Adam(model.parameters(), lr=1e-3) — Adam con learning rate 0.001. model.parameters() le dice al optimizador qué pesos debe actualizar.model.compile(optimizer="adam", loss="sparse_categorical_crossentropy").
En PyTorch, creas los objetos explícitamente. Más verbose, pero más control.
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:
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}")
model.train() — activa el modo entrenamiento. Dropout funciona, BatchNorm usa estadísticas del batch.DataLoader nos da tuplas (inputs, labels)..to(device) — mueve cada batch a GPU. Imprescindible si el modelo está en GPU.optimizer.zero_grad() — crítico. PyTorch acumula gradientes por defecto. Si no los limpias, los gradientes del batch anterior se suman a los del actual.loss.backward() — backpropagation. Calcula ∂loss/∂w para todos los parámetros.optimizer.step() — actualiza los pesos: w ← w − lr × ∂loss/∂w.Todo training loop en PyTorch sigue este patrón de 3 líneas mágicas:
optimizer.zero_grad()— limpia los gradientes acumulados del paso anteriorloss.backward()— calcula los gradientes de todos los parámetros mediante backpropagationoptimizer.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.
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():
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")
model.eval() — modo evaluación. Dropout se desactiva, BatchNorm usa estadísticas globales.torch.no_grad() — no rastrear operaciones para autograd. Ahorra memoria y es más rápido. Siempre usarlo en evaluación/inferencia.97.9% de accuracy — prácticamente el mismo resultado que TensorFlow. Misma arquitectura, mismo dataset, misma calidad.
Guardar el modelo
PyTorch ofrece dos formas de guardar un modelo. La recomendada es guardar solo
el state_dict (los pesos):
# 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")
model.state_dict() — diccionario con todos los pesos y biases. Es lo mínimo para reconstruir el modelo..pth o .pt es la convención
de PyTorch. Internamente usa pickle de Python, así que puedes guardar cualquier
objeto serializable.
Cargar y usar el modelo guardado
Para cargar el modelo, primero necesitas crear la misma arquitectura y luego cargar los pesos:
# 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")
MLP. Los pesos no contienen la arquitectura, solo los valores numéricos.load_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)..eval() — no olvides poner en modo evaluación para desactivar Dropout.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.
Hacer predicciones
Usamos el modelo cargado para clasificar dígitos nuevos:
# 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}")
torch.stack() — une una lista de tensores en un batch. De 5 tensores (784,) a un tensor (5, 784).torch.softmax(logits, dim=1) — convierte logits en probabilidades. dim=1 aplica softmax sobre las 10 clases (no sobre el batch)..argmax(dim=1) — índice de la probabilidad más alta = dígito predicho.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
"""
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]}")