Saltar a contenido

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

  1. ¿Por qué PyTorch?
  2. 1.1. Breve historia: de Torch a la Linux Foundation
  3. 1.2. Por qué ganó la batalla de los frameworks
  4. 1.3. El ecosistema PyTorch
  5. 1.4. PyTorch vs TensorFlow vs JAX en 2026
  6. 1.5. Instalación
  7. Tensores a fondo
  8. 2.1. Creación de tensores
  9. 2.2. dtype: por qué importa el tipo de dato
  10. 2.3. Shape, view, reshape, squeeze y unsqueeze
  11. 2.4. Indexación y slicing
  12. 2.5. Broadcasting
  13. 2.6. Operaciones esenciales
  14. 2.7. Conversión NumPy ↔ PyTorch
  15. 2.8. Dispositivos: CPU, CUDA y MPS
  16. Autograd: la magia explicada
  17. 3.1. requires_grad y el grafo dinámico
  18. 3.2. backward() y .grad
  19. 3.3. La acumulación de gradientes y zero_grad
  20. 3.4. no_grad, inference_mode y detach
  21. 3.5. El círculo se cierra: regresión lineal con autograd puro
  22. nn.Module a fondo
  23. 4.1. Anatomía de una clase modelo
  24. 4.2. nn.Linear por dentro
  25. 4.3. nn.Sequential: cuándo basta
  26. 4.4. Capas comunes
  27. 4.5. Contar parámetros y state_dict
  28. Datos: Dataset y DataLoader
  29. 5.1. Dataset custom
  30. 5.2. DataLoader
  31. 5.3. Transforms
  32. 5.4. train/val split con random_split
  33. El training loop profesional
  34. 6.1. La plantilla canónica
  35. 6.2. Funciones reutilizables: train_one_epoch y evaluate
  36. 6.3. Checkpoints: guardar y cargar
  37. 6.4. Early stopping
  38. 6.5. Curvas de pérdida con matplotlib
  39. 6.6. Reproducibilidad
  40. 6.7. Aplicación 1: clasificador de churn tabular
  41. 6.8. Aplicación 2: MNIST
  42. GPU en la práctica
  43. 7.1. El patrón correcto para mover cosas
  44. 7.2. Medir el speedup
  45. 7.3. Errores típicos con devices
  46. 7.4. Colab: GPU gratis
  47. 7.5. Mixed precision con torch.autocast
  48. torch.compile: PyTorch 2.x
  49. Ejemplo integrador: pipeline completo de clasificación tabular
  50. Caso empresarial: cómo estructura su código un equipo profesional
  51. Buenas prácticas
  52. Malas prácticas
  53. Errores comunes
  54. FAQ — Preguntas frecuentes
  55. Resumen
  56. 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:

  1. Eager execution (ejecución inmediata): en PyTorch, cuando escribes c = a + b, la suma se ejecuta en ese instante y puedes imprimir c, 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.
  2. Es pythónico: un modelo PyTorch es una clase Python normal. Un bucle de entrenamiento es un bucle for normal. Si sabes Python, sabes leer PyTorch. No hay un "lenguaje dentro del lenguaje".
  3. 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.
  4. 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.

x = torch.arange(12)                     # tensor([0, 1, 2, ..., 11]) — shape: (12,)
print(x.shape)                           # torch.Size([12])

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
plano = imgs.flatten(1)          # (32, 28, 28) → (32, 784): aplana desde la dim 1
# equivalente: imgs.view(32, -1) o imgs.reshape(-1, 784)
salida = capa(plano)             # (32, 128)

recuperado = plano.view(32, 28, 28)   # o .reshape(-1, 28, 28)
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 .grad de cada tensor hoja con requires_grad=True.
  • Tras el backward, el grafo se libera (por eficiencia). Llamar backward() dos veces da RuntimeError: 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**
m = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10))
print(sum(p.numel() for p in m.parameters()))   # 203530 ✅

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:

  1. Id a colab.research.google.com e iniciad sesión con vuestra cuenta Google.
  2. Nuevo notebook → menú Entorno de ejecución → Cambiar tipo de entorno de ejecución → T4 GPU → Guardar.
  3. Verificad: !nvidia-smi en una celda (muestra la GPU) y torch.cuda.is_available()True.
  4. 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:

  1. El scaler viaja dentro del checkpoint — un modelo sin su preprocesado es inservible en producción.
  2. Se evalúa en test con el mejor checkpoint, no con el último modelo.
  3. sklearn convive con PyTorch sin fricción: PyTorch entrena, sklearn mide. Cada herramienta en lo suyo.
  4. drop_last=True en train porque BatchNorm lanza ValueError: Expected more than 1 value per channel si 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

  1. Fijad las semillas al principio de cada script (set_seed(42)) — sin reproducibilidad no hay depuración posible.
  2. 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.
  3. 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.
  4. Guardad state_dict (modelo Y optimizador), nunca el modelo entero, y cargad siempre con map_location.
  5. Guardad el preprocesado junto al modelo (scaler, vocabulario, orden de columnas): un modelo sin su preprocesado es chatarra.
  6. model.eval() + torch.no_grad() en toda evaluación, sin excepciones.
  7. Usad .item() para extraer escalares de la loss para logging; jamás acumuléis tensores con grafo.
  8. Empezad simple: overfitead primero un batch pequeño (si el modelo no puede memorizar 32 muestras, hay un bug); luego escalad.
  9. Monitorizad SIEMPRE train Y validación: una sola curva no dice nada sobre sobreajuste.
  10. Versionad configs e hiperparámetros (ficheros yaml + git), no los llevéis en la cabeza ni en celdas de notebook.

12. Malas prácticas

  1. Copiar código de PyTorch de 2019 (Variable, .data, torch.autograd.Variable): API muerta que aún infesta Stack Overflow.
  2. Aplicar softmax en el forward y usar CrossEntropyLoss: doble softmax, aprendizaje degradado y silencioso.
  3. Llamar model.forward(x) en vez de model(x): se salta los hooks; funcionará hasta el día que no.
  4. Hardcodear "cuda" por todo el código: el script muere en cualquier máquina sin GPU (AssertionError: Torch not compiled with CUDA enabled o similar).
  5. Escalar los datos antes del split train/test: data leakage — vuestras métricas mienten.
  6. num_workers=8 en Windows sin el guard if __name__ == "__main__":: crash críptico asegurado.
  7. Entrenar sin checkpoints "porque son solo 2 horas": hasta que se corta la sesión de Colab en el minuto 110.
  8. Medir tiempos de GPU sin torch.cuda.synchronize(): benchmarks de fantasía (CUDA es asíncrono).
  9. Notebooks como sistema de producción: sección 10; ya sabéis cómo acaba.
  10. 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 acumulanzero_grad() siempre; no_grad()/inference_mode cuando no entrenéis; .item() para logging.
  • nn.Module: __init__ declara capas, forward define el flujo, se invoca con model(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=0 para empezar.
  • El training loop canónico: zero_grad → forward → loss → backward → step, con model.train()/model.eval(), validación bajo no_grad, checkpoints con state_dict de 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 con autocast para modelos serios, y Colab cuando no tengáis hardware.
  • torch.compile acelera 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 nn desde 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