En la lección anterior abriste el capó de TensorFlow y descubriste que model.fit() es, en el fondo, un bucle de GradientTape empaquetado. Hoy conocerás el framework que decidió no empaquetar ese bucle: PyTorch. Creado por Meta (Facebook) y publicado en 2017, PyTorch es hoy el estándar de facto en investigación —la gran mayoría de los papers que leerás publican su código en PyTorch— y una opción de primera línea en industria. Su filosofía es darte el control explícito de cada paso. Para demostrarte que ya sabes casi todo lo necesario, en esta lección reproduciremos el mismo ejemplo de gradiente de 06-01 y reescribiremos en PyTorch la red MNIST 784-128-64-10 de 02-05, entrenándola hasta comparar resultados con la versión Keras. El equipo de TecnoMarket quiere hablar los dos idiomas; tú también.

Contenido

  1. Qué es PyTorch y su filosofía
  2. Tensores de PyTorch: creación, operaciones y dispositivos
  3. Autograd: el GradientTape de PyTorch
  4. Construir modelos con nn.Module
  5. El bucle de entrenamiento explícito, línea a línea
  6. Evaluación con torch.no_grad()
  7. Entrenar la red MNIST y comparar con Keras

Qué es PyTorch y su filosofía

PyTorch nació en los laboratorios de investigación de Meta como sucesor "pythónico" de Torch (un framework en Lua). Tres ideas definen su carácter:

  • Define-by-run (definición dinámica): el grafo de operaciones se construye mientras se ejecuta el código Python, no antes. Cada forward pass puede ser distinto: puedes usar if, bucles for, imprimir tensores intermedios con un simple print. Se siente como numpy con gradientes.
  • Control explícito: nada de fit(). El bucle de entrenamiento lo escribes tú. Más líneas de código, sí, pero cero magia: cuando algo falla, ves exactamente dónde.
  • Estándar de facto en investigación: si en el módulo 5 te picó la curiosidad por los transformers y los LLMs, casi todo su código abierto (incluida la librería transformers de Hugging Face) está construido sobre PyTorch.
Filosofía Keras/TensorFlow PyTorch
Lema implícito "Lo común debe ser fácil" "Todo debe ser explícito"
Bucle de entrenamiento Empaquetado en fit() Lo escribes tú
Sensación al usarlo Conducir automático Conducir manual

Instalación (ya la hiciste en 01-05 con pip install torch; añade torchvision para los datasets de visión):

import torch
print(torch.__version__)              # p. ej. 2.x
print(torch.cuda.is_available())      # True si hay GPU NVIDIA configurada

Tensores de PyTorch: creación, operaciones y dispositivos

Los tensores de PyTorch son conceptualmente idénticos a los de TensorFlow. Cambia la sintaxis, no la idea:

import torch

escalar = torch.tensor(4.99)                       # precio de un cable USB
vector  = torch.tensor([120.0, 15.5, 899.0])       # precios de 3 productos
matriz  = torch.rand(2, 3)                         # aleatorio uniforme, shape (2,3)
ceros   = torch.zeros(3, 3)
print(vector.shape)    # torch.Size([3])
print(vector.dtype)    # torch.float32  (mismo default que TF)

Operaciones, con su equivalencia:

Operación TensorFlow (06-01) PyTorch
Producto matricial tf.matmul(a, b) torch.matmul(a, b) o a @ b
Media tf.reduce_mean(a) a.mean()
Cambiar forma tf.reshape(a, (4,1)) a.reshape(4, 1) o a.view(4, 1)
Convertir tipo tf.cast(a, tf.float32) a.float() o a.to(torch.float32)
A numpy a.numpy() a.numpy() (idéntico)

El broadcasting funciona con las mismas reglas que viste en 06-01: dimensiones iguales o de tamaño 1 son compatibles.

El puente con numpy

import numpy as np

arr = np.array([1.0, 2.0, 3.0])
t = torch.from_numpy(arr)     # numpy -> tensor (¡comparten memoria!)
arr2 = t.numpy()              # tensor -> numpy (también comparten memoria)

t[0] = 99.0
print(arr[0])                 # 99.0 -> cambiar uno cambia el otro

Que compartan memoria es eficiente (no hay copia) pero puede sorprender: si no quieres el vínculo, usa torch.tensor(arr) (copia) en lugar de from_numpy.

Dispositivos: .to(device)

En PyTorch mover datos entre CPU y GPU es explícito (en TensorFlow era automático). El patrón universal que verás en todo código PyTorch:

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

x = torch.rand(32, 784)
x = x.to(device)              # mover el tensor a la GPU (o quedarse en CPU)
# model.to(device)            # los modelos se mueven igual

Regla de oro: modelo y datos deben estar en el mismo dispositivo, o PyTorch lanzará un error del tipo Expected all tensors to be on the same device. Es de los errores más comunes al empezar.

Autograd: el GradientTape de PyTorch

PyTorch también graba las operaciones para derivar automáticamente, pero sin cinta explícita: basta con marcar un tensor con requires_grad=True y todas las operaciones sobre él quedan registradas. Reproduzcamos el mismo ejemplo de 06-01 —minimizar L(w) = (w - 4)²— para comparar los dos estilos cara a cara:

import torch

w = torch.tensor(0.0, requires_grad=True)   # ~ tf.Variable(0.0)

loss = (w - 4.0) ** 2       # el grafo se construye solo, sin "with tape"
loss.backward()             # ~ tape.gradient(loss, w): deriva hacia atrás
print(w.grad)               # tensor(-8.) -> mismo -8.0 que en 06-01

Diferencias clave respecto a GradientTape:

  • No hay contexto with: cualquier operación sobre un tensor con requires_grad=True se graba siempre.
  • backward() se llama sobre la pérdida, y los gradientes aparecen en el atributo .grad de cada tensor hoja.
  • Los gradientes se acumulan: si llamas a backward() dos veces sin limpiar, .grad contiene la suma. Por eso todo bucle PyTorch limpia gradientes en cada paso.

El descenso de gradiente completo, gemelo del de 06-01:

w = torch.tensor(0.0, requires_grad=True)
lr = 0.1

for paso in range(30):
    loss = (w - 4.0) ** 2
    loss.backward()                  # calcula dL/dw y lo deja en w.grad
    with torch.no_grad():            # actualizar SIN grabar esta operación
        w -= lr * w.grad             # w = w - lr * gradiente
    w.grad.zero_()                   # ¡limpiar! si no, se acumularía

print(w.item())   # ~3.995: converge a 4, igual que numpy (02-03) y TF (06-01)

Tres versiones del mismo algoritmo, tres niveles de automatización:

Versión Derivada Actualización Bucle
numpy (02-03) A mano A mano A mano
TensorFlow (06-01) Automática (tape) A mano (assign_sub) A mano
PyTorch (hoy) Automática (autograd) A mano (luego la hará optimizer.step()) A mano
Keras fit() Automática Automática Automático

Construir modelos con nn.Module

En PyTorch los modelos se construyen siempre con subclassing —la tercera API que viste en 06-01 es aquí la forma estándar—. Se hereda de nn.Module, se declaran las capas en __init__ y se conectan en forward(). Reescribamos la red MNIST 784-128-64-10 de 02-05:

import torch
from torch import nn

class RedMNIST(nn.Module):
    def __init__(self):
        super().__init__()
        self.capa1 = nn.Linear(784, 128)   # ~ Dense(128) sobre entrada 784
        self.capa2 = nn.Linear(128, 64)    # ~ Dense(64)
        self.salida = nn.Linear(64, 10)    # ~ Dense(10)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.relu(self.capa1(x))
        x = self.relu(self.capa2(x))
        return self.salida(x)              # logits SIN softmax (ver nota)

model = RedMNIST()
print(model)   # imprime la estructura, como summary() en Keras

Observaciones importantes:

  • nn.Linear(784, 128) pide entrada y salida explícitas; Keras infería la entrada del dato. Más escritura, menos sorpresas.
  • No hay softmax en la salida. En PyTorch, la función de pérdida nn.CrossEntropyLoss espera logits crudos (aplica log-softmax internamente por estabilidad numérica). Es el equivalente exacto de compilar en Keras con SparseCategoricalCrossentropy(from_logits=True). Poner softmax + CrossEntropyLoss es un error clásico que degrada el entrenamiento.
  • Nunca llamas a forward() directamente: llamas al modelo, model(x), y PyTorch invoca forward con la maquinaria de hooks necesaria.
  • Contar parámetros: sum(p.numel() for p in model.parameters()) → 109.386, exactamente los mismos que la versión Keras de 02-05 (784·128+128 + 128·64+64 + 64·10+10).

El bucle de entrenamiento explícito, línea a línea

Aquí está la gran diferencia con Keras. Primero, los datos con DataLoader (el gemelo de tf.data):

from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

transform = transforms.ToTensor()   # PIL -> tensor float32 en [0,1]
train_ds = datasets.MNIST(root="data", train=True, download=True, transform=transform)
test_ds  = datasets.MNIST(root="data", train=False, download=True, transform=transform)

train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)   # ~ shuffle+batch de tf.data
test_loader  = DataLoader(test_ds, batch_size=32)

Y ahora el bucle completo, comentado línea a línea:

device = "cuda" if torch.cuda.is_available() else "cpu"
model = RedMNIST().to(device)                          # modelo al dispositivo

loss_fn = nn.CrossEntropyLoss()                        # ~ sparse_categorical_crossentropy
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)  # ~ optimizer="adam"

for epoch in range(5):                                 # ~ epochs=5 en fit()
    model.train()                                      # modo entrenamiento (afecta a dropout/BN)
    for x_batch, y_batch in train_loader:              # ~ fit() itera los lotes por ti
        x_batch = x_batch.view(x_batch.size(0), -1)    # (32,1,28,28) -> (32,784), el flatten
        x_batch, y_batch = x_batch.to(device), y_batch.to(device)

        logits = model(x_batch)                        # 1. FORWARD: predicciones
        loss = loss_fn(logits, y_batch)                # 2. PÉRDIDA: cuánto nos equivocamos

        optimizer.zero_grad()                          # 3. LIMPIAR gradientes acumulados
        loss.backward()                                # 4. BACKWARD: repartir la culpa (02-03)
        optimizer.step()                               # 5. ACTUALIZAR pesos con Adam

    print(f"Época {epoch+1}: pérdida último lote = {loss.item():.4f}")

Desglose de los cinco pasos del corazón del bucle:

  1. Forward (model(x_batch)): los datos atraviesan la red y salen los logits. Es la propagación hacia adelante de 02-03.
  2. Pérdida (loss_fn(...)): un único número que mide el error del lote, como en 02-04.
  3. optimizer.zero_grad(): pone a cero los .grad de todos los parámetros. Recuerda: autograd acumula; sin esta línea, cada paso sumaría los gradientes de todos los pasos anteriores y el entrenamiento divergería.
  4. loss.backward(): backpropagation. Calcula el gradiente de la pérdida respecto a cada uno de los 109.386 parámetros.
  5. optimizer.step(): Adam actualiza cada parámetro usando su .grad. Es el w.assign_sub(lr * grad) de 06-01, con la sofisticación de Adam (02-04).

Qué automatiza Keras y qué expone PyTorch

Tarea Keras fit() PyTorch
Iterar épocas y lotes Automático for epoch... / for x, y in loader
Mover datos a GPU Automático .to(device) manual
Forward Automático model(x)
Cálculo de pérdida Automático (la das en compile) loss_fn(logits, y)
Limpiar gradientes Automático optimizer.zero_grad()
Backward Automático loss.backward()
Actualizar pesos Automático optimizer.step()
Métricas y barra de progreso Automático Las calculas/imprimes tú
Modo train/eval (dropout, BN) Automático model.train() / model.eval()

Ninguna columna es "mejor": Keras te da velocidad para lo estándar; PyTorch te da transparencia para lo no estándar. Cuando en 05-01 viste el esqueleto del entrenamiento de una GAN (dos redes, dos optimizadores alternándose), ese tipo de bucle a medida es donde el estilo explícito de PyTorch brilla.

Evaluación con torch.no_grad()

Al evaluar no queremos entrenar: no hacen falta gradientes. torch.no_grad() desactiva la grabación de autograd, ahorrando memoria y tiempo:

model.eval()                              # modo evaluación (dropout off, BN congelada)
correctas, total = 0, 0

with torch.no_grad():                     # nada de lo de dentro se graba
    for x_batch, y_batch in test_loader:
        x_batch = x_batch.view(x_batch.size(0), -1).to(device)
        y_batch = y_batch.to(device)
        logits = model(x_batch)
        preds = logits.argmax(dim=1)      # clase con mayor logit
        correctas += (preds == y_batch).sum().item()
        total += y_batch.size(0)

print(f"Precisión en test: {correctas / total:.4f}")

Dos cosas distintas que suelen confundirse:

  • model.eval() cambia el comportamiento de ciertas capas (dropout deja de apagar neuronas, batch norm usa sus estadísticas acumuladas — lo viste en 05-04).
  • torch.no_grad() desactiva el cálculo de gradientes.

En evaluación se usan las dos. Es el equivalente de lo que model.evaluate() de Keras hace solo.

Entrenar la red MNIST y comparar con Keras

Ejecutando el bucle anterior 5 épocas (el mismo presupuesto que en 02-05), un resultado típico:

Keras (02-05) PyTorch (hoy)
Arquitectura 784-128-64-10, ReLU Idéntica
Parámetros 109.386 109.386
Optimizador / pérdida Adam / crossentropy Adam / CrossEntropyLoss
Precisión en test (~5 épocas) ~97,7 % ~97,5 – 97,9 %
Líneas de código de entrenamiento ~5 (compile + fit) ~25 (bucle explícito)

La conclusión que el equipo de TecnoMarket apunta en su pizarra: misma red, mismos datos, mismo resultado. Las pequeñas diferencias decimales vienen de la inicialización aleatoria y el orden de los lotes, no del framework. Lo que cambia es la experiencia de desarrollo, y eso es exactamente lo que compararemos sistemáticamente en la próxima lección.

Errores Comunes y Consejos

  • Olvidar optimizer.zero_grad(): el error número uno. Los gradientes se acumulan, la pérdida baila o explota y no hay mensaje de error que te avise. Si tu entrenamiento PyTorch "no converge", revisa esto primero.
  • Softmax + CrossEntropyLoss: nn.CrossEntropyLoss ya incluye el softmax (logarítmico). Añadir nn.Softmax en la salida entrena mal en silencio. Salida = logits crudos.
  • Modelo y datos en dispositivos distintos: Expected all tensors to be on the same device. Mueve ambos con .to(device); el modelo una vez, los datos en cada lote.
  • Olvidar model.eval() al evaluar: con dropout activo en test, la precisión medida será peor que la real. Y al revés, olvidar model.train() al volver a entrenar.
  • .item() vs. el tensor: loss es un tensor conectado al grafo; para registrar su valor usa loss.item(). Acumular tensores en una lista durante muchas épocas retiene el grafo y agota la memoria.
  • Consejo: cuando un shape no cuadre, aprovecha define-by-run: pon print(x.shape) dentro de forward(). Esa depuración inmediata es uno de los grandes placeres de PyTorch.

Ejercicios

Ejercicio 1: gradiente a mano y con autograd

Calcula con autograd el gradiente de L(w, b) = (3*w + b - 10)**2 en w=1.0, b=0.0 (el mismo del ejercicio 2 de 06-01). Comprueba que obtienes -42 y -14, idénticos a la versión GradientTape. Después ejecuta 200 pasos de descenso con lr 0.01 usando torch.no_grad() para actualizar.

Ejercicio 2: la red de reseñas en nn.Module

TecnoMarket quiere el clasificador binario de reseñas (positiva/negativa) como módulo PyTorch: entrada de 200 características (la reseña ya vectorizada, como en 04-03), capas ocultas de 64 y 32 con ReLU, y salida de 1 logit. Escribe la clase ClasificadorResenas(nn.Module) e indica qué función de pérdida usarías y por qué la salida no lleva sigmoide.

Ejercicio 3: leer el bucle

Sin ejecutarlo, explica qué fallaría (y con qué síntoma) en este bucle:

for x, y in train_loader:
    logits = model(x)
    loss = loss_fn(logits, y)
    loss.backward()
    optimizer.step()

Soluciones

Solución 1:

import torch

w = torch.tensor(1.0, requires_grad=True)
b = torch.tensor(0.0, requires_grad=True)

loss = (3.0 * w + b - 10.0) ** 2
loss.backward()
print(w.grad.item(), b.grad.item())   # -42.0, -14.0 (idéntico a 06-01)

lr = 0.01
for _ in range(200):
    loss = (3.0 * w + b - 10.0) ** 2
    if w.grad is not None:
        w.grad.zero_(); b.grad.zero_()   # limpiar antes de recalcular
    loss.backward()
    with torch.no_grad():
        w -= lr * w.grad
        b -= lr * b.grad

print(loss.item())        # ~0.0

Mismo resultado que con GradientTape: los dos autograds calculan la misma matemática de 02-03.

Solución 2:

from torch import nn

class ClasificadorResenas(nn.Module):
    def __init__(self):
        super().__init__()
        self.capa1 = nn.Linear(200, 64)
        self.capa2 = nn.Linear(64, 32)
        self.salida = nn.Linear(32, 1)     # 1 logit para clasificación binaria
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.relu(self.capa1(x))
        x = self.relu(self.capa2(x))
        return self.salida(x)

Pérdida: nn.BCEWithLogitsLoss. Igual que CrossEntropyLoss con el softmax, esta pérdida incluye la sigmoide internamente (con mayor estabilidad numérica), así que la red debe devolver el logit crudo. Es el equivalente de BinaryCrossentropy(from_logits=True) en Keras.

Solución 3: Faltan tres cosas:

  1. optimizer.zero_grad(): los gradientes se acumulan paso a paso; síntoma: la pérdida deja de bajar o diverge sin errores visibles.
  2. Aplanado y dispositivo: si x viene de MNIST con shape (32, 1, 28, 28) y el modelo espera (32, 784), habrá un error de shapes en nn.Linear; y si el modelo está en GPU y los datos no, error de dispositivos.
  3. model.train() antes del bucle (relevante si el modelo tiene dropout/BN).

El orden correcto del núcleo es: forward → pérdida → zero_grad()backward()step().

Conclusión

Ya hablas los dos idiomas. PyTorch te ha mostrado el otro estilo: tensores casi idénticos a los de TensorFlow (con movimiento explícito .to(device) y puente directo con numpy), autograd que resuelve el mismo gradiente que GradientTape con requires_grad + backward(), modelos como clases nn.Module (el subclassing de 06-01 como norma, no como opción) y, sobre todo, el bucle de entrenamiento explícito: forward, pérdida, zero_grad, backward, step — los cinco pasos que Keras empaqueta en fit(). La red MNIST de 02-05 reescrita en PyTorch alcanza la misma precisión: el framework no cambia la matemática, cambia quién escribe cada pieza.

La pregunta inevitable es: ¿cuál usar? En la próxima lección haremos la comparación honesta —filosofía, depuración, ecosistemas, industria vs. investigación— y veremos qué elegiría el equipo de TecnoMarket y por qué, con una conclusión tranquilizadora: los conceptos que has aprendido viajan contigo, sea cual sea la herramienta.

Curso de Deep Learning

Módulo 1: Introducción a Deep Learning

Módulo 2: Fundamentos de Redes Neuronales

Módulo 3: Redes Neuronales Convolucionales (CNN)

Módulo 4: Redes Neuronales Recurrentes (RNN)

Módulo 5: Técnicas Avanzadas en Deep Learning

Módulo 6: Herramientas y Frameworks

Módulo 7: Proyectos Prácticos

Módulo 8: Consideraciones Éticas y Futuro del Deep Learning

© Copyright 2026. Todos los derechos reservados