AulaML Python · C++
Python avanzado · 55 min · Python para Machine Learning

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.

Lección 6 de 7 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es PyTorch?
  2. ¿Para qué sirve?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis: instalar y crear tensores
  5. Ejemplo básico: un tensor y una operación
  6. Ejemplo intermedio: autograd a mano
  7. Caso práctico: un perceptrón multicapa completo
  8. Visualización: cómo cambia un lote al atravesar una capa
  9. Visualización: qué hace backward()
  10. PyTorch frente a TensorFlow
  11. Errores comunes
  12. Buenas prácticas
  13. Ejercicios
  14. Soluciones explicadas
  15. Resumen
  16. 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.

SHELL Instalación de PyTorch
python3 -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install torch scikit-learn
python -c "import torch; print(torch.__version__)"
PYTHON tensores.py
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 semilla

a.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.

PYTHON capa_lineal.py
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()))  # 25

La 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.

PYTHON autograd.py
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.0667

La 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.

PYTHON mlp.py
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.

Mapa de datos ·Activaciones de un lote (3 muestras × 5 neuronas)
3 × 5 rango [-1.8, 2.1] Salida lineal: cada celda es z = x·Wᵀ + b para una muestra y una neurona. La forma se conserva: (3, 5).
n1
n2
n3
n4
n5
muestra 1
0
-1.8
1.3
1.8
0.35
muestra 2
-0.15
0.85
0.5
0.3
-0.15
muestra 3
2.1
-0.5
-1.3
0.8
1.45

Recorre los pasos para ver cómo cambian las celdas.

PYTHON activaciones.py
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.

Ejecución visual ·Del forward a los gradientes
1 / 7
1 import torch
2
3 w = torch.tensor(2.0, requires_grad=True)
4 b = torch.tensor(1.0, requires_grad=True)
5 x = torch.tensor([1.0, 2.0, 3.0])
6 y = torch.tensor([3.0, 5.0, 8.0])
7 pred = w * x + b
8 loss = ((pred - y) ** 2).mean()
9 loss.backward()
10 print(w.grad, b.grad)
Se crea w como escalar con requires_grad=True: PyTorch anotará todas las operaciones en las que participe.
Variables
w = tensor(2., requires_grad=True)
Salida
—

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.

PYTHON equivalente_keras.py
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íntomaCausaSolución
mat1 and mat2 shapes cannot be multipliedLa entrada no tiene las características que espera la capaRevisa x.shape y el primer nn.Linear
expected scalar type Long but found FloatEtiquetas en coma flotante para CrossEntropyLossConvierte con dtype=torch.long
element 0 of tensors does not require gradLlamas a backward() sobre algo sin gradienteComprueba requires_grad y no uses .item() antes
Expected all tensors to be on the same deviceModelo 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 subeMétricas mal calculadas o eval() olvidadoUsa modelo.eval() y argmax(dim=1)
La pérdida se estanca desde la época 1Learning rate demasiado bajo o datos sin normalizarPrueba lr=0.01 y estandariza las entradas
El gradiente que se acumula
Incorrecto
PYTHON Sin poner a cero
for xb, yb in cargador:
    perdida = criterio(modelo(xb), yb)
    perdida.backward()
    optimizador.step()
Correcto
PYTHON Con zero_grad
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 y modelo.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_seed para que el experimento sea reproducible.
  • Guarda el state_dict del 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

Ejercicio 1

Contar parámetros antes de entrenar

Construye con 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.

Completar código Completa el paso de entrenamiento
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.

Ejercicio 2

Guardar el mejor modelo

Modifica el bucle del perceptrón multicapa para calcular también la pérdida de validación al final de cada época. Guarda el 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=True activa el registro de operaciones; backward() llena .grad.
  • Los gradientes se acumulan: llama a zero_grad() en cada batch.
  • nn.Linear(a, b) aplica x·Wᵀ + b y tiene a·b + b parámetros.
  • CrossEntropyLoss usa logits y etiquetas long; no apliques softmax antes.
  • El ciclo es forward, pérdida, zero_grad, backward y step, repetido por épocas.
  • Evalúa con modelo.eval() y torch.no_grad(); vigila la curva de validación.

Evaluación

Evaluación 1

¿Qué hace exactamente loss.backward()?

Evaluación 2

¿Cuál es la forma de salida de nn.Linear(4, 5) aplicada a un lote de forma (3, 4)?

Evaluación 3

¿Por qué hay que llamar a optimizador.zero_grad() en cada iteración?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.