Capítulo 3: PyTorch — el framework del deep learning profesional¶
"En los capítulos 1 y 2 construisteis un MLP, backpropagation y Adam con NumPy puro. Fue duro, pero ahora sabéis exactamente qué hay bajo el capó. Este capítulo os entrega las llaves del coche de verdad: PyTorch. Todo lo que implementasteis a mano, PyTorch lo hace por vosotros — mejor, más rápido y en GPU. Pero como ya sabéis cómo funciona, nunca será una caja negra para vosotros."
Índice¶
- ¿Por qué PyTorch?
- 1.1. Breve historia: de Torch a la Linux Foundation
- 1.2. Por qué ganó la batalla de los frameworks
- 1.3. El ecosistema PyTorch
- 1.4. PyTorch vs TensorFlow vs JAX en 2026
- 1.5. Instalación
- Tensores a fondo
- 2.1. Creación de tensores
- 2.2. dtype: por qué importa el tipo de dato
- 2.3. Shape, view, reshape, squeeze y unsqueeze
- 2.4. Indexación y slicing
- 2.5. Broadcasting
- 2.6. Operaciones esenciales
- 2.7. Conversión NumPy ↔ PyTorch
- 2.8. Dispositivos: CPU, CUDA y MPS
- Autograd: la magia explicada
- 3.1. requires_grad y el grafo dinámico
- 3.2. backward() y .grad
- 3.3. La acumulación de gradientes y zero_grad
- 3.4. no_grad, inference_mode y detach
- 3.5. El círculo se cierra: regresión lineal con autograd puro
- nn.Module a fondo
- 4.1. Anatomía de una clase modelo
- 4.2. nn.Linear por dentro
- 4.3. nn.Sequential: cuándo basta
- 4.4. Capas comunes
- 4.5. Contar parámetros y state_dict
- Datos: Dataset y DataLoader
- 5.1. Dataset custom
- 5.2. DataLoader
- 5.3. Transforms
- 5.4. train/val split con random_split
- El training loop profesional
- 6.1. La plantilla canónica
- 6.2. Funciones reutilizables: train_one_epoch y evaluate
- 6.3. Checkpoints: guardar y cargar
- 6.4. Early stopping
- 6.5. Curvas de pérdida con matplotlib
- 6.6. Reproducibilidad
- 6.7. Aplicación 1: clasificador de churn tabular
- 6.8. Aplicación 2: MNIST
- GPU en la práctica
- 7.1. El patrón correcto para mover cosas
- 7.2. Medir el speedup
- 7.3. Errores típicos con devices
- 7.4. Colab: GPU gratis
- 7.5. Mixed precision con torch.autocast
- torch.compile: PyTorch 2.x
- Ejemplo integrador: pipeline completo de clasificación tabular
- Caso empresarial: cómo estructura su código un equipo profesional
- Buenas prácticas
- Malas prácticas
- Errores comunes
- FAQ — Preguntas frecuentes
- Resumen
- Bibliografía y recursos
1. ¿Por qué PyTorch?¶
1.1. Breve historia: de Torch a la Linux Foundation¶
PyTorch no nació de la nada. Su genealogía es interesante y explica muchas de sus decisiones de diseño:
- 2002 — Torch: nace en Suiza (IDIAP) como una librería de machine learning escrita en C con interfaz en Lua, un lenguaje de scripting minoritario. Torch7 fue muy usado en investigación (DeepMind lo usó durante años), pero Lua era una barrera de entrada enorme.
- 2016 — PyTorch 0.1: un pequeño equipo de Facebook AI Research (FAIR), liderado por Adam Paszke, Sam Gross y Soumith Chintala, reescribe la idea de Torch con interfaz Python y una novedad clave: ejecución eager con autograd dinámico (ahora entenderéis qué significa).
- 2018 — PyTorch 1.0: se fusiona con Caffe2 (el framework de producción de Facebook) y se convierte en un framework completo de investigación y producción.
- 2022 — PyTorch Foundation: Meta transfiere la gobernanza del proyecto a la Linux Foundation, garantizando neutralidad: hoy contribuyen Meta, Microsoft, NVIDIA, AMD, Google, Amazon e Intel, entre otros.
- 2023 — PyTorch 2.0: llega
torch.compile, que añade compilación JIT del grafo sin sacrificar la experiencia eager. Lo mejor de los dos mundos. - 2026 — hoy: PyTorch 2.x es el estándar de facto. La inmensa mayoría de los papers con código publicado en NeurIPS, ICML e ICLR usan PyTorch, y modelos como Llama, Stable Diffusion o Whisper están construidos sobre él.
1.2. Por qué ganó la batalla de los frameworks¶
Entre 2016 y 2020 hubo una "guerra de frameworks" entre TensorFlow (Google) y PyTorch (Meta). PyTorch ganó de forma clara en investigación y, con el tiempo, también en industria. Las razones:
- Eager execution (ejecución inmediata): en PyTorch, cuando escribes
c = a + b, la suma se ejecuta en ese instante y puedes imprimirc, ponerle un breakpoint o inspeccionarla con el debugger de Python. TensorFlow 1.x te obligaba a construir primero un grafo estático completo y luego ejecutarlo en una "sesión" — depurar era una pesadilla. TensorFlow 2.x adoptó eager execution... precisamente porque PyTorch demostró que era el camino. - Es pythónico: un modelo PyTorch es una clase Python normal. Un bucle de entrenamiento es un bucle
fornormal. Si sabes Python, sabes leer PyTorch. No hay un "lenguaje dentro del lenguaje". - El grafo dinámico: el grafo computacional (el que dibujasteis en el capítulo 2) se construye sobre la marcha en cada forward. Eso permite modelos con control de flujo arbitrario:
if, bucles, recursión... fundamentales en NLP y en investigación. - Efecto red: los investigadores publican en PyTorch → los estudiantes aprenden PyTorch → las empresas contratan gente que sabe PyTorch → las empresas usan PyTorch. El círculo se retroalimenta.
Nota
Esto no significa que TensorFlow esté "muerto". Sigue siendo fuerte en despliegue móvil/embebido (LiteRT, antes TFLite) y en empresas con infraestructura Google Cloud heredada. Pero si hoy empiezas de cero, la respuesta por defecto es PyTorch.
1.3. El ecosistema PyTorch¶
PyTorch es el núcleo de una constelación de librerías oficiales y de terceros:
| Librería | Qué aporta | ¿La usaremos? |
|---|---|---|
torch |
Tensores, autograd, nn, optimizadores | Este capítulo |
torchvision |
Datasets de imágenes, transforms, modelos preentrenados (ResNet...) | Capítulo 4 (CNNs) |
torchaudio |
Audio: espectrogramas, datasets, modelos de voz | Mención |
torchtext |
Texto (en desuso; hoy se usa Hugging Face) | |
| PyTorch Lightning | Elimina el boilerplate del training loop | Mención (sección 10) |
| Hugging Face Transformers | Modelos preentrenados de NLP/visión sobre PyTorch | Módulo 04 (NLP) |
Consejo profesional
aprended primero PyTorch "a pelo" (este capítulo) antes de saltar a Lightning o a Hugging Face Trainer. Las abstracciones de alto nivel son maravillosas cuando sabes qué están abstrayendo. Si no, el primer error críptico os dejará bloqueados.
1.4. PyTorch vs TensorFlow vs JAX en 2026¶
| Criterio | PyTorch 2.x | TensorFlow/Keras 3 | JAX |
|---|---|---|---|
| Filosofía | Eager + compilación opcional (torch.compile) |
Grafo con Keras como API de alto nivel | Funcional puro + transformaciones (jit, grad, vmap) |
| Curva de aprendizaje | Suave si sabes Python | Suave con Keras, dura por debajo | Empinada (programación funcional, inmutabilidad) |
| Investigación | Dominante (mayoría de papers) | Minoritario | Fuerte en Google DeepMind y labs de escala |
| Producción | Muy fuerte (TorchServe, ONNX, torch.export) |
Fuerte (TF Serving, LiteRT en móvil) | Requiere más ingeniería propia |
| TPUs | Soporte vía torch_xla (mejorando) |
Nativo | Nativo y excelente |
| Comunidad y ejemplos | Enorme | Grande pero decreciente | Pequeña pero de altísimo nivel |
| Cuándo elegirlo | Por defecto: investigación, prototipos, producción general, todo este máster | Mantener sistemas legacy TF; despliegue móvil embebido | Computación científica a gran escala, entrenamiento masivo en TPU, si te encaja lo funcional |
Nota
Keras 3 es multi-backend: puede ejecutarse sobre TensorFlow, JAX o PyTorch. Es un movimiento inteligente, pero en la práctica profesional se trabaja directamente con PyTorch por control y por ecosistema.
1.5. Instalación¶
# CPU solo (suficiente para seguir este capítulo):
pip install torch torchvision
# Con GPU NVIDIA (CUDA): consultad el selector oficial en pytorch.org/get-started
# que genera el comando exacto para vuestra versión de CUDA, p. ej.:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu124
Comprobación rápida:
import torch # importamos la librería principal
print(torch.__version__) # p. ej. '2.7.1' — cualquier 2.x nos vale
print(torch.cuda.is_available()) # True si hay GPU NVIDIA con drivers correctos
Advertencia
si torch.cuda.is_available() devuelve False teniendo GPU NVIDIA, casi siempre es porque instalasteis la versión CPU de torch. Desinstalad (pip uninstall torch) y reinstalad con el índice CUDA correcto desde pytorch.org.
2. Tensores a fondo¶
El tensor es el ndarray de PyTorch: un array N-dimensional. Todo lo que aprendisteis de NumPy en el módulo de fundamentos aplica casi 1:1, con dos superpoderes añadidos: pueden vivir en la GPU y pueden recordar su historial de operaciones para calcular gradientes (autograd).
2.1. Creación de tensores¶
import torch
import numpy as np
# --- Desde datos Python ---
a = torch.tensor([1.0, 2.0, 3.0]) # desde una lista → tensor 1D float32
b = torch.tensor([[1, 2], [3, 4]]) # lista de listas → tensor 2D int64
# --- Constructores con forma ---
z = torch.zeros(3, 4) # matriz 3x4 de ceros
o = torch.ones(2, 3) # matriz 2x3 de unos
e = torch.empty(2, 2) # SIN inicializar (basura de memoria, es más rápido)
i = torch.eye(3) # matriz identidad 3x3
r = torch.arange(0, 10, 2) # [0, 2, 4, 6, 8] — como range()
l = torch.linspace(0, 1, 5) # [0.00, 0.25, 0.50, 0.75, 1.00]
f = torch.full((2, 3), 7.0) # matriz 2x3 rellena con 7.0
# --- Aleatorios (¡los usaréis constantemente!) ---
n = torch.randn(3, 3) # normal estándar N(0, 1) — inicialización de pesos
u = torch.rand(3, 3) # uniforme en [0, 1)
p = torch.randint(0, 10, (2, 5)) # enteros aleatorios en [0, 10)
# --- "Como otro" (mismo shape/dtype/device que otro tensor) ---
zz = torch.zeros_like(n) # ceros con la misma forma que n
rr = torch.randn_like(n) # aleatorios con la misma forma que n
Nota
torch.tensor(datos) siempre copia los datos. torch.as_tensor(datos) evita la copia cuando puede (p. ej. desde un array NumPy compatible). Para datos grandes esto importa.
2.2. dtype: por qué importa el tipo de dato¶
Cada tensor tiene un dtype. No es un detalle menor: determina la precisión, la memoria y la velocidad de vuestros modelos.
| dtype | Bits | Rango aproximado | Uso típico |
|---|---|---|---|
torch.float32 |
32 | ±3.4e38, ~7 dígitos | Por defecto para pesos y activaciones |
torch.float64 |
64 | ±1.8e308, ~16 dígitos | Ciencia numérica; casi nunca en DL (lento en GPU) |
torch.float16 |
16 | ±65 504, ~3 dígitos | Mixed precision en GPU (riesgo de overflow) |
torch.bfloat16 |
16 | ±3.4e38, ~2-3 dígitos | Mixed precision moderno: mismo rango que float32 |
torch.int64 |
64 | enteros | Etiquetas de clase (CrossEntropyLoss las exige) |
torch.int32/int8 |
32/8 | enteros | Índices, cuantización de modelos |
torch.bool |
8 | True/False | Máscaras |
x = torch.tensor([1.0, 2.0]) # float32 por defecto (¡no float64 como NumPy!)
print(x.dtype) # torch.float32
y = torch.tensor([1, 2]) # los enteros crean int64 por defecto
print(y.dtype) # torch.int64
# Conversión de tipo (devuelve un tensor NUEVO):
x16 = x.to(torch.float16) # media precisión: mitad de memoria
xb = x.to(torch.bfloat16) # bfloat16: rango de float32, menos precisión
xl = x.long() # atajo para .to(torch.int64)
xf = y.float() # atajo para .to(torch.float32)
Consejo profesional
float32 divide por dos la memoria respecto a float64 de NumPy y las GPUs modernas están optimizadas para ello. Y bfloat16 la vuelve a dividir por dos con el mismo rango dinámico que float32 — esa es la semilla del mixed precision que veremos en la sección 7.5. Los LLMs actuales se entrenan casi todos en bfloat16.
Advertencia
el error RuntimeError: expected scalar type Float but found Double aparece cuando mezcláis un tensor float64 (típicamente venido de NumPy, que usa float64 por defecto) con pesos float32. Solución: tensor.float() al convertir desde NumPy.
2.3. Shape, view, reshape, squeeze y unsqueeze¶
Ésta es la fuente número 1 de errores en deep learning. Dominad esta sección y os ahorraréis horas de depuración.
view / reshape — reorganizan los mismos datos en otra forma:
m = x.view(3, 4) # 12 elementos → matriz 3x4 (sin copiar memoria)
# [[ 0, 1, 2, 3],
# [ 4, 5, 6, 7],
# [ 8, 9, 10, 11]]
m2 = x.view(4, -1) # -1 = "calcúlalo tú": 12/4 = 3 columnas → 4x3
m3 = x.reshape(2, 6) # reshape = view que copia si hace falta (más seguro)
Visualmente, view NO mueve datos, solo cambia "las gafas" con las que los miras:
datos en memoria: [0 1 2 3 4 5 6 7 8 9 10 11]
view(3,4): fila0=[0 1 2 3] fila1=[4 5 6 7] fila2=[8 9 10 11]
view(4,3): fila0=[0 1 2] fila1=[3 4 5] fila2=[6 7 8] fila3=[9 10 11]
Nota
view exige que el tensor sea contiguo en memoria; si habéis hecho una transposición previa fallará con RuntimeError: view size is not compatible.... Usad .reshape() o .contiguous().view() y listo. En la práctica: usad reshape salvo que persigáis la última gota de rendimiento.
unsqueeze — añade una dimensión de tamaño 1 (¡crítico para batches!):
v = torch.tensor([1.0, 2.0, 3.0]) # shape: (3,) — un vector suelto
v0 = v.unsqueeze(0) # shape: (1, 3) — "un batch con 1 muestra"
v1 = v.unsqueeze(1) # shape: (3, 1) — "3 muestras de 1 feature"
# Visualmente:
# v → [1, 2, 3] (3,)
# v.unsqueeze(0) → [[1, 2, 3]] (1, 3) ← fila
# v.unsqueeze(1) → [[1], [2], [3]] (3, 1) ← columna
squeeze — elimina dimensiones de tamaño 1:
w = torch.zeros(1, 3, 1, 2) # shape: (1, 3, 1, 2)
w.squeeze() # shape: (3, 2) — quita TODAS las de tamaño 1
w.squeeze(0) # shape: (3, 1, 2) — quita solo la dimensión 0
w.squeeze(1) # shape: (1, 3, 1, 2) — la dim 1 mide 3, no hace nada
Advertencia
el caso de uso más común: vuestro modelo devuelve shape (batch, 1) y las etiquetas tienen shape (batch,). Si calculáis la pérdida sin igualarlos, PyTorch hará broadcasting silencioso y obtendréis shape (batch, batch): la pérdida no bajará y no habrá ningún error visible (solo un UserWarning). Solución: salida.squeeze(1) o etiquetas.unsqueeze(1). Leed los warnings.
Otras operaciones de forma que veréis a menudo:
t = torch.randn(2, 3, 4)
t.permute(2, 0, 1).shape # (4, 2, 3) — reordena dimensiones (imágenes HWC↔CHW)
t.transpose(0, 1).shape # (3, 2, 4) — intercambia dos dimensiones
t.flatten().shape # (24,) — aplana todo
t.flatten(1).shape # (2, 12) — aplana desde la dim 1 (típico antes de nn.Linear)
Ejercicio rápido 2.1: tenéis un tensor de imágenes imgs con shape (32, 28, 28) (32 imágenes de 28×28). Queréis pasarlo por una capa nn.Linear(784, 128). ¿Qué operación necesitáis? ¿Y si luego queréis recuperar la forma original?
Ver solución
La clave: la dimensión 0 (batch) **nunca se toca**; se aplana el resto.2.4. Indexación y slicing¶
Idéntico a NumPy:
m = torch.arange(20).view(4, 5) # matriz 4x5
m[0] # primera fila → shape (5,)
m[:, 2] # tercera columna → shape (4,)
m[1:3, 1:4] # submatriz filas 1-2, cols 1-3 → shape (2, 3)
m[-1] # última fila
m[m > 10] # indexación booleana: elementos > 10 → tensor 1D
m[[0, 2], :] # filas 0 y 2 (fancy indexing)
# Un patrón que veréis en TODAS las funciones de accuracy:
logits = torch.randn(8, 10) # 8 muestras, 10 clases
preds = logits.argmax(dim=1) # índice de la clase con mayor puntuación → (8,)
Nota
dim=1 significa "reduce a lo largo de las columnas, para cada fila". Es el equivalente del axis de NumPy. Interiorizad esto: tensor.sum(dim=0) colapsa filas, tensor.sum(dim=1) colapsa columnas.
2.5. Broadcasting¶
El mismo mecanismo que aprendisteis en NumPy: cuando dos tensores de formas distintas se operan, PyTorch estira virtualmente las dimensiones de tamaño 1 (o inexistentes) para que casen, sin copiar memoria.
Reglas (de derecha a izquierda, dimensión a dimensión): dos dimensiones son compatibles si son iguales o una de ellas es 1.
a = torch.randn(32, 10) # batch de 32 muestras con 10 features
b = torch.randn(10) # un vector de 10 (p. ej. un bias)
c = a + b # b se "estira" a (32, 10) → funciona ✅
col = torch.randn(32, 1) # una columna
d = a * col # col se estira a (32, 10) → funciona ✅
mal = torch.randn(32) # shape (32,)
# a + mal # ❌ RuntimeError: The size of tensor a (10) must match
# the size of tensor b (32) at non-singleton dimension 1
Esto es exactamente lo que pasa dentro de nn.Linear: X @ W.T + b donde b tiene shape (out_features,) y se difunde sobre todo el batch. Ya lo hicisteis a mano en el capítulo 1.
2.6. Operaciones esenciales¶
x = torch.randn(3, 4)
y = torch.randn(3, 4)
# Elemento a elemento:
x + y; x * y; x / y; x ** 2 # aritmética
torch.exp(x); torch.log(x.abs()); torch.sqrt(x.abs())
torch.clamp(x, min=0) # recorta: esto ES ReLU
torch.sigmoid(x); torch.tanh(x) # las activaciones del capítulo 1
# Reducciones:
x.sum(); x.mean(); x.std() # escalares
x.sum(dim=0) # por columnas → shape (4,)
x.mean(dim=1, keepdim=True) # por filas conservando la dim → (3, 1)
x.max(dim=1) # devuelve (valores, índices) — ¡una tupla!
x.argmax(dim=1) # solo los índices
# Álgebra lineal:
A = torch.randn(3, 4)
B = torch.randn(4, 5)
C = A @ B # multiplicación de matrices → (3, 5)
C2 = torch.matmul(A, B) # equivalente
At = A.T # transpuesta
# Operaciones in-place (terminan en _; modifican el tensor SIN crear uno nuevo):
x.add_(1) # x = x + 1 in situ
x.zero_() # pone x a cero in situ (¿os suena zero_grad?)
Advertencia
las operaciones in-place (add_, mul_...) ahorran memoria pero pueden romper autograd si sobrescriben un valor que se necesitaba para el backward: RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation. Regla práctica: evitadlas dentro del forward de un modelo.
2.7. Conversión NumPy ↔ PyTorch¶
import numpy as np
arr = np.array([1.0, 2.0, 3.0]) # array NumPy (float64 por defecto)
t = torch.from_numpy(arr) # NumPy → tensor: ⚠ COMPARTEN MEMORIA
t2 = torch.tensor(arr) # NumPy → tensor: copia independiente
back = t.numpy() # tensor → NumPy: ⚠ también comparte memoria
# Demostración del peligro de la memoria compartida:
arr[0] = 999.0
print(t) # tensor([999., 2., 3.], dtype=torch.float64)
# ¡¡el tensor cambió porque cambió el array!!
Advertencia
from_numpy y .numpy() son vistas de la misma memoria: modificar uno modifica el otro. Es rapidísimo (cero copias) pero un origen clásico de bugs silenciosos. Además: (1) heredan el float64 de NumPy — convertid con .float(); (2) .numpy() falla en tensores con gradiente o en GPU: el idiom completo es t.detach().cpu().numpy(). Memorizadlo, lo escribiréis mil veces.
# El idiom universal para "sacar un tensor a NumPy" pase lo que pase:
resultado = t.detach().cpu().numpy()
# │ │ └─ convierte a NumPy
# │ └─ lo trae de la GPU a la CPU si hiciera falta
# └─ lo desconecta del grafo de gradientes si lo tuviera
2.8. Dispositivos: CPU, CUDA y MPS¶
Cada tensor vive en un device. Las operaciones entre tensores exigen que estén en el mismo.
# Detección portable del mejor device disponible — usad SIEMPRE este patrón:
def get_device() -> torch.device:
if torch.cuda.is_available(): # GPU NVIDIA (Windows/Linux)
return torch.device("cuda")
if torch.backends.mps.is_available(): # GPU Apple Silicon (M1/M2/M3/M4)
return torch.device("mps")
return torch.device("cpu") # sin GPU: todo funciona, más lento
device = get_device()
print(f"Usando: {device}")
x = torch.randn(3, 3) # nace en CPU
x_gpu = x.to(device) # copia al device (si es GPU, viaja por PCIe)
y = torch.randn(3, 3, device=device) # o nace directamente allí (más eficiente)
# print(x + y) # ❌ si device="cuda": RuntimeError: Expected all tensors to be
# on the same device, but found at least two devices, cuda:0 and cpu!
print(x_gpu + y) # ✅ ambos en el mismo device
Nota
el código de este capítulo funciona igual en CPU; solo será más lento. No necesitáis GPU para aprender. Cuando queráis una, la sección 7.4 explica cómo usar Google Colab gratis.
Ejercicio rápido 2.2: sin ejecutarlo, ¿qué shapes producen estas líneas? torch.randn(4, 1) + torch.randn(1, 5), torch.randn(3, 4).mean(dim=0), torch.randn(2, 3).unsqueeze(1).shape.
Ver solución
1. `(4, 1) + (1, 5)` → broadcasting en ambas dims → **(4, 5)**. 2. `mean(dim=0)` colapsa la dimensión 0 (las 3 filas) → **(4,)**. 3. `unsqueeze(1)` inserta una dim de tamaño 1 en la posición 1: `(2, 3)` → **(2, 1, 3)**.3. Autograd: la magia explicada¶
Aquí conectamos directamente con el capítulo 2. Allí construisteis el grafo computacional a mano y aplicasteis la regla de la cadena hacia atrás nodo a nodo. Autograd hace exactamente eso, automáticamente, para cualquier código que escribáis. No es magia: es vuestro capítulo 2 industrializado.
3.1. requires_grad y el grafo dinámico¶
# requires_grad=True le dice a PyTorch: "vigila este tensor,
# necesitaré la derivada de algo respecto a él"
w = torch.tensor(2.0, requires_grad=True) # un "peso" que queremos optimizar
x = torch.tensor(3.0) # dato de entrada: NO necesita gradiente
y = w * x # PyTorch registra: "y salió de una multiplicación de w y x"
z = y ** 2 # y registra: "z salió de elevar y al cuadrado"
print(z) # tensor(36., grad_fn=<PowBackward0>)
# ^^^^^^^^^^^^^^^^^^^^^
# ¡Ahí está el grafo! grad_fn apunta al nodo que sabe
# calcular el gradiente local de esa operación.
Cada operación sobre un tensor con requires_grad=True añade un nodo al grafo. El atributo grad_fn es la prueba visible. Esto es exactamente lo que hicisteis en el capítulo 2 cuando guardabais los valores intermedios del forward para usarlos en el backward:
graph LR
w["w = 2.0<br/>(requires_grad=True)"] --> MUL(("×"))
x["x = 3.0"] --> MUL
MUL --> y["y = 6.0<br/>grad_fn=MulBackward"]
y --> POW(("pow 2"))
POW --> z["z = 36.0<br/>grad_fn=PowBackward"]
z -. "backward(): dz/dy = 2y = 12".-> y
y -. "dz/dw = dz/dy · dy/dw = 12·3 = 36".-> w
style w fill:#e1f5fe
style z fill:#fff3e0
Dinámico significa que el grafo se construye de nuevo en cada forward, siguiendo el flujo real de vuestro código Python. Podéis meter if, bucles for de longitud variable, llamadas a funciones... y autograd siempre derivará el camino que de verdad se ejecutó. (Los grafos estáticos de TF 1.x no podían hacer esto — por eso ganó PyTorch.)
3.2. backward() y .grad¶
z.backward() # ← recorre el grafo HACIA ATRÁS aplicando la regla de la cadena
# (vuestro capítulo 2, automatizado)
print(w.grad) # tensor(36.)
# Verificación a mano: z = (w·x)² = w²x² → dz/dw = 2wx² = 2·2·9 = 36 ✅
print(x.grad) # None — x no tenía requires_grad, no se calculó nada para él
Puntos clave:
backward()solo puede llamarse sobre un escalar (como la loss). Si lo llamáis en un tensor no escalar:RuntimeError: grad can be implicitly created only for scalar outputs.- Los gradientes aparecen en el atributo
.gradde cada tensor hoja conrequires_grad=True. - Tras el backward, el grafo se libera (por eficiencia). Llamar
backward()dos veces daRuntimeError: Trying to backward through the graph a second time.
3.3. La acumulación de gradientes y zero_grad¶
Advertencia
este es el bug clásico número 1 de PyTorch. Grabáoslo a fuego: .grad NO se sobrescribe, se ACUMULA (se suma) en cada backward().
Demostración con código:
w = torch.tensor(2.0, requires_grad=True)
for paso in range(3):
y = w * 3.0 # forward: y = 3w → dy/dw = 3 siempre
y.backward() # backward
print(f"paso {paso}: w.grad = {w.grad}")
# Salida REAL:
# paso 0: w.grad = 3.0 ← correcto
# paso 1: w.grad = 6.0 ← ¡¡3 + 3!! se acumuló
# paso 2: w.grad = 9.0 ← ¡¡3 + 3 + 3!! el gradiente es 3 veces el real
Si esto ocurriera en un entrenamiento, cada paso usaría la suma de todos los gradientes históricos: la loss oscilaría o divergiría sin ningún mensaje de error. Por eso todo training loop empieza poniendo los gradientes a cero:
w = torch.tensor(2.0, requires_grad=True)
for paso in range(3):
if w.grad is not None:
w.grad.zero_() # ← ponemos el gradiente a CERO antes del backward
y = w * 3.0
y.backward()
print(f"paso {paso}: w.grad = {w.grad}")
# paso 0: 3.0 | paso 1: 3.0 | paso 2: 3.0 ✅ correcto
Con optimizadores, esto es optimizer.zero_grad() — lo veréis en la sección 6.
Nota
¿por qué acumular es el comportamiento por defecto? Porque a veces es útil: la gradient accumulation permite simular batches gigantes en GPUs pequeñas haciendo varios backward antes de un solo step(). Es una feature, no un bug... hasta que se te olvida el zero_grad().
3.4. no_grad, inference_mode y detach¶
Construir el grafo cuesta memoria y tiempo. Cuando no vais a entrenar (validación, inferencia, métricas), desactivadlo:
model_w = torch.tensor(2.0, requires_grad=True)
# Contexto no_grad: nada de lo que ocurra dentro construye grafo
with torch.no_grad():
pred = model_w * 5.0
print(pred.requires_grad) # False — sin grafo, sin coste extra
# inference_mode: versión moderna y aún más rápida (PyTorch ≥1.9).
# Úsalo para inferencia pura; sus tensores no pueden reutilizarse luego en autograd.
with torch.inference_mode():
pred = model_w * 5.0
# detach(): corta UN tensor del grafo (el resto sigue conectado)
y = model_w * 3.0
y_sin_grafo = y.detach() # mismo valor, sin historial
print(y_sin_grafo.requires_grad) # False
Cuándo usar cada uno:
| Herramienta | Ámbito | Uso típico |
|---|---|---|
torch.no_grad() |
Bloque de código | Validación durante el entrenamiento |
torch.inference_mode() |
Bloque de código | Inferencia en producción (más rápido) |
.detach() |
Un tensor concreto | Sacar la loss para logging, métricas, .numpy() |
Consejo profesional
para acumular la loss en un contador durante el entrenamiento usad total += loss.item(). .item() extrae el número Python y desconecta del grafo. Si hacéis total += loss (sin .item()), estáis guardando el grafo entero de cada batch → la memoria crece hasta el OOM. Bug clásico número 2.
3.5. El círculo se cierra: regresión lineal con autograd puro¶
En el módulo 01 hicisteis regresión lineal con gradientes calculados a mano en NumPy. Repitámosla con autograd sin usar nn todavía — solo tensores. Comparad línea a línea con vuestro código antiguo:
import torch
# ----- 1. Datos sintéticos: y = 3x + 2 + ruido -----
torch.manual_seed(42) # reproducibilidad
X = torch.rand(100, 1) * 10 # 100 puntos en [0, 10)
y = 3 * X + 2 + torch.randn(100, 1) # recta verdadera + ruido gaussiano
# ----- 2. Parámetros a aprender (¡esto en el cap. 1 era self.W y self.b!) -----
w = torch.randn(1, requires_grad=True) # pendiente, inicializada al azar
b = torch.zeros(1, requires_grad=True) # ordenada en el origen
lr = 0.01 # learning rate
# ----- 3. Bucle de entrenamiento -----
for epoch in range(200):
# FORWARD: predicción con los parámetros actuales
y_pred = X * w + b # broadcasting: (100,1)*(1,) → (100,1)
# LOSS: error cuadrático medio (la misma fórmula que programasteis)
loss = ((y_pred - y) ** 2).mean() # escalar
# BACKWARD: ¡aquí está TODO vuestro capítulo 2 en una línea!
loss.backward() # calcula dloss/dw y dloss/db
# y los deposita en w.grad y b.grad
# UPDATE: descenso de gradiente manual (aún sin optimizador)
with torch.no_grad(): # la actualización NO debe entrar al grafo
w -= lr * w.grad # w ← w - lr·∇w (vuestra fórmula del cap. 2)
b -= lr * b.grad # b ← b - lr·∇b
# ZERO GRAD: limpiamos para la siguiente iteración (sección 3.3)
w.grad.zero_()
b.grad.zero_()
if epoch % 50 == 0:
print(f"época {epoch:3d} | loss = {loss.item():.4f} | w = {w.item():.3f} | b = {b.item():.3f}")
print(f"Final: w = {w.item():.3f} (real: 3), b = {b.item():.3f} (real: 2)")
# época 0 | loss = 158.xx | ...
# Final: w ≈ 3.0, b ≈ 2.0 ✅
El círculo se cierra: la única diferencia con vuestro código NumPy del módulo 01 es que las derivadas (w.grad, b.grad) las calculó autograd en lugar de vosotros. Todo lo demás — forward, loss, update — es idéntico. En la sección siguiente, nn.Module y optim empaquetarán también el forward y el update.
Ejercicio rápido 3.1: en el código anterior, ¿qué pasaría si elimináis las dos líneas de zero_() ? ¿Y si quitáis el with torch.no_grad(): de la actualización?
Ver solución
1. **Sin `zero_()`**: los gradientes se acumulan época tras época (sección 3.3). El paso efectivo crece sin control y la loss diverge a `inf`/`nan` en pocas épocas. 2. **Sin `no_grad()`**: la operación `w -= lr * w.grad` intentaría (a) registrarse en el grafo y (b) modificar in-place una hoja con gradiente, lanzando `RuntimeError: a leaf Variable that requires grad is being used in an in-place operation`. La actualización de parámetros nunca debe formar parte del grafo.4. nn.Module a fondo¶
torch.nn es la caja de herramientas de redes neuronales. Su pieza central es nn.Module: la clase base de todo — una capa, un bloque, un modelo completo, GPT-4. Todo es un nn.Module.
4.1. Anatomía de una clase modelo¶
El contrato tiene dos partes: __init__ declara las piezas (capas con parámetros), forward define el flujo (cómo la entrada se convierte en salida). Autograd genera el backward solo.
import torch.nn as nn
class MLP(nn.Module): # 1. heredamos de nn.Module
def __init__(self, in_features: int, hidden: int, out_features: int):
super().__init__() # 2. SIEMPRE llamar al padre PRIMERO
# (registra la maquinaria interna;
# olvidarlo da un error críptico)
# 3. declaramos las capas como ATRIBUTOS:
# al asignarlas, nn.Module las "registra" automáticamente
# y sus pesos aparecerán en model.parameters()
self.fc1 = nn.Linear(in_features, hidden) # capa oculta: W1 (hidden×in) y b1
self.act = nn.ReLU() # activación (sin parámetros)
self.fc2 = nn.Linear(hidden, out_features) # capa de salida: W2 y b2
def forward(self, x: torch.Tensor) -> torch.Tensor:
# 4. el flujo de datos: código Python normal, puede tener ifs y bucles
x = self.fc1(x) # (batch, in) → (batch, hidden) [x @ W1.T + b1]
x = self.act(x) # ReLU elemento a elemento
x = self.fc2(x) # (batch, hidden) → (batch, out)
return x # devolvemos LOGITS (sin softmax — ver nota abajo)
model = MLP(in_features=20, hidden=64, out_features=2)
print(model) # imprime la arquitectura: ¡probadlo!
x = torch.randn(8, 20) # batch de 8 muestras
salida = model(x) # ⚠ se llama model(x), NUNCA model.forward(x)
print(salida.shape) # torch.Size([8, 2])
Nota (¿por qué model(x) y no model.forward(x)?)
model(x) invoca __call__, que ejecuta hooks internos y luego llama a vuestro forward. Llamar a forward directamente se salta esa maquinaria y romperá cosas más adelante (hooks, Lightning, cuantización...). Costumbre profesional: siempre model(x).
Nota (¿por qué devolver logits sin softmax?)
nn.CrossEntropyLoss ya incluye log_softmax internamente (por estabilidad numérica, como visteis en el capítulo 2). Si aplicáis softmax en el forward y luego usáis CrossEntropyLoss, lo aplicáis dos veces: el modelo aprende, pero mal. Regla: el modelo emite logits; el softmax solo se usa en inferencia si necesitáis probabilidades.
4.2. nn.Linear por dentro¶
nn.Linear(in_features, out_features) es exactamente la capa densa que programasteis:
capa = nn.Linear(20, 64)
print(capa.weight.shape) # torch.Size([64, 20]) — la matriz W (¡ojo: out×in!)
print(capa.bias.shape) # torch.Size([64]) — el vector b
print(capa.weight.requires_grad) # True — son nn.Parameter: autograd los vigila
# Lo que hace en el forward: y = x @ W.T + b
# Con x de shape (batch, 20): (batch,20) @ (20,64) + (64,) → (batch, 64)
# La inicialización por defecto es Kaiming-uniform (visteis por qué en el cap. 2:
# evitar que las activaciones exploten o se desvanezcan al inicio).
Un nn.Parameter es un tensor con requires_grad=True que, al asignarse como atributo de un módulo, queda registrado en parameters() — que es la lista que le pasaréis al optimizador.
4.3. nn.Sequential: cuándo basta¶
Si vuestro modelo es una tubería lineal (capa → capa → capa, sin bifurcaciones), nn.Sequential os ahorra la clase:
model = nn.Sequential(
nn.Linear(20, 64), # entrada → oculta
nn.ReLU(), # activación
nn.Dropout(0.2), # regularización
nn.Linear(64, 2), # oculta → salida (logits)
)
salida = model(torch.randn(8, 20)) # las capas se aplican en orden
| Situación | ¿Sequential o clase? |
|---|---|
| Tubería lineal simple (MLP básico) | nn.Sequential |
| Conexiones residuales (x + f(x)) | Clase propia |
| Varias entradas o varias salidas | Clase propia |
| Control de flujo (if/for) en el forward | Clase propia |
| Reutilizar bloques con lógica | Clase propia |
Consejo profesional
lo habitual es mezclar: una clase propia cuyo __init__ usa nn.Sequential para los bloques repetitivos. Lo veréis en el ejemplo integrador (sección 9).
4.4. Capas comunes¶
| Capa | Firma típica | Qué hace | Parámetros | ¿Dónde se estudia? |
|---|---|---|---|---|
nn.Linear |
Linear(in, out) |
Transformación afín xW.T+b |
in·out + out |
Este capítulo |
nn.ReLU |
ReLU() |
max(0, x) |
0 | Cap. 1 |
nn.Dropout |
Dropout(p=0.5) |
Apaga neuronas al azar en train (regulariza) | 0 | Este capítulo |
nn.BatchNorm1d |
BatchNorm1d(features) |
Normaliza activaciones por batch | 2·features |
Este capítulo |
nn.Embedding |
Embedding(vocab, dim) |
Tabla de vectores para índices (palabras) | vocab·dim |
Módulo 04 (NLP) |
nn.Conv2d |
Conv2d(in_ch, out_ch, kernel) |
Convolución sobre imágenes | in·out·k² + out |
Capítulo 4 (CNNs) |
nn.LSTM / nn.GRU |
LSTM(in, hidden) |
Recurrencia sobre secuencias | varios | Módulo 04 |
nn.MultiheadAttention |
MultiheadAttention(dim, heads) |
Atención (Transformers) | varios | Módulo 04 |
Advertencia
Dropout y BatchNorm se comportan distinto en entrenamiento y en evaluación. Dropout apaga neuronas solo en train; BatchNorm usa estadísticas del batch en train y estadísticas acumuladas en eval. Por eso el training loop alterna model.train() y model.eval(). Olvidar model.eval() al validar es el bug clásico número 3: las métricas de validación salen ruidosas y peores de lo real.
4.5. Contar parámetros y state_dict¶
def contar_parametros(model: nn.Module) -> tuple[int, int]:
"""Devuelve (total, entrenables). Útil para comparar arquitecturas."""
total = sum(p.numel() for p in model.parameters()) # numel = nº elementos
entrenables = sum(p.numel() for p in model.parameters() if p.requires_grad)
return total, entrenables
model = MLP(20, 64, 2)
total, tr = contar_parametros(model)
print(f"Parámetros: {total:,} (entrenables: {tr:,})")
# fc1: 20·64 + 64 = 1344 | fc2: 64·2 + 2 = 130 | total = 1474 ✅ verificadlo a mano
# state_dict: diccionario ordenado {nombre → tensor} con TODOS los pesos.
# Es la representación serializable del modelo (así se guarda y se comparte).
for nombre, tensor in model.state_dict().items():
print(f"{nombre:20s} {tuple(tensor.shape)}")
# fc1.weight (64, 20)
# fc1.bias (64,)
# fc2.weight (2, 64)
# fc2.bias (2,)
Ejercicio rápido 4.1: ¿cuántos parámetros tiene nn.Sequential(nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10))? Calculadlo a mano antes de comprobarlo con código.
Ver solución
- Capa 1: `784·256 + 256 = 200 960` (pesos + bias) - ReLU: `0` - Capa 2: `256·10 + 10 = 2 570` - **Total: 203 530**5. Datos: Dataset y DataLoader¶
En el mundo real los datos no caben en un tensor gigante en RAM, hay que barajarlos, trocearlos en batches y cargarlos en paralelo. PyTorch separa esto en dos piezas: Dataset (cómo obtener UNA muestra) y DataLoader (cómo servir BATCHES de muestras).
flowchart LR
CSV[("CSV / imágenes /<br/>base de datos")] --> DS["Dataset<br/>__len__ → nº muestras<br/>__getitem__(i) → (x_i, y_i)"]
DS --> DL["DataLoader<br/>batch_size, shuffle,<br/>num_workers"]
DL --> B1["batch 1<br/>(32, features)"]
DL --> B2["batch 2<br/>(32, features)"]
DL --> B3["batch N ..."]
B1 --> LOOP["Training loop"]
B2 --> LOOP
B3 --> LOOP
5.1. Dataset custom¶
Un Dataset solo necesita dos métodos: __len__ (cuántas muestras hay) y __getitem__ (dame la muestra i-ésima). Ejemplo realista desde un CSV con Pandas:
import pandas as pd
import torch
from torch.utils.data import Dataset
class TabularDataset(Dataset):
"""Dataset para datos tabulares desde un DataFrame de Pandas."""
def __init__(self, df: pd.DataFrame, target_col: str):
# Separamos features y objetivo:
X = df.drop(columns=[target_col]).values # ndarray float64 de NumPy
y = df[target_col].values # ndarray con las etiquetas
# Convertimos a tensores UNA VEZ aquí (no en cada __getitem__: sería lento).
# .float() → float32 para las features (lo que esperan las capas)
# .long() → int64 para etiquetas de clase (lo que exige CrossEntropyLoss)
self.X = torch.from_numpy(X).float()
self.y = torch.from_numpy(y).long()
def __len__(self) -> int:
return len(self.X) # nº total de muestras
def __getitem__(self, idx: int):
return self.X[idx], self.y[idx] # devuelve UNA muestra: (features, etiqueta)
# Uso:
df = pd.read_csv("clientes.csv") # vuestro Pandas del módulo 01
ds = TabularDataset(df, target_col="churn")
print(len(ds)) # nº de filas
x0, y0 = ds[0] # primera muestra
print(x0.shape, y0) # p. ej. torch.Size([20]) tensor(1)
Nota
para datasets de imágenes, __init__ guarda solo las rutas de los ficheros y __getitem__ lee la imagen del disco bajo demanda (lazy loading). Así podéis entrenar con millones de imágenes que no caben en RAM. Lo haréis en el capítulo 4.
5.2. DataLoader¶
from torch.utils.data import DataLoader
loader = DataLoader(
ds, # el Dataset
batch_size=32, # cuántas muestras por batch (visteis por qué en el cap. 2)
shuffle=True, # barajar cada época — IMPRESCINDIBLE en train, False en val
num_workers=0, # procesos paralelos de carga (ver advertencia Windows)
drop_last=False, # ¿descartar el último batch si es incompleto?
pin_memory=True, # acelera la copia CPU→GPU (activadlo si tenéis CUDA)
)
# Iterar es un for normal — cada vuelta es un batch ya apilado:
for X_batch, y_batch in loader:
print(X_batch.shape, y_batch.shape) # torch.Size([32, 20]) torch.Size([32])
break
| Parámetro | Valor recomendado | Por qué |
|---|---|---|
batch_size |
32-256 (potencias de 2) | Equilibrio memoria/estabilidad del gradiente |
shuffle |
True en train, False en val/test |
Romper el orden evita ciclos espurios; en val no aporta nada |
num_workers |
0 en Windows/debug; 2-8 en Linux | Carga en paralelo mientras la GPU computa |
pin_memory |
True si hay GPU |
Memoria page-locked → transferencia más rápida |
drop_last |
True en train si usáis BatchNorm |
Un último batch de 1 muestra rompe BatchNorm |
Advertencia (Windows)
num_workers > 0 crea subprocesos con spawn, lo que exige que vuestro script tenga el guard if __name__ == "__main__": y que el Dataset sea picklable. Sin el guard veréis el críptico RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. En Windows y en notebooks, empezad siempre con num_workers=0; subidlo solo cuando la carga de datos sea el cuello de botella (y en un script con el guard).
5.3. Transforms¶
Los transforms son funciones aplicadas a cada muestra al cargarla: normalizar, convertir a tensor, y — clave en visión — data augmentation (rotaciones, recortes aleatorios...). Se pasan al Dataset y se aplican dentro de __getitem__:
from torchvision import transforms
transform = transforms.Compose([ # encadena varios transforms
transforms.ToTensor(), # PIL/ndarray → tensor float32 en [0,1], y HWC → CHW
transforms.Normalize((0.1307,), (0.3081,)), # (x - media) / std por canal
])
# Se usa: datasets.MNIST(..., transform=transform)
Los tratamos de pasada: en el capítulo 4 (CNNs) los exprimiremos a fondo con augmentation.
5.4. train/val split con random_split¶
from torch.utils.data import random_split
n_total = len(ds)
n_val = int(0.2 * n_total) # 20% para validación
n_train = n_total - n_val
# generator con semilla → el split es REPRODUCIBLE entre ejecuciones
train_ds, val_ds = random_split(
ds, [n_train, n_val],
generator=torch.Generator().manual_seed(42)
)
train_loader = DataLoader(train_ds, batch_size=32, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=64, shuffle=False) # val: sin barajar,
# batch mayor (no hay backward)
Consejo profesional
con datos tabulares donde las features requieren escalado (StandardScaler del módulo 02), ajustad el scaler SOLO con el train y aplicadlo a val/test. Escalar antes de separar es data leakage: la validación "ve" estadísticas del test. En el ejemplo integrador (sección 9) lo haremos bien.
6. El training loop profesional¶
Llegamos al núcleo del capítulo. El training loop es al ingeniero de deep learning lo que la sartén al cocinero: lo usaréis todos los días. PyTorch no lo esconde tras un .fit() — os lo da explícito, y eso es una ventaja: control total.
flowchart TD
A["Inicio de época"] --> B["model.train()"]
B --> C["Batch del DataLoader<br/>X, y → device"]
C --> D["optimizer.zero_grad()<br/>(limpiar gradientes viejos)"]
D --> E["FORWARD<br/>logits = model(X)"]
E --> F["LOSS<br/>loss = criterion(logits, y)"]
F --> G["BACKWARD<br/>loss.backward()<br/>(vuestro capítulo 2)"]
G --> H["STEP<br/>optimizer.step()<br/>(vuestro Adam del cap. 2)"]
H --> I{"¿Más batches?"}
I -- "sí"--> C
I -- "no"--> J["model.eval() + no_grad()<br/>validación"]
J --> K{"¿Mejora val_loss?"}
K -- "sí"--> L["Guardar checkpoint"]
K -- "no (paciencia agotada)"--> M["EARLY STOPPING"]
L --> N{"¿Más épocas?"}
N -- "sí"--> A
N -- "no"--> M
6.1. La plantilla canónica¶
Ésta es LA plantilla. Memorizad el orden de las 5 líneas centrales — es siempre el mismo:
import torch
import torch.nn as nn
device = get_device() # sección 2.8
model = MLP(20, 64, 2).to(device) # 1. modelo al device
criterion = nn.CrossEntropyLoss() # función de pérdida (clasificación)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # vuestro Adam del cap. 2
# ← le pasamos QUÉ parámetros optimizar
n_epochs = 20
for epoch in range(n_epochs):
# ============ FASE DE ENTRENAMIENTO ============
model.train() # activa Dropout/BatchNorm en modo train
total_loss, correctos, vistos = 0.0, 0, 0
for X, y in train_loader:
X, y = X.to(device), y.to(device) # 2. batch al MISMO device que el modelo
optimizer.zero_grad() # (1) limpiar gradientes (sección 3.3)
logits = model(X) # (2) FORWARD: (batch, 2) logits
loss = criterion(logits, y) # (3) LOSS: escalar
loss.backward() # (4) BACKWARD: rellena .grad de cada parámetro
optimizer.step() # (5) UPDATE: aplica Adam a cada parámetro
# --- métricas (sin tocar el grafo: .item() y no_grad implícito en argmax de logits detach) ---
total_loss += loss.item() * X.size(0) # .item() → float Python (¡evita fuga de memoria!)
correctos += (logits.argmax(dim=1) == y).sum().item()
vistos += X.size(0)
train_loss = total_loss / vistos
train_acc = correctos / vistos
# ============ FASE DE VALIDACIÓN ============
model.eval() # Dropout OFF, BatchNorm usa stats acumuladas
val_loss, val_correctos, val_vistos = 0.0, 0, 0
with torch.no_grad(): # sin grafo: más rápido, menos memoria
for X, y in val_loader:
X, y = X.to(device), y.to(device)
logits = model(X) # solo forward — nunca backward en val
val_loss += criterion(logits, y).item() * X.size(0)
val_correctos += (logits.argmax(dim=1) == y).sum().item()
val_vistos += X.size(0)
val_loss /= val_vistos
val_acc = val_correctos / val_vistos
print(f"época {epoch+1:2d}/{n_epochs} | "
f"train: loss={train_loss:.4f} acc={train_acc:.3f} | "
f"val: loss={val_loss:.4f} acc={val_acc:.3f}")
Las 5 líneas sagradas, en orden: zero_grad → forward → loss → backward → step. Cambiad el orden y todo se rompe en silencio (p. ej., step antes de backward no actualiza nada; zero_grad después de backward borra los gradientes recién calculados).
6.2. Funciones reutilizables: train_one_epoch y evaluate¶
Copiar-pegar el loop en cada proyecto es de aficionados. Lo profesional es encapsularlo:
def train_one_epoch(model, loader, criterion, optimizer, device) -> tuple[float, float]:
"""Entrena una época completa. Devuelve (loss_media, accuracy)."""
model.train() # modo entrenamiento
total_loss, correct, seen = 0.0, 0, 0
for X, y in loader:
X, y = X.to(device), y.to(device) # datos al device
optimizer.zero_grad() # limpiar
logits = model(X) # forward
loss = criterion(logits, y) # pérdida
loss.backward() # backward
optimizer.step() # actualizar
total_loss += loss.item() * X.size(0) # acumular ponderando por batch real
correct += (logits.argmax(1) == y).sum().item() # aciertos
seen += X.size(0)
return total_loss / seen, correct / seen
@torch.no_grad() # decorador: TODO el cuerpo sin grafo
def evaluate(model, loader, criterion, device) -> tuple[float, float]:
"""Evalúa sin gradientes. Devuelve (loss_media, accuracy)."""
model.eval() # modo evaluación
total_loss, correct, seen = 0.0, 0, 0
for X, y in loader:
X, y = X.to(device), y.to(device)
logits = model(X) # solo forward
total_loss += criterion(logits, y).item() * X.size(0)
correct += (logits.argmax(1) == y).sum().item()
seen += X.size(0)
return total_loss / seen, correct / seen
Nota
multiplicamos la loss por X.size(0) y dividimos por el total al final porque el último batch suele ser más pequeño; promediar "losses de batch" sin ponderar sesga ligeramente la métrica. Detalle pequeño, hábito profesional.
6.3. Checkpoints: guardar y cargar¶
# ---------- GUARDAR ----------
checkpoint = {
"epoch": epoch, # dónde nos quedamos
"model_state": model.state_dict(), # TODOS los pesos del modelo
"optimizer_state": optimizer.state_dict(), # ⚠ estado de Adam: momentos m y v
# (¡los del capítulo 2!) — sin esto,
# reanudar el entrenamiento "olvida"
# la inercia y da un tirón a la loss
"val_loss": val_loss, # métrica del checkpoint
}
torch.save(checkpoint, "checkpoints/best.pt") # serializa con pickle+zip
# ---------- CARGAR ----------
# ⚠ map_location: si guardasteis en GPU y cargáis en una máquina sin GPU,
# sin map_location obtendréis: RuntimeError: Attempting to deserialize object
# on a CUDA device but torch.cuda.is_available() is False
ckpt = torch.load("checkpoints/best.pt", map_location=device, weights_only=False)
model.load_state_dict(ckpt["model_state"]) # restaurar pesos
optimizer.load_state_dict(ckpt["optimizer_state"]) # restaurar estado del optimizador
start_epoch = ckpt["epoch"] + 1 # reanudar donde tocaba
Advertencia
guardad siempre el state_dict, nunca torch.save(model) (el modelo entero). Guardar el objeto completo ata el fichero a la ruta exacta de vuestra clase (pickle guarda mi_proyecto.models.MLP); si movéis o renombráis el módulo, el fichero se vuelve incargable: ModuleNotFoundError al cargar. El state_dict son solo tensores con nombre: portable y robusto.
Nota
desde PyTorch 2.6, torch.load usa weights_only=True por defecto (por seguridad: pickle puede ejecutar código arbitrario). Si vuestro checkpoint tiene objetos extra (como el diccionario de arriba), pasad weights_only=False solo si el fichero es vuestro o de una fuente de confianza.
6.4. Early stopping¶
Del capítulo 2 sabéis que el sobreajuste se manifiesta cuando la val_loss empieza a subir mientras la train_loss sigue bajando. Early stopping corta el entrenamiento ahí:
class EarlyStopping:
"""Detiene el entrenamiento si val_loss no mejora en `patience` épocas."""
def __init__(self, patience: int = 5, min_delta: float = 0.0):
self.patience = patience # cuántas épocas sin mejora toleramos
self.min_delta = min_delta # mejora mínima para "contar" como mejora
self.best = float("inf") # mejor val_loss vista
self.counter = 0 # épocas seguidas sin mejorar
self.should_stop = False
def step(self, val_loss: float) -> bool:
"""Llamar tras cada época. Devuelve True si hubo mejora (→ guardar checkpoint)."""
if val_loss < self.best - self.min_delta: # ¿mejoró lo suficiente?
self.best = val_loss # nueva mejor marca
self.counter = 0 # reiniciar paciencia
return True # ¡mejoró! → checkpoint
self.counter += 1 # una época más sin mejora
if self.counter >= self.patience: # paciencia agotada
self.should_stop = True
return False
# Uso en el loop:
stopper = EarlyStopping(patience=5)
for epoch in range(100): # tope alto: el stopper cortará antes
train_loss, _ = train_one_epoch(model, train_loader, criterion, optimizer, device)
val_loss, _ = evaluate(model, val_loader, criterion, device)
if stopper.step(val_loss): # ¿mejoró?
torch.save({"model_state": model.state_dict()}, "best.pt") # guardar el MEJOR
if stopper.should_stop:
print(f"Early stopping en la época {epoch+1}")
break
Consejo profesional
al terminar, recargad el checkpoint del mejor modelo antes de evaluar en test. El modelo en memoria al parar es el de la última época (ya sobreajustado), no el mejor.
6.5. Curvas de pérdida con matplotlib¶
import matplotlib.pyplot as plt
historia = {"train_loss": [], "val_loss": [], "train_acc": [], "val_acc": []}
# ... dentro del loop, tras cada época:
# historia["train_loss"].append(train_loss) (etc.)
def plot_historia(h: dict):
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) # dos gráficos lado a lado
epocas = range(1, len(h["train_loss"]) + 1)
ax1.plot(epocas, h["train_loss"], label="train") # pérdida de entrenamiento
ax1.plot(epocas, h["val_loss"], label="val") # pérdida de validación
ax1.set_xlabel("época"); ax1.set_ylabel("loss")
ax1.set_title("Curvas de pérdida"); ax1.legend(); ax1.grid(alpha=0.3)
ax2.plot(epocas, h["train_acc"], label="train")
ax2.plot(epocas, h["val_acc"], label="val")
ax2.set_xlabel("época"); ax2.set_ylabel("accuracy")
ax2.set_title("Accuracy"); ax2.legend(); ax2.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("curvas.png", dpi=120) # guardad SIEMPRE la figura
plt.show()
Cómo leerlas (repaso del capítulo 2): ambas bajan juntas → todo bien, seguid. val se estanca o sube mientras train baja → sobreajuste (más dropout, más datos, early stopping). Ambas altas y planas → infraajuste (modelo más grande, más lr, más épocas). Loss en zigzag violento → lr demasiado alto.
6.6. Reproducibilidad¶
Los resultados en DL tienen tres fuentes de azar: inicialización de pesos, barajado de datos y no-determinismo de la GPU. Fijadlas todas:
import random
import numpy as np
import torch
def set_seed(seed: int = 42):
"""Fija todas las semillas relevantes. Llamad a esto ANTES de crear el modelo."""
random.seed(seed) # random de Python (splits, sampling propio)
np.random.seed(seed) # NumPy (datos sintéticos, sklearn)
torch.manual_seed(seed) # CPU: inicialización de pesos, dropout
torch.cuda.manual_seed_all(seed) # todas las GPUs
# Determinismo estricto en GPU (más lento; solo si lo necesitáis de verdad):
# torch.backends.cudnn.deterministic = True
# torch.backends.cudnn.benchmark = False
set_seed(42)
Nota
ni con todo esto dos GPUs de modelos distintos darán bits idénticos (los kernels difieren). La reproducibilidad exacta multi-máquina es un problema abierto; lo que sí conseguís es reproducibilidad en vuestra máquina, que es lo que necesitáis para depurar.
6.7. Aplicación 1: clasificador de churn tabular¶
Juntemos todo con un dataset sintético de churn (abandono de clientes) — el mismo problema de negocio del módulo 02, ahora con red neuronal:
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader, random_split
set_seed(42)
# ----- 1. Dataset sintético de churn (en la práctica: pd.read_csv) -----
n = 2000
rng = np.random.default_rng(42)
df = pd.DataFrame({
"antiguedad_meses": rng.integers(1, 72, n), # meses como cliente
"gasto_mensual": rng.uniform(10, 120, n), # facturación media
"tickets_soporte": rng.poisson(1.5, n), # incidencias abiertas
"uso_semanal_h": rng.exponential(5, n), # horas de uso
})
# Regla generadora oculta: churn ↑ si poca antigüedad, muchos tickets y poco uso
logit = (-0.04 * df["antiguedad_meses"] + 0.5 * df["tickets_soporte"]
- 0.15 * df["uso_semanal_h"] + 0.5)
df["churn"] = (1 / (1 + np.exp(-logit)) > rng.uniform(0, 1, n)).astype(int)
# ----- 2. Escalado SIN leakage: media/std calculadas solo sobre train -----
features = ["antiguedad_meses", "gasto_mensual", "tickets_soporte", "uso_semanal_h"]
n_train = int(0.8 * n)
idx = rng.permutation(n) # barajamos índices
train_idx, val_idx = idx[:n_train], idx[n_train:]
mu = df.loc[train_idx, features].mean() # estadísticas SOLO de train
sigma = df.loc[train_idx, features].std()
df[features] = (df[features] - mu) / sigma # aplicamos a todo
# ----- 3. Dataset y DataLoaders (reutilizamos TabularDataset de la sección 5.1) -----
train_ds = TabularDataset(df.iloc[train_idx], "churn")
val_ds = TabularDataset(df.iloc[val_idx], "churn")
train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=128, shuffle=False)
# ----- 4. Modelo, pérdida, optimizador -----
device = get_device()
model = nn.Sequential(
nn.Linear(4, 32), nn.ReLU(), nn.Dropout(0.2), # bloque oculto 1
nn.Linear(32, 16), nn.ReLU(), # bloque oculto 2
nn.Linear(16, 2), # logits: [no-churn, churn]
).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# ----- 5. Entrenamiento con early stopping (funciones de 6.2 y 6.4) -----
stopper = EarlyStopping(patience=5)
for epoch in range(100):
tl, ta = train_one_epoch(model, train_loader, criterion, optimizer, device)
vl, va = evaluate(model, val_loader, criterion, device)
print(f"época {epoch+1:2d} | train loss={tl:.4f} acc={ta:.3f} | val loss={vl:.4f} acc={va:.3f}")
if stopper.step(vl):
torch.save({"model_state": model.state_dict()}, "churn_best.pt")
if stopper.should_stop:
print(f"Early stopping (mejor val_loss={stopper.best:.4f})")
break
En 15-25 épocas deberíais rondar 0.75-0.85 de accuracy de validación (el problema tiene ruido irreducible a propósito — como los datos reales).
6.8. Aplicación 2: MNIST¶
El "hola mundo" de la visión: 70 000 imágenes de dígitos manuscritos 28×28. Con torchvision es una línea (descarga ~12 MB la primera vez a ./data):
from torchvision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(), # imagen PIL → tensor (1,28,28) en [0,1]
transforms.Normalize((0.1307,), (0.3081,)), # media/std canónicas de MNIST
])
# download=True descarga la primera vez; después usa la copia local
train_full = datasets.MNIST("./data", train=True, download=True, transform=transform)
test_ds = datasets.MNIST("./data", train=False, download=True, transform=transform)
train_ds, val_ds = random_split(train_full, [55_000, 5_000],
generator=torch.Generator().manual_seed(42))
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=256, shuffle=False)
test_loader = DataLoader(test_ds, batch_size=256, shuffle=False)
# MLP para imágenes: hay que APLANAR los píxeles (28·28 = 784 features)
model = nn.Sequential(
nn.Flatten(), # (batch, 1, 28, 28) → (batch, 784) ← ¡capa clave!
nn.Linear(784, 256), nn.ReLU(), nn.Dropout(0.2),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, 10), # 10 logits: dígitos 0-9
).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(5): # 5 épocas bastan para ~97-98%
tl, ta = train_one_epoch(model, train_loader, criterion, optimizer, device)
vl, va = evaluate(model, val_loader, criterion, device)
print(f"época {epoch+1} | train acc={ta:.4f} | val acc={va:.4f}")
test_loss, test_acc = evaluate(model, test_loader, criterion, device)
print(f"TEST accuracy: {test_acc:.4f}") # ~0.97-0.98 con un simple MLP
Nota
un MLP "aplana" la imagen y pierde toda la estructura espacial (qué píxel está al lado de cuál). Aun así saca ~98% en MNIST porque es un dataset fácil. En el capítulo 4 veréis cómo las CNNs explotan esa estructura espacial y por qué son imprescindibles en imágenes reales.
Ejercicio rápido 6.1: en la plantilla canónica, ¿qué tres cosas diferencian la fase de validación de la de entrenamiento? ¿Qué pasaría si olvidáis cada una?
Ver solución
1. **`model.eval()`** — sin él, Dropout sigue apagando neuronas y BatchNorm usa stats del batch: métricas de validación ruidosas y pesimistas. 2. **`torch.no_grad()`** — sin él, se construye el grafo en cada forward: gasto inútil de memoria y tiempo (y riesgo de OOM con modelos grandes). Las métricas salen igual, pero pagáis de más. 3. **No hay `backward()` ni `step()`** — si por error los llamarais en validación, estaríais *entrenando con los datos de validación*: leakage total, métricas infladas, modelo inservible para estimar generalización.7. GPU en la práctica¶
7.1. El patrón correcto para mover cosas¶
Dos reglas y ninguna excepción:
device = get_device()
# REGLA 1: el modelo se mueve UNA vez, antes de crear el optimizador
model = MLP(20, 64, 2).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# ⚠ Si hacéis .to(device) DESPUÉS de crear el optimizador con un modelo en CPU,
# en algunos flujos el optimizador queda apuntando a tensores viejos. Orden seguro:
# modelo → device → optimizador.
# REGLA 2: cada batch se mueve DENTRO del loop (los datos completos no caben en VRAM)
for X, y in train_loader:
X = X.to(device, non_blocking=True) # non_blocking + pin_memory=True en el
y = y.to(device, non_blocking=True) # DataLoader → copia asíncrona (más rápido)
...
Nota
model.to(device) mueve el módulo in-place (y también devuelve la referencia), pero tensor.to(device) devuelve un tensor nuevo: X.to(device) sin reasignar no hace nada. Escribid siempre X = X.to(device).
7.2. Medir el speedup¶
import time
def benchmark(device_str: str, n: int = 4096, reps: int = 50) -> float:
device = torch.device(device_str)
a = torch.randn(n, n, device=device) # matriz grande
b = torch.randn(n, n, device=device)
for _ in range(3): # calentamiento (compila kernels)
_ = a @ b
if device.type == "cuda":
torch.cuda.synchronize() # ⚠ CUDA es ASÍNCRONO: sin sync,
# medís el tiempo de ENCOLAR, no de ejecutar
t0 = time.perf_counter()
for _ in range(reps):
_ = a @ b
if device.type == "cuda":
torch.cuda.synchronize() # esperar a que la GPU termine de verdad
return (time.perf_counter() - t0) / reps
t_cpu = benchmark("cpu")
print(f"CPU: {t_cpu*1000:.1f} ms por matmul 4096x4096")
if torch.cuda.is_available():
t_gpu = benchmark("cuda")
print(f"GPU: {t_gpu*1000:.1f} ms — speedup: {t_cpu/t_gpu:.0f}x")
# Órdenes de magnitud típicos: CPU ~500-2000 ms; GPU moderna ~5-30 ms → 30-100x
Consejo profesional
la GPU brilla con operaciones grandes y en paralelo. En un modelo diminuto con batches pequeños, el coste de copiar datos CPU→GPU puede comerse el beneficio y la GPU llega a ser más lenta. Medid antes de asumir.
7.3. Errores típicos con devices¶
El error más famoso de PyTorch, aprendedlo a leer:
RuntimeError: Expected all tensors to be on the same device,
but found at least two devices, cuda:0 and cpu!
Traducción: en una operación se juntaron un tensor en GPU (cuda:0) y otro en CPU. Los sospechosos habituales:
| Sospechoso | Cómo se cuela | Solución |
|---|---|---|
| El batch | Olvidasteis X = X.to(device) en el loop |
Moverlo al inicio del loop |
| Las etiquetas | Movisteis X pero no y | Mover ambos |
| El modelo | Nunca hicisteis model.to(device) |
Moverlo tras crearlo |
| Tensor auxiliar | Un torch.tensor(...) creado a mitad de forward (nace en CPU) |
Crearlo con device=x.device |
| Checkpoint | Cargado sin map_location |
torch.load(..., map_location=device) |
7.4. Colab: GPU gratis¶
Si no tenéis GPU, Google Colab os presta una (T4 típicamente) gratis:
- Id a
colab.research.google.come iniciad sesión con vuestra cuenta Google. - Nuevo notebook → menú Entorno de ejecución → Cambiar tipo de entorno de ejecución → T4 GPU → Guardar.
- Verificad:
!nvidia-smien una celda (muestra la GPU) ytorch.cuda.is_available()→True. - PyTorch ya viene preinstalado. Subid vuestros datos con el panel lateral de archivos o montad Google Drive:
from google.colab import drive; drive.mount('/content/drive').
Advertencia
las sesiones gratuitas se desconectan tras un tiempo de inactividad (y como máximo ~12 h): guardad checkpoints en Drive periódicamente, no en el disco efímero de la sesión, o perderéis el entrenamiento.
7.5. Mixed precision con torch.autocast¶
Recordáis la tabla de dtypes: bfloat16 ocupa la mitad que float32 con el mismo rango. El mixed precision ejecuta las operaciones tolerantes (matmuls, convoluciones) en 16 bits y mantiene en 32 las delicadas (reducciones, la loss). Resultado: 1.5-3x más rápido y la mitad de memoria, con precisión final prácticamente idéntica.
from torch.amp import autocast, GradScaler
scaler = GradScaler("cuda") # solo necesario con float16 (no bf16):
# escala la loss para que los gradientes
# pequeños no se hagan 0 en 16 bits
for X, y in train_loader:
X, y = X.to(device), y.to(device)
optimizer.zero_grad()
with autocast("cuda", dtype=torch.bfloat16): # ← dentro: ops en 16 bits donde es seguro
logits = model(X) # forward en precisión mixta
loss = criterion(logits, y)
# Con bfloat16 (GPUs Ampere+, 2020 en adelante) basta:
loss.backward()
optimizer.step()
# Con float16 (GPUs antiguas) usad el scaler:
# scaler.scale(loss).backward()
# scaler.step(optimizer)
# scaler.update()
Cuándo usarlo: siempre que entrenéis en GPU modelos medianos/grandes en CUDA. Cuándo no: en CPU (sin beneficio), en modelos diminutos (overhead), o si depuráis un problema numérico (volved a float32 para descartar).
8. torch.compile: PyTorch 2.x¶
La gran novedad de PyTorch 2.x: compilación JIT opcional del modelo, manteniendo el modo eager para todo lo demás.
model = MLP(784, 256, 10).to(device)
model = torch.compile(model) # ← UNA línea. Eso es todo.
# El resto del código (training loop, checkpoints...) no cambia NADA.
Qué hace por dentro: la primera vez que llega un batch, TorchDynamo traza vuestro forward y captura el grafo de operaciones; TorchInductor lo compila a kernels optimizados (Triton en GPU, C++/OpenMP en CPU), fusionando operaciones (p. ej. linear+relu+dropout en un solo kernel: menos viajes a memoria, que es el verdadero cuello de botella en GPU). Las llamadas siguientes usan el código compilado.
| Situación | ¿Ayuda torch.compile? |
|---|---|
| Modelo grande, muchas épocas, GPU | Típicamente 1.3-2x más rápido |
| Transformers / CNNs estándar | Muy bien soportado |
| Modelo diminuto o pocas iteraciones | La compilación inicial (segundos-minutos) no se amortiza |
| Shapes de entrada que cambian constantemente | Recompila en cada shape nueva (usad dynamic=True) |
| Código con muchos ifs dependientes de datos | "Graph breaks": funciona pero acelera menos |
| Depuración | Los stack traces se vuelven crípticos: desactivadlo al depurar |
Advertencia (Windows)
el backend Inductor para GPU se apoya en Triton, cuyo soporte en Windows nativo ha sido históricamente limitado (en Linux y WSL2 funciona de maravilla). En Windows puro puede que torch.compile caiga a un modo parcial o falle según versión. Es honesto decirlo: si entrenáis en serio con compilación, hacedlo en Linux/WSL2/Colab. Vuestro código sigue siendo válido: si compile falla, quitad esa línea y todo funciona igual (más lento).
Consejo profesional
patrón robusto multi-plataforma:
import sys
if sys.platform != "win32": # compilar solo fuera de Windows
model = torch.compile(model)
9. Ejemplo integrador: pipeline completo de clasificación tabular¶
Todo el capítulo en un solo script realista y ejecutable: predicción de impago de préstamos (~1000 filas sintéticas). Dataset custom desde Pandas, DataLoader, MLP con dropout+batchnorm, AdamW+scheduler, early stopping, checkpoint del mejor modelo, métricas de sklearn (puente al módulo 02) y guardado para inferencia.
"""
pipeline_impago.py — Pipeline PyTorch completo de clasificación tabular.
Ejecutar: python pipeline_impago.py
"""
import random
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from sklearn.metrics import classification_report, roc_auc_score # módulo 02 al rescate
# ============================================================
# 0. REPRODUCIBILIDAD Y DEVICE
# ============================================================
def set_seed(seed: int = 42):
random.seed(seed) # azar de Python
np.random.seed(seed) # azar de NumPy
torch.manual_seed(seed) # azar de PyTorch (CPU)
torch.cuda.manual_seed_all(seed) # azar de PyTorch (GPU)
def get_device() -> torch.device:
if torch.cuda.is_available():
return torch.device("cuda")
if torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
set_seed(42)
device = get_device()
print(f"Device: {device}")
# ============================================================
# 1. DATOS SINTÉTICOS (~1000 préstamos)
# En un proyecto real: df = pd.read_csv("prestamos.csv")
# ============================================================
n = 1000
rng = np.random.default_rng(42)
df = pd.DataFrame({
"ingresos_anuales": rng.lognormal(10.3, 0.5, n), # distribución realista de ingresos
"importe_prestamo": rng.uniform(1_000, 50_000, n), # importe solicitado
"edad": rng.integers(21, 70, n), # edad del solicitante
"antiguedad_laboral": rng.integers(0, 30, n), # años en el empleo actual
"num_impagos_previos": rng.poisson(0.4, n), # historial crediticio
"ratio_deuda_ingresos": rng.uniform(0.05, 0.8, n), # deuda total / ingresos
})
# Regla generadora del impago (oculta para el modelo):
score = (-0.00003 * df["ingresos_anuales"] + 0.00004 * df["importe_prestamo"]
+ 1.2 * df["num_impagos_previos"] + 2.5 * df["ratio_deuda_ingresos"]
- 0.05 * df["antiguedad_laboral"] - 0.5)
prob = 1 / (1 + np.exp(-score)) # sigmoide → probabilidad
df["impago"] = (prob > rng.uniform(0, 1, n)).astype(int) # muestreo → etiqueta 0/1
print(f"Tasa de impago: {df['impago'].mean():.1%}") # dataset desbalanceado, como en la realidad
# ============================================================
# 2. SPLIT TRAIN/VAL/TEST + ESCALADO SIN LEAKAGE
# ============================================================
FEATURES = [c for c in df.columns if c != "impago"]
idx = rng.permutation(n) # barajar índices
n_tr, n_va = int(0.7 * n), int(0.15 * n) # 70/15/15
tr_idx, va_idx, te_idx = idx[:n_tr], idx[n_tr:n_tr+n_va], idx[n_tr+n_va:]
mu = df.loc[tr_idx, FEATURES].mean() # estadísticas SOLO del train
sigma = df.loc[tr_idx, FEATURES].std() + 1e-8 # +eps por si alguna std es 0
df[FEATURES] = (df[FEATURES] - mu) / sigma # estandarizar todo con stats de train
# Guardamos el scaler: EN INFERENCIA hay que aplicar EXACTAMENTE la misma transformación
scaler = {"mu": mu.to_dict(), "sigma": sigma.to_dict(), "features": FEATURES}
# ============================================================
# 3. DATASET Y DATALOADERS
# ============================================================
class LoanDataset(Dataset):
"""Dataset tabular: convierte un DataFrame en tensores una sola vez."""
def __init__(self, frame: pd.DataFrame):
self.X = torch.from_numpy(frame[FEATURES].values).float() # float64→float32
self.y = torch.from_numpy(frame["impago"].values).long() # int64 para CrossEntropy
def __len__(self):
return len(self.X)
def __getitem__(self, i):
return self.X[i], self.y[i]
train_loader = DataLoader(LoanDataset(df.iloc[tr_idx]), batch_size=64,
shuffle=True, drop_last=True) # drop_last: protege a BatchNorm
val_loader = DataLoader(LoanDataset(df.iloc[va_idx]), batch_size=128, shuffle=False)
test_loader = DataLoader(LoanDataset(df.iloc[te_idx]), batch_size=128, shuffle=False)
# ============================================================
# 4. MODELO: MLP con BatchNorm + Dropout
# ============================================================
class LoanMLP(nn.Module):
def __init__(self, in_features: int, hidden: int = 64, p_drop: float = 0.3):
super().__init__()
self.net = nn.Sequential(
nn.Linear(in_features, hidden), # entrada → oculta 1
nn.BatchNorm1d(hidden), # normaliza activaciones → entrena más estable
nn.ReLU(), # no linealidad
nn.Dropout(p_drop), # regularización (solo activa en train)
nn.Linear(hidden, hidden // 2), # oculta 1 → oculta 2
nn.BatchNorm1d(hidden // 2),
nn.ReLU(),
nn.Dropout(p_drop),
nn.Linear(hidden // 2, 2), # → 2 logits: [paga, impaga]
)
def forward(self, x):
return self.net(x) # logits crudos (CrossEntropy hace el softmax)
model = LoanMLP(len(FEATURES)).to(device)
print(f"Parámetros: {sum(p.numel() for p in model.parameters()):,}")
# ============================================================
# 5. PÉRDIDA, OPTIMIZADOR Y SCHEDULER
# ============================================================
criterion = nn.CrossEntropyLoss()
# AdamW = Adam con weight decay CORRECTO (desacoplado). Estándar actual.
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
# Scheduler: si val_loss se estanca 3 épocas, divide el lr entre 2 (afinado fino)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min",
factor=0.5, patience=3)
# ============================================================
# 6. FUNCIONES DE ENTRENAMIENTO Y EVALUACIÓN
# ============================================================
def train_one_epoch(model, loader):
model.train() # Dropout/BatchNorm en modo train
total, seen = 0.0, 0
for X, y in loader:
X, y = X.to(device), y.to(device) # batch al device
optimizer.zero_grad() # 1) limpiar gradientes
loss = criterion(model(X), y) # 2) forward + 3) loss
loss.backward() # 4) backward (autograd)
optimizer.step() # 5) actualizar pesos (AdamW)
total += loss.item() * X.size(0) # acumular (item() = sin grafo)
seen += X.size(0)
return total / seen
@torch.no_grad()
def evaluate(model, loader):
model.eval() # modo evaluación
total, correct, seen = 0.0, 0, 0
for X, y in loader:
X, y = X.to(device), y.to(device)
logits = model(X)
total += criterion(logits, y).item() * X.size(0)
correct += (logits.argmax(1) == y).sum().item()
seen += X.size(0)
return total / seen, correct / seen
# ============================================================
# 7. LOOP CON EARLY STOPPING Y CHECKPOINT DEL MEJOR
# ============================================================
best_val, patience, bad_epochs = float("inf"), 8, 0
historia = {"train": [], "val": []}
for epoch in range(1, 101):
tr_loss = train_one_epoch(model, train_loader)
va_loss, va_acc = evaluate(model, val_loader)
scheduler.step(va_loss) # el scheduler mira la val_loss
historia["train"].append(tr_loss)
historia["val"].append(va_loss)
lr_actual = optimizer.param_groups[0]["lr"] # lr vigente (por si bajó)
print(f"época {epoch:3d} | train={tr_loss:.4f} | val={va_loss:.4f} "
f"| val_acc={va_acc:.3f} | lr={lr_actual:.1e}")
if va_loss < best_val: # ¿mejor modelo hasta ahora?
best_val, bad_epochs = va_loss, 0
torch.save({ # checkpoint COMPLETO
"model_state": model.state_dict(), # pesos
"optimizer_state": optimizer.state_dict(), # momentos de AdamW
"scaler": scaler, # ¡el escalado va CON el modelo!
"epoch": epoch,
"val_loss": va_loss,
}, "impago_best.pt")
else:
bad_epochs += 1
if bad_epochs >= patience:
print(f"Early stopping en época {epoch} (mejor val={best_val:.4f})")
break
# ============================================================
# 8. EVALUACIÓN FINAL: recargar el MEJOR modelo y medir en TEST
# ============================================================
ckpt = torch.load("impago_best.pt", map_location=device, weights_only=False)
model.load_state_dict(ckpt["model_state"]) # volvemos al mejor punto
model.eval()
y_true, y_pred, y_prob = [], [], []
with torch.no_grad():
for X, y in test_loader:
logits = model(X.to(device))
probs = torch.softmax(logits, dim=1)[:, 1] # prob. de impago (clase 1)
y_true += y.tolist()
y_pred += logits.argmax(1).cpu().tolist() # ¡.cpu() antes de salir de torch!
y_prob += probs.cpu().tolist()
# Métricas de sklearn (módulo 02): precision, recall, f1 y AUC
print(classification_report(y_true, y_pred, target_names=["paga", "impaga"]))
print(f"ROC-AUC: {roc_auc_score(y_true, y_prob):.3f}")
# ============================================================
# 9. INFERENCIA SOBRE UN CLIENTE NUEVO (así se usa en producción)
# ============================================================
nuevo = pd.DataFrame([{
"ingresos_anuales": 28_000, "importe_prestamo": 22_000, "edad": 29,
"antiguedad_laboral": 1, "num_impagos_previos": 2, "ratio_deuda_ingresos": 0.55,
}])
# MISMO escalado que en entrenamiento (por eso lo guardamos en el checkpoint):
sc = ckpt["scaler"]
for c in sc["features"]:
nuevo[c] = (nuevo[c] - sc["mu"][c]) / sc["sigma"][c]
x_nuevo = torch.from_numpy(nuevo[sc["features"]].values).float().to(device)
with torch.inference_mode(): # inferencia pura: lo más rápido
prob_impago = torch.softmax(model(x_nuevo), dim=1)[0, 1].item()
print(f"Probabilidad de impago del nuevo solicitante: {prob_impago:.1%}")
Puntos clave del pipeline que os llevaréis a cualquier proyecto real:
- El scaler viaja dentro del checkpoint — un modelo sin su preprocesado es inservible en producción.
- Se evalúa en test con el mejor checkpoint, no con el último modelo.
- sklearn convive con PyTorch sin fricción: PyTorch entrena, sklearn mide. Cada herramienta en lo suyo.
drop_last=Trueen train porque BatchNorm lanzaValueError: Expected more than 1 value per channelsi le llega un batch de tamaño 1.
Ejercicio rápido 9.1: modificad el pipeline para que el problema sea desbalanceado severo (5% de impagos) y compensadlo. Pista: nn.CrossEntropyLoss acepta un argumento weight.
Ver solución
# 1. Al generar los datos, restad más al score: score = (... - 2.5) → ~5% positivos.
# 2. Pesos inversos a la frecuencia de cada clase (calculados SOLO en train):
frec = df.iloc[tr_idx]["impago"].value_counts(normalize=True).sort_index()
pesos = torch.tensor([1.0 / frec[0], 1.0 / frec[1]], dtype=torch.float32).to(device)
criterion = nn.CrossEntropyLoss(weight=pesos) # los errores en la clase rara pesan más
# 3. Y evaluad con recall/F1 de la clase positiva y AUC, no con accuracy
# (un modelo que dice siempre "paga" ya tendría 95% de accuracy — módulo 02).
10. Caso empresarial: cómo estructura su código un equipo profesional¶
Caso empresarial
una fintech tiene un modelo de scoring como el de la sección 9 en producción. El primer prototipo vivía en un notebook de 900 celdas de un único data scientist. Cuando esa persona cambió de empresa, nadie pudo reentrenar el modelo: rutas hardcodeadas, celdas que solo funcionaban ejecutadas en cierto orden, sin semillas, sin versiones de datos. La empresa tardó 3 meses en reconstruirlo. Desde entonces, su política: los notebooks son para explorar; lo que se entrena y despliega vive en un repositorio con esta estructura:
proyecto-scoring/
├── configs/
│ ├── base.yaml # hiperparámetros: lr, batch_size, hidden, seed...
│ └── experimento_042.yaml # cada experimento = un fichero de config versionado
├── src/
│ ├── data/
│ │ ├── dataset.py # los Dataset custom (LoanDataset...)
│ │ └── preprocess.py # escalado, splits, features — SIN leakage y testeado
│ ├── models/
│ │ └── mlp.py # las clases nn.Module (LoanMLP...)
│ ├── engine/
│ │ ├── train.py # train_one_epoch, evaluate, EarlyStopping
│ │ └── checkpoint.py # guardar/cargar con scaler incluido
│ └── train.py # punto de entrada: python -m src.train --config configs/base.yaml
├── tests/
│ └── test_model.py # p. ej.: "el forward con (8, 6) devuelve (8, 2)"
├── notebooks/
│ └── 01_exploracion.ipynb # exploración SÍ, entrenamiento de producción NO
├── checkpoints/ # .pt (en .gitignore; van a S3/MLflow, no a git)
└── requirements.txt # torch==2.x.x pineado — reproducibilidad
flowchart TD
NB["Notebook<br/>exploración y prototipo"] -->|"funciona → se refactoriza"|SRC["src/<br/>código testeado y versionado"]
CFG["configs/*.yaml<br/>hiperparámetros"] --> TRAIN["src/train.py"]
SRC --> TRAIN
TRAIN --> CKPT["checkpoints + métricas<br/>(MLflow / W&B)"]
CKPT --> DEPLOY["servicio de inferencia<br/>(API, batch...)"]
TESTS["tests/"] -.->|CI en cada commit|SRC
Por qué los notebooks no van a producción: (1) el estado oculto entre celdas hace los resultados irreproducibles; (2) no se pueden testear ni revisar en un PR con diff legible; (3) no se pueden ejecutar con cron/orquestadores de forma fiable; (4) mezclan exploración, entrenamiento y visualización en un solo artefacto imposible de mantener.
¿Y cuándo escribir el training loop a mano vs usar una abstracción? Tabla de decisión honesta:
| Opción | Qué es | Elegidla cuando | Evitadla cuando |
|---|---|---|---|
| PyTorch puro (este capítulo) | Vosotros escribís el loop | Aprendizaje, investigación, loops no estándar, control total, dependencias mínimas | El boilerplate os frena y el loop es estándar |
| PyTorch Lightning | Framework que estructura el loop (LightningModule, Trainer) |
Proyectos de equipo, multi-GPU/distribuido sin dolor, logging integrado | Loops muy exóticos donde lucháis contra el framework |
| Hugging Face Trainer | Loop preconstruido para modelos Transformers | Fine-tuning de modelos preentrenados de NLP/visión (módulo 04) | Modelos propios no-transformer, datos tabulares |
| API externa (OpenAI, Anthropic...) | No entrenáis nada: consumís un modelo servido | El problema lo resuelve un modelo fundacional y no hay restricción de datos/coste/latencia | Datos sensibles que no pueden salir, necesidad de modelo propio, coste por token prohibitivo a escala |
Consejo profesional
la progresión sana es exactamente la de este máster: primero PyTorch puro (sabéis qué pasa), luego Lightning cuando el boilerplate se repita (sabréis qué os está abstrayendo), y HF Trainer cuando toquéis transformers. Quien empieza por arriba se estrella en el primer error de shapes.
11. Buenas prácticas¶
- Fijad las semillas al principio de cada script (
set_seed(42)) — sin reproducibilidad no hay depuración posible. - Escribid código device-agnóstico:
device = get_device()una vez y.to(device)en modelo y batches. El mismo script debe correr en CPU, CUDA y MPS. - Imprimid shapes al construir un modelo nuevo: un
print(x.shape)en cada paso del forward la primera vez os ahorra el 80% de los errores. - Guardad
state_dict(modelo Y optimizador), nunca el modelo entero, y cargad siempre conmap_location. - Guardad el preprocesado junto al modelo (scaler, vocabulario, orden de columnas): un modelo sin su preprocesado es chatarra.
model.eval()+torch.no_grad()en toda evaluación, sin excepciones.- Usad
.item()para extraer escalares de la loss para logging; jamás acumuléis tensores con grafo. - Empezad simple: overfitead primero un batch pequeño (si el modelo no puede memorizar 32 muestras, hay un bug); luego escalad.
- Monitorizad SIEMPRE train Y validación: una sola curva no dice nada sobre sobreajuste.
- Versionad configs e hiperparámetros (ficheros yaml + git), no los llevéis en la cabeza ni en celdas de notebook.
12. Malas prácticas¶
- Copiar código de PyTorch de 2019 (Variable,
.data,torch.autograd.Variable): API muerta que aún infesta Stack Overflow. - Aplicar softmax en el forward y usar CrossEntropyLoss: doble softmax, aprendizaje degradado y silencioso.
- Llamar
model.forward(x)en vez demodel(x): se salta los hooks; funcionará hasta el día que no. - Hardcodear
"cuda"por todo el código: el script muere en cualquier máquina sin GPU (AssertionError: Torch not compiled with CUDA enabledo similar). - Escalar los datos antes del split train/test: data leakage — vuestras métricas mienten.
num_workers=8en Windows sin el guardif __name__ == "__main__":: crash críptico asegurado.- Entrenar sin checkpoints "porque son solo 2 horas": hasta que se corta la sesión de Colab en el minuto 110.
- Medir tiempos de GPU sin
torch.cuda.synchronize(): benchmarks de fantasía (CUDA es asíncrono). - Notebooks como sistema de producción: sección 10; ya sabéis cómo acaba.
- Ignorar los
UserWarning: el warning de broadcasting de shapes en la loss es un bug real gritándoos con educación.
13. Errores comunes¶
Los 8 errores más frecuentes de PyTorch, con su mensaje real y su cura:
| # | Mensaje de error (real) | Causa | Solución |
|---|---|---|---|
| 1 | RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu! |
Modelo en GPU y batch (o tensor auxiliar) en CPU, o viceversa | X = X.to(device) en el loop; tensores nuevos con device=x.device (sección 7.3) |
| 2 | RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x784 and 20x64) |
La dimensión de entrada de nn.Linear no coincide con las features reales |
Revisad shapes con print(x.shape); ajustad in_features o aplanad con flatten(1) (sección 2.3) |
| 3 | RuntimeError: expected scalar type Float but found Double |
Tensor float64 venido de NumPy mezclado con pesos float32 | .float() al convertir desde NumPy (sección 2.7) |
| 4 | RuntimeError: expected scalar type Long but found Float (en CrossEntropyLoss) |
Las etiquetas de clase no son int64 | y = y.long(); las etiquetas de clasificación siempre long (sección 5.1) |
| 5 | La loss no baja o diverge, sin ningún error | Falta optimizer.zero_grad(): los gradientes se acumulan batch tras batch |
Añadid zero_grad() al inicio de cada iteración (sección 3.3) |
| 6 | RuntimeError: Trying to backward through the graph a second time |
Segundo backward() sobre el mismo grafo (o loss acumulada entre iteraciones sin .item()) |
Un backward por forward; acumulad con loss.item() (secciones 3.2 y 3.4) |
| 7 | torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate ... |
Batch demasiado grande, o fuga por acumular tensores con grafo, o validar sin no_grad() |
Reducid batch_size; .item()/detach() para métricas; no_grad() en eval; mixed precision (7.5) |
| 8 | RuntimeError: Attempting to deserialize object on a CUDA device but torch.cuda.is_available() is False |
Checkpoint guardado en GPU, cargado en máquina sin GPU | torch.load(ruta, map_location="cpu") o map_location=device (sección 6.3) |
14. FAQ — Preguntas frecuentes¶
1. ¿Necesito GPU para seguir este módulo? No. Todo el código de este capítulo corre en CPU (los ejemplos tardan segundos o pocos minutos). Para los capítulos de CNNs y transformers una GPU acelera mucho: usad Colab gratis (sección 7.4) cuando la necesitéis.
2. ¿Por qué mi loss no baja en absoluto?
Checklist en orden: (1) ¿está optimizer.zero_grad()? (2) ¿le pasasteis model.parameters() al optimizador después de mover el modelo al device? (3) ¿softmax duplicado con CrossEntropyLoss? (4) ¿shapes con broadcasting silencioso en la loss (un (N,1) contra (N,))? (5) ¿lr demasiado alto (diverge) o bajo (plano)? (6) ¿los datos están escalados? El 90% de los casos es uno de estos seis.
3. ¿Cuándo uso view y cuándo reshape?
reshape siempre funciona (copia si hace falta); view es una vista garantizada sin copia pero exige memoria contigua. Regla práctica: usad reshape y no penséis más, salvo optimización fina.
4. ¿nn.CrossEntropyLoss o nn.BCEWithLogitsLoss para clasificación binaria?
Ambas valen. BCEWithLogitsLoss usa 1 logit de salida y etiquetas float; CrossEntropyLoss usa 2 logits y etiquetas long. CrossEntropy generaliza mejor si mañana añadís clases; BCE permite pos_weight muy cómodo para desbalanceo. En este máster usamos CrossEntropy por uniformidad.
5. ¿Por qué dos modos, model.train() y model.eval()?
Porque Dropout y BatchNorm se comportan distinto al entrenar (dropout activo, stats del batch) que al predecir (dropout apagado, stats acumuladas). Los modos les dicen en qué fase estamos. Si vuestro modelo no tiene esas capas, no cambia nada — pero llamadlos igual: hoy no las tiene, mañana sí.
6. ¿torch.no_grad() o torch.inference_mode()?
inference_mode es más rápido y estricto: usadlo para inferencia pura (producción). no_grad es más flexible: usadlo en la validación dentro del entrenamiento. Si dudáis, no_grad nunca es incorrecto.
7. ¿Cómo depuro un modelo PyTorch?
Como cualquier Python (esa es la gracia del modo eager): print(x.shape) dentro del forward, breakpoints del IDE, x.isnan().any() para cazar NaNs. Además: entrenad con un solo batch minúsculo hasta overfitearlo (si no puede, hay bug), y desactivad torch.compile mientras depuráis.
8. ¿PyTorch sirve para producción o solo para investigar?
Sirve, y a gran escala: los modelos de Meta, Tesla Autopilot, OpenAI y la mayoría de startups de IA sirven modelos PyTorch. Herramientas: torch.export/ONNX para exportar, TorchServe/Triton Inference Server para servir, ejecutores optimizados para móvil. Lo veréis en el módulo de MLOps.
9. ¿Debo aprender también TensorFlow? No como prioridad. Leed código TF si os lo cruzáis en un proyecto legacy (con Keras se entiende rápido), pero vuestro tiempo rinde más profundizando en PyTorch + su ecosistema (Lightning, Hugging Face). Las ofertas de empleo de 2026 lo reflejan.
10. Mi entrenamiento en GPU va igual de lento que en CPU. ¿Por qué?
Sospechosos: (1) el modelo o los datos no están realmente en la GPU (verificad next(model.parameters()).device); (2) el modelo es tan pequeño que domina el coste de transferencia; (3) el cuello de botella es el DataLoader (subid num_workers — en Linux — y activad pin_memory); (4) batches diminutos: probad a subir batch_size.
15. Resumen¶
- PyTorch ganó la guerra de frameworks por ser eager y pythónico: el código se ejecuta línea a línea y se depura como Python normal. En 2026 es el estándar en investigación e industria, gobernado por la Linux Foundation.
- El tensor es un ndarray con dos superpoderes: GPU y autograd. Dominad
dtype(float32 por defecto, long para etiquetas, bfloat16 para mixed precision) y las operaciones de forma (view/reshape,squeeze/unsqueeze,flatten) — son la fuente nº1 de errores. - Autograd es vuestro capítulo 2 automatizado: cada operación construye el grafo dinámico,
backward()aplica la regla de la cadena, los gradientes caen en.grad. Recordad: se acumulan →zero_grad()siempre;no_grad()/inference_modecuando no entrenéis;.item()para logging. nn.Module:__init__declara capas,forwarddefine el flujo, se invoca conmodel(x). El modelo emite logits; CrossEntropyLoss hace el resto.Dataset(una muestra) +DataLoader(batches, shuffle, workers) separan datos de entrenamiento con limpieza. En Windows,num_workers=0para empezar.- El training loop canónico:
zero_grad → forward → loss → backward → step, conmodel.train()/model.eval(), validación bajono_grad, checkpoints constate_dictde modelo Y optimizador, early stopping y semillas fijadas. Es la plantilla de vuestra carrera. - GPU: modelo al device una vez, batches en el loop, benchmarks con
synchronize, mixed precision conautocastpara modelos serios, y Colab cuando no tengáis hardware. torch.compileacelera con una línea en modelos grandes (mejor en Linux/WSL2); prescindible mientras aprendéis.- Profesionalmente: notebooks para explorar,
src/+ configs para entrenar, y elegir entre PyTorch puro / Lightning / HF Trainer / API según el problema — ahora tenéis criterio para hacerlo.
En el próximo capítulo aplicamos todo esto a imágenes de verdad: redes convolucionales (CNNs), donde la estructura espacial que el MLP tira a la basura se convierte en la protagonista.
16. Bibliografía y recursos¶
- Documentación oficial de PyTorch — https://pytorch.org/docs/stable/ (referencia completa de la API; consultadla ante cualquier duda de firma o parámetro).
- Tutoriales oficiales — https://pytorch.org/tutorials/ (en especial Learn the Basics y What is torch.nn really? de Jeremy Howard, que reconstruye
nndesde cero como hicimos aquí). - PyTorch — Get Started — https://pytorch.org/get-started/locally/ (selector de instalación por SO/CUDA).
- Autograd mechanics — https://pytorch.org/docs/stable/notes/autograd.html (cómo funciona autograd por dentro; lectura avanzada muy recomendable).
- torch.compile — Introduction — https://pytorch.org/tutorials/intermediate/torch_compile_tutorial.html.
- Automatic Mixed Precision — https://pytorch.org/docs/stable/amp.html.
- Stevens, Antiga & Viehmann, Deep Learning with PyTorch (Manning) — el libro de referencia, escrito por desarrolladores del propio framework.
- Paszke et al. (2019), PyTorch: An Imperative Style, High-Performance Deep Learning Library (NeurIPS) — el paper original; explica las decisiones de diseño que estudiamos en la sección 1.
- PyTorch Lightning — https://lightning.ai/docs/pytorch/stable/ (cuando estéis listos para quitar boilerplate).
| Anterior | Módulo | Siguiente |
|---|---|---|
| Capítulo 2: Backpropagation y entrenamiento | 03-DEEP-LEARNING | Capítulo 4: Visión por computador: CNNs |