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
- Qué es PyTorch y su filosofía
- Tensores de PyTorch: creación, operaciones y dispositivos
- Autograd: el GradientTape de PyTorch
- Construir modelos con nn.Module
- El bucle de entrenamiento explícito, línea a línea
- Evaluación con torch.no_grad()
- 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, buclesfor, imprimir tensores intermedios con un simpleprint. 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
transformersde 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 configuradaTensores 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 otroQue 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 igualRegla 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-01Diferencias clave respecto a GradientTape:
- No hay contexto
with: cualquier operación sobre un tensor conrequires_grad=Truese graba siempre. backward()se llama sobre la pérdida, y los gradientes aparecen en el atributo.gradde cada tensor hoja.- Los gradientes se acumulan: si llamas a
backward()dos veces sin limpiar,.gradcontiene 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 KerasObservaciones 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.CrossEntropyLossespera logits crudos (aplica log-softmax internamente por estabilidad numérica). Es el equivalente exacto de compilar en Keras conSparseCategoricalCrossentropy(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 invocaforwardcon 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:
- Forward (
model(x_batch)): los datos atraviesan la red y salen los logits. Es la propagación hacia adelante de 02-03. - Pérdida (
loss_fn(...)): un único número que mide el error del lote, como en 02-04. optimizer.zero_grad(): pone a cero los.gradde 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.loss.backward(): backpropagation. Calcula el gradiente de la pérdida respecto a cada uno de los 109.386 parámetros.optimizer.step(): Adam actualiza cada parámetro usando su.grad. Es elw.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.CrossEntropyLossya incluye el softmax (logarítmico). Añadirnn.Softmaxen 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, olvidarmodel.train()al volver a entrenar. .item()vs. el tensor:losses un tensor conectado al grafo; para registrar su valor usaloss.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 deforward(). 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.0Mismo 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:
optimizer.zero_grad(): los gradientes se acumulan paso a paso; síntoma: la pérdida deja de bajar o diverge sin errores visibles.- Aplanado y dispositivo: si
xviene de MNIST con shape(32, 1, 28, 28)y el modelo espera(32, 784), habrá un error de shapes ennn.Linear; y si el modelo está en GPU y los datos no, error de dispositivos. 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
- ¿Qué es Deep Learning?
- Historia y evolución del Deep Learning
- Aplicaciones de Deep Learning
- Conceptos básicos de redes neuronales
- Preparación del entorno de trabajo
Módulo 2: Fundamentos de Redes Neuronales
- Perceptrón y Perceptrón Multicapa
- Función de activación
- Propagación hacia adelante y hacia atrás
- Optimización y función de pérdida
- Tu primera red neuronal completa
Módulo 3: Redes Neuronales Convolucionales (CNN)
- Introducción a las CNN
- Capas convolucionales y de pooling
- Arquitecturas populares de CNN
- Aplicaciones de CNN en reconocimiento de imágenes
Módulo 4: Redes Neuronales Recurrentes (RNN)
- Introducción a las RNN
- LSTM y GRU
- Aplicaciones de RNN en procesamiento del lenguaje natural
- Secuencias y series temporales
Módulo 5: Técnicas Avanzadas en Deep Learning
- Redes Generativas Adversariales (GAN)
- Autoencoders
- Transfer Learning
- Regularización y técnicas de mejora
- Mecanismos de atención y Transformers
Módulo 6: Herramientas y Frameworks
- Introducción a TensorFlow
- Introducción a PyTorch
- Comparación de frameworks
- Entornos de desarrollo y recursos adicionales
- Guardar, cargar y desplegar modelos
Módulo 7: Proyectos Prácticos
- Clasificación de imágenes con CNN
- Generación de texto con RNN
- Detección de anomalías con Autoencoders
- Creación de una GAN para generación de imágenes
- Fine-tuning de un modelo preentrenado
