Redes neuronales con PyTorch
Tensores, gradientes automáticos y un perceptrón multicapa entrenado de principio a fin
PyTorch convierte una red neuronal en un programa: tensores que guardan datos, autograd que calcula derivadas y un bucle que repite forward, pérdida y actualización. Entrenarás un perceptrón multicapa completo, entenderás qué hace cada línea y sabrás leer las formas de cada capa.
- Crear tensores y distinguir forma, tipo y dispositivo
- Explicar cómo autograd obtiene gradientes con `backward()` y por qué hay que ponerlos a cero
- Construir un perceptrón multicapa con `nn.Sequential` y elegir pérdida y optimizador
- Escribir el bucle de entrenamiento con epochs, batches y evaluación sin gradientes
- Detectar sobreajuste y trasladar el modelo a GPU cuando esté disponible
Antes necesitas: Validación y evaluación de modelos
En esta lección
- ¿Qué es PyTorch?
- ¿Para qué sirve?
- ¿Cómo funciona por dentro?
- Sintaxis: instalar y crear tensores
- Ejemplo básico: un tensor y una operación
- Ejemplo intermedio: autograd a mano
- Caso práctico: un perceptrón multicapa completo
- Visualización: cómo cambia un lote al atravesar una capa
- Visualización: qué hace backward()
- PyTorch frente a TensorFlow
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Una red neuronal no es magia: es una función con muchos parámetros que se ajustan por descenso de gradiente. Lo difícil es calcular esas derivadas a mano cuando la función tiene cientos de miles de operaciones. PyTorch resuelve justo esa parte: registra lo que haces con los tensores y te devuelve los gradientes sin que derives nada.
En esta lección pasarás de un tensor suelto a un modelo entrenado. El objetivo no es memorizar la API, sino entender el ciclo que se repite en todo proyecto de aprendizaje profundo: preparar datos, predecir, medir el error, calcular gradientes y actualizar pesos.
¿Qué es PyTorch?
Es una biblioteca de Python para cálculo numérico con tensores (arrays multidimensionales) que puede ejecutarse en CPU o GPU y que incorpora autograd, un motor de diferenciación automática. Incluye torch.nn para construir capas y funciones de pérdida, y torch.optim con los optimizadores habituales. Todo el código que escribas es Python normal: bucles, condicionales y depuración con print.
Un tensor se parece a un array de NumPy, pero añade dos cosas: puede vivir en la GPU y puede recordar las operaciones para derivarlas. Esa memoria de operaciones es lo que se llama grafo computacional, y es la razón por la que PyTorch puede entrenar modelos sin que tú escribas una sola derivada.
¿Para qué sirve?
- Clasificación y regresión con redes densas, convolucionales o recurrentes.
- Visión artificial: detección de objetos, segmentación, clasificación de imágenes.
- Procesamiento de lenguaje: el sustrato sobre el que se entrenan los Transformers de la siguiente lección.
- Investigación: probar arquitecturas nuevas sin salir de Python.
- Prototipos rápidos: pasar de una idea a un modelo entrenado en una tarde.
- Producción: exportar a TorchScript u ONNX y servir el modelo desde una API.
¿Cómo funciona por dentro?
Cada tensor guarda datos, forma (shape), tipo (dtype) y dispositivo (device). Una capa nn.Linear(4, 5) contiene una matriz de pesos de forma (5, 4) y un sesgo de forma (5,); al aplicarla a un lote de forma (3, 4) produce (3, 5). Las dimensiones deben encajar: la última del lote tiene que coincidir con la segunda de los pesos.
Con requires_grad=True, cada operación añade un nodo al grafo. Al llamar a loss.backward(), PyTorch recorre ese grafo al revés y aplica la regla de la cadena, dejando el resultado en tensor.grad. Los gradientes se acumulan: si no los pones a cero antes de cada paso, el cálculo mezcla el lote actual con los anteriores.
El entrenamiento repite siempre la misma secuencia: forward para obtener predicciones, cálculo de la pérdida, zero_grad, backward y step del optimizador. Una época recorre todo el conjunto; un batch es el trozo que se procesa en cada paso. Con 800 ejemplos y batch_size=64 hay 13 batches por época, y el último puede ser más pequeño.
Sintaxis: instalar y crear tensores
PyTorch no viene con Python. Instálalo en un entorno virtual; la rueda por defecto ya incluye lo necesario para CPU. Para CUDA necesitas la versión y el índice que correspondan a tu driver, según la tabla oficial de instalación.
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install torch scikit-learn
python -c "import torch; print(torch.__version__)"import torch
torch.manual_seed(0) # misma semilla, mismos valores
a = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) # infiere dtype float32
b = torch.zeros(2, 3)
c = torch.arange(6).reshape(2, 3) # enteros
print(a.shape, a.dtype, a.device)
print(c.shape, c.dtype)
print(a.T) # transpuesta
print((a @ a).shape) # producto matricial
print(torch.rand(2, 2)) # reproducible por la semillaa.shape es torch.Size([2, 2]), a.dtype es torch.float32 y a.device es cpu. torch.arange(6) produce enteros de 64 bits (int64), que es el tipo que esperan las etiquetas de clasificación. @ es el producto matricial; * sería producto elemento a elemento. Si mezclas float32 con float64, PyTorch lanza un error de tipos.
Ejemplo básico: un tensor y una operación
Una capa lineal es una multiplicación de matrices más un sesgo. Con tres muestras de cuatro características y una capa de cuatro entradas y cinco salidas, la forma del resultado es (3, 5): una fila por muestra y una columna por neurona.
import torch
from torch import nn
torch.manual_seed(0)
x = torch.randn(3, 4) # 3 muestras, 4 características
capa = nn.Linear(4, 5) # 20 pesos + 5 sesgos
z = capa(x)
print(x.shape, "->", z.shape) # (3, 4) -> (3, 5)
print(capa.weight.shape, capa.bias.shape) # (5, 4) y (5,)
print(sum(p.numel() for p in capa.parameters())) # 25La capa guarda 25 parámetros: 20 pesos y 5 sesgos. capa(x) equivale a x @ capa.weight.T + capa.bias, y por eso la segunda dimensión de weight coincide con la última de x. Si pasaras un lote de forma (3, 5) a esta capa, obtendrías RuntimeError: mat1 and mat2 shapes cannot be multiplied.
Ejemplo intermedio: autograd a mano
Antes de usar un modelo, conviene ver el mecanismo desnudo. Ajustamos w y b para que w·x + b se acerque a y, con error cuadrático medio. No hay optimizador: solo gradientes, para que compruebes que coinciden con la derivada que harías en papel.
import torch
w = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(1.0, requires_grad=True)
x = torch.tensor([1.0, 2.0, 3.0])
y = torch.tensor([3.0, 5.0, 8.0])
pred = w * x + b # tensor([3., 5., 7.])
loss = ((pred - y) ** 2).mean()
loss.backward()
print(w.grad, b.grad) # tensor(-2.) tensor(-0.6667)
tasa = 0.1
with torch.no_grad():
w -= tasa * w.grad
b -= tasa * b.grad
print(round(w.item(), 4), round(b.item(), 4)) # 2.2 1.0667La pérdida es (0² + 0² + (−1)²) / 3 = 1/3 ≈ 0.3333. Su derivada respecto a w es (2/3)·Σ(pred−y)·x = (2/3)·(0 + 0 − 3) = −2, y respecto a b, (2/3)·(−1) ≈ −0.667. El signo negativo indica que aumentar w reduce la pérdida: por eso el paso resta. torch.no_grad() evita que la actualización se registre en el grafo.
Caso práctico: un perceptrón multicapa completo
El problema de las dos lunas es el ejemplo clásico de datos no separables por una recta. Una red con dos capas ocultas de 16 neuronas lo resuelve. Usamos 1 000 puntos con ruido, 80 % para entrenar y 20 % para validar, CrossEntropyLoss y Adam.
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
torch.manual_seed(42)
# 1. Datos: 1000 puntos, dos clases, ruido moderado
X, y = make_moons(n_samples=1000, noise=0.25, random_state=42)
X_tr, X_va, y_tr, y_va = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
X_tr = torch.tensor(X_tr, dtype=torch.float32)
y_tr = torch.tensor(y_tr, dtype=torch.long)
X_va = torch.tensor(X_va, dtype=torch.float32)
y_va = torch.tensor(y_va, dtype=torch.long)
# 2. Modelo 2 -> 16 -> 16 -> 2, pérdida y optimizador
modelo = nn.Sequential(
nn.Linear(2, 16), nn.ReLU(),
nn.Linear(16, 16), nn.ReLU(),
nn.Linear(16, 2),
)
criterio = nn.CrossEntropyLoss()
optimizador = torch.optim.Adam(modelo.parameters(), lr=0.01)
cargador = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64, shuffle=True)
# 3. Entrenamiento y evaluación al final de cada época
for epoca in range(50):
modelo.train()
for xb, yb in cargador:
optimizador.zero_grad()
perdida = criterio(modelo(xb), yb)
perdida.backward()
optimizador.step()
modelo.eval()
with torch.no_grad():
aciertos = (modelo(X_va).argmax(dim=1) == y_va).float().mean()
if epoca % 10 == 9:
print(f"época {epoca + 1:3d} · val acc {aciertos.item():.3f}")CrossEntropyLoss espera logits (salidas crudas de la última capa) y etiquetas enteras, no one-hot ni probabilidades; por eso y se convierte con dtype=torch.long. argmax(dim=1) recupera la clase predicha. En este problema la exactitud de validación debería superar 0.95: si se queda cerca de 0.85, revisa el learning rate y el ruido.
Mover el modelo a GPU es independiente de la arquitectura: dispositivo = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu", luego modelo.to(dispositivo) y, dentro del bucle, xb = xb.to(dispositivo) y yb = yb.to(dispositivo). Si olvidas mover los datos, el error de dispositivo aparece en el primer batch.
Visualización: cómo cambia un lote al atravesar una capa
El siguiente lote tiene tres muestras y cuatro características. Una capa de 4 entradas y 5 salidas calcula z = x·Wᵀ + b, con W de forma (5, 4) y b de forma (5,). El primer paso muestra z; el segundo, el resultado tras aplicar ReLU.
z = x·Wᵀ + b para una muestra y una neurona. La forma se conserva: (3, 5).Recorre los pasos para ver cómo cambian las celdas.
import torch
import torch.nn.functional as F
x = torch.tensor([
[1.0, 0.0, -1.0, 2.0],
[0.5, 1.5, 0.0, -0.5],
[0.0, -1.0, 2.0, 1.0],
])
W = torch.tensor([
[0.5, -0.3, 0.8, 0.1],
[-0.2, 0.6, 0.4, -0.5],
[0.3, 0.3, -0.6, 0.2],
[0.7, -0.1, 0.0, 0.4],
[-0.4, 0.2, 0.5, 0.6],
])
b = torch.tensor([0.1, -0.2, 0.0, 0.3, 0.05])
z = F.linear(x, W, b) # (3, 5)
print(z.round(decimals=2))
print(F.relu(z).round(decimals=2))Los valores se reproducen con x de forma (3, 4), W de forma (5, 4) y b de forma (5,). Ejecuta el cálculo y compara con la matriz: el primer valor es 0 y no negativo porque 0.5·1 + 0·0 + 0.8·(−1) + 0.1·2 + 0.1 = 0. Una neurona apagada no está rota; su combinación no aporta para esa muestra.
Visualización: qué hace backward()
Este es el mismo cálculo de la sección anterior, paso a paso. Observa el momento exacto en que aparecen w.grad y b.grad: antes de backward() no existen, y después contienen la derivada de la pérdida respecto a cada parámetro.
import torchw = torch.tensor(2.0, requires_grad=True)b = torch.tensor(1.0, requires_grad=True)x = torch.tensor([1.0, 2.0, 3.0])y = torch.tensor([3.0, 5.0, 8.0])pred = w * x + bloss = ((pred - y) ** 2).mean()loss.backward()print(w.grad, b.grad)w como escalar con requires_grad=True: PyTorch anotará todas las operaciones en las que participe.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Si llamaras a backward() dos veces sin poner los gradientes a cero, el segundo resultado se sumaría al primero y obtendrías -4 y -1.3333. Esa acumulación es intencionada: permite sumar gradientes de varios batches, pero en un bucle de entrenamiento normal se convierte en un error silencioso.
PyTorch frente a TensorFlow
PyTorch y TensorFlow resuelven el mismo problema con filosofías distintas. En PyTorch el grafo se construye mientras se ejecuta el código (define-by-run), así que puedes depurar con print y condiciones normales de Python. TensorFlow apostó históricamente por un grafo estático y hoy ofrece Keras como interfaz de alto nivel, más declarativa: describes capas, compilas el modelo y llamas a fit.
import tensorflow as tf
modelo = tf.keras.Sequential([
tf.keras.layers.Input(shape=(2,)),
tf.keras.layers.Dense(8, activation="relu"),
tf.keras.layers.Dense(1),
])
modelo.compile(optimizer="adam", loss="mse")
modelo.fit(X, y, epochs=20, batch_size=4, verbose=0)
predicciones = modelo.predict(X)El código de Keras es más corto porque el bucle de entrenamiento está dentro de fit; en PyTorch lo escribes tú y por eso ves con claridad dónde se calcula la pérdida y dónde se actualizan los pesos. Para aprender, esa explicitud vale mucho. En producción, ambos ecosistemas exportan a formatos intercambiables (ONNX) y tienen herramientas de servicio propias: TorchServe/TorchScript en PyTorch, TF Serving/TFLite en TensorFlow.
Errores comunes
| Mensaje o síntoma | Causa | Solución |
|---|---|---|
mat1 and mat2 shapes cannot be multiplied | La entrada no tiene las características que espera la capa | Revisa x.shape y el primer nn.Linear |
expected scalar type Long but found Float | Etiquetas en coma flotante para CrossEntropyLoss | Convierte con dtype=torch.long |
element 0 of tensors does not require grad | Llamas a backward() sobre algo sin gradiente | Comprueba requires_grad y no uses .item() antes |
Expected all tensors to be on the same device | Modelo en GPU y datos en CPU (o al revés) | Mueve xb e yb con .to(dispositivo) |
| La pérdida baja y la exactitud no sube | Métricas mal calculadas o eval() olvidado | Usa modelo.eval() y argmax(dim=1) |
| La pérdida se estanca desde la época 1 | Learning rate demasiado bajo o datos sin normalizar | Prueba lr=0.01 y estandariza las entradas |
for xb, yb in cargador:
perdida = criterio(modelo(xb), yb)
perdida.backward()
optimizador.step()for xb, yb in cargador:
optimizador.zero_grad()
perdida = criterio(modelo(xb), yb)
perdida.backward()
optimizador.step()`backward()` suma al contenido previo de `.grad`, no lo reemplaza. Sin `zero_grad`, el paso 2 usa la suma de los gradientes de los pasos 1 y 2, y el entrenamiento se descontrola sin lanzar ningún error. El orden correcto es poner a cero antes del forward del batch actual.
Buenas prácticas
- Normaliza las entradas: una red con variables en escalas muy distintas entrena mal.
- Separa validación desde el principio y evalúa al final de cada época.
- Usa
modelo.train()al entrenar ymodelo.eval()al evaluar: dropout y batchnorm se comportan distinto. - Envuelve la evaluación en
torch.no_grad()para no construir un grafo que no necesitas. - Fija
torch.manual_seedpara que el experimento sea reproducible. - Guarda el
state_dictdel mejor modelo, no el del último: el sobreajuste llega antes de la época 50. - Empieza con una arquitectura pequeña; si no aprende, una más grande tampoco lo hará.
Ejercicios
Contar parámetros antes de entrenar
nn.Sequential una red Linear(3, 8) → ReLU → Linear(8, 1). Calcula a mano cuántos parámetros entrenables tiene y compruébalo con sum(p.numel() for p in modelo.parameters()). Después indica la forma de salida para un lote de 10 muestras.Pista
Cada Linear(a, b) aporta a·b pesos y b sesgos. La salida conserva el tamaño del lote en la primera dimensión.
for xb, yb in cargador: optimizador.() perdida = criterio(modelo(xb), yb) perdida.() optimizador.()
Primero se limpian los gradientes, luego se calculan y al final se aplican a los pesos.
Guardar el mejor modelo
state_dict solo cuando esa pérdida mejore e imprime al terminar la mejor cifra. Explica por qué no conviene guardar el modelo de la última época.Pista
Inicializa mejor = float("inf"), evalúa con modelo.eval() y torch.no_grad(), y usa torch.save(modelo.state_dict(), "mejor.pt").
Soluciones explicadas
En el primer ejercicio, contar parámetros a mano evita sorpresas: una capa Linear(3, 8) no tiene 24 parámetros sino 32, porque cada una de las 8 salidas lleva su sesgo. Saber la cifra te dice cuánta capacidad tiene el modelo y cuánta memoria ocupará. La forma de salida, (10, 1), recuerda que la primera dimensión es siempre el lote.
En el segundo, la clave es medir la validación con el modelo en eval() y sin construir grafo. Guardar state_dict en lugar del objeto completo produce archivos más pequeños y portables. Si el mejor resultado llega en la época 12 y sigues entrenando hasta la 50, habrás gastado cómputo en empeorar el modelo.
Resumen
- Un tensor tiene datos, forma, tipo y dispositivo;
@es producto matricial. requires_grad=Trueactiva el registro de operaciones;backward()llena.grad.- Los gradientes se acumulan: llama a
zero_grad()en cada batch. nn.Linear(a, b)aplicax·Wᵀ + by tienea·b + bparámetros.CrossEntropyLossusa logits y etiquetaslong; no apliques softmax antes.- El ciclo es forward, pérdida, zero_grad, backward y step, repetido por épocas.
- Evalúa con
modelo.eval()ytorch.no_grad(); vigila la curva de validación.
Evaluación
¿Qué hace exactamente loss.backward()?
¿Cuál es la forma de salida de nn.Linear(4, 5) aplicada a un lote de forma (3, 4)?
¿Por qué hay que llamar a optimizador.zero_grad() en cada iteración?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.