Depurar y medir tu código

Los fallos de IA más caros no dan error: hay que saber buscarlos y medirlos.

Duración: ~60 minutosHerramientas: PythonAntes, conviene haber hecho: «Tu puesto de trabajo de IA, desde cero» y nociones básicas de PyTorch (las verás en el módulo 4)

Qué vas a aprender

  • Usar breakpoint() condicional y debug_print para inspeccionar formas, tipos y valores NaN de los tensores a mitad del entrenamiento
  • Perfilar bucles de entrenamiento con cProfile, line_profiler y tracemalloc para encontrar cuellos de botella
  • Detectar los bugs típicos de IA: formas incompatibles, pérdida NaN, fuga de datos y tensores en el dispositivo equivocado
  • Configurar TensorBoard para visualizar curvas de pérdida, histogramas de pesos y distribuciones de gradientes

Por qué importa

El código de IA falla de otra manera. Una aplicación web se cae con una traza de error. Un bucle de entrenamiento mal configurado funciona durante 8 horas, quema 200 € de GPU y produce un modelo que predice la media para cualquier entrada. El código nunca dio error. El bug era un tensor en el dispositivo equivocado, un .detach() olvidado o etiquetas que se colaban en las características.

Necesitas herramientas de depuración que detecten estos fallos silenciosos antes de que te hagan perder tiempo y dinero.

La idea clave

La depuración en IA funciona a tres niveles:

graph TD
    L3["3. Dinámica del entrenamiento<br/>Curvas de pérdida, normas de gradientes, activaciones"] --> L2
    L2["2. Operaciones con tensores<br/>Formas, tipos, dispositivos, valores NaN/Inf"] --> L1
    L1["1. Python estándar<br/>Breakpoints, logging, profiling, memoria"]

La mayoría de la gente salta directamente al nivel 3 (mirar TensorBoard fijamente). Pero el 80 % de los bugs de IA viven en los niveles 1 y 2.

Paso a paso

Parte 1: depurar con print (sí, funciona)

Depurar con print tiene mala fama, y no la merece. En código con tensores, un print bien colocado gana a ir paso a paso con el depurador, porque necesitas ver a la vez formas, tipos y rangos de valores.

def debug_print(name, tensor):
    print(f"{name}: shape={tensor.shape}, dtype={tensor.dtype}, "
          f"device={tensor.device}, "
          f"min={tensor.min().item():.4f}, max={tensor.max().item():.4f}, "
          f"mean={tensor.mean().item():.4f}, "
          f"has_nan={tensor.isnan().any().item()}")

Llámala después de cada operación sospechosa. Cuando encuentres el bug, quita los prints. Así de simple.

Parte 2: el depurador de Python (pdb y breakpoint)

El depurador integrado está infravalorado en IA. Pon un breakpoint() en tu bucle de entrenamiento e inspecciona los tensores de forma interactiva.

def training_step(model, batch, criterion, optimizer):
    inputs, labels = batch
    outputs = model(inputs)
    loss = criterion(outputs, labels)

    if loss.item() > 100 or torch.isnan(loss):
        breakpoint()

    loss.backward()
    optimizer.step()

Cuando el depurador te deje dentro, estos comandos son útiles:

  • p outputs.shape para comprobar formas
  • p loss.item() para ver el valor de la pérdida
  • p torch.isnan(outputs).sum() para contar NaNs
  • p model.fc1.weight.grad para revisar gradientes
  • c para continuar, q para salir

Esto es depuración condicional: solo te paras cuando algo parece ir mal. En un entrenamiento de 10.000 pasos, marca la diferencia.

Parte 3: logging en Python

Sustituye los prints por logging cuando la depuración vaya más allá de una comprobación rápida.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("training.log"),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

logger.info("Empieza el entrenamiento: lr=%.4f, batch_size=%d", lr, batch_size)
logger.warning("Pico de pérdida detectado: %.4f en el paso %d", loss.item(), step)
logger.error("Pérdida NaN en el paso %d, deteniendo", step)

El logging te da marcas de tiempo, niveles de gravedad y salida a archivo. Cuando un entrenamiento falla a las 3 de la madrugada, quieres un archivo de log, no una salida de terminal que se perdió con el scroll.

Pruébalo aquí con un entrenamiento simulado. Cambia level=logging.INFO por logging.WARNING y vuelve a ejecutarlo: los mensajes de nivel INFO desaparecen sin tocar el resto del código. Esa es la gran ventaja frente a los print:

import logging, math

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    force=True,   # sustituye cualquier configuración anterior
)
logger = logging.getLogger("entrenamiento")

lr, batch_size = 1e-3, 32
logger.info("Empieza el entrenamiento: lr=%.4f, batch_size=%d", lr, batch_size)

perdidas = [2.31, 1.87, 1.52, 4.90, 1.21, float("nan")]
for paso, perdida in enumerate(perdidas, start=1):
    if math.isnan(perdida):
        logger.error("Pérdida NaN en el paso %d, deteniendo", paso)
        break
    if paso > 1 and perdida > 2 * perdidas[paso - 2]:
        logger.warning("Pico de pérdida detectado: %.2f en el paso %d", perdida, paso)
    else:
        logger.info("Paso %d: pérdida %.2f", paso, perdida)

Parte 4: cronometrar secciones del código

Saber en qué se va el tiempo es el primer paso para optimizar.

import time

class Timer:
    def __init__(self, name=""):
        self.name = name

    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, *args):
        elapsed = time.perf_counter() - self.start
        print(f"[{self.name}] {elapsed:.4f}s")

with Timer("carga de datos"):
    batch = next(dataloader_iter)

with Timer("forward"):
    outputs = model(batch)

with Timer("backward"):
    loss.backward()

Hallazgo habitual: la carga de datos se come el 60 % del tiempo de entrenamiento. La solución es num_workers > 0 en tu DataLoader, no una GPU más rápida.

Usa el mismo Timer para comparar tres formas de hacer lo mismo. Ejecútalo aquí (los tiempos del navegador son más lentos que en tu ordenador, pero las proporciones se parecen):

import time

class Timer:
    def __init__(self, name=""):
        self.name = name

    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, *args):
        self.elapsed = time.perf_counter() - self.start
        print(f"[{self.name}] {self.elapsed:.4f}s")

n = 300_000

with Timer("bucle for") as t1:
    total = 0
    for i in range(n):
        total += i * i

with Timer("comprensión de lista") as t2:
    total = sum([i * i for i in range(n)])

with Timer("generador") as t3:
    total = sum(i * i for i in range(n))

mas_lento = max(t1, t2, t3, key=lambda t: t.elapsed)
print(f"\nEl más lento ha sido «{mas_lento.name}». Mide siempre antes de optimizar.")

Parte 5: cProfile y line_profiler

Cuando necesitas algo más que cronómetros manuales:

python -m cProfile -s cumtime train.py

Muestra cada llamada a función ordenada por tiempo acumulado. Para un perfil línea a línea:

pip install line_profiler
@profile
def train_step(model, data, target):
    output = model(data)
    loss = F.cross_entropy(output, target)
    loss.backward()
    return loss

# Ejecútalo con: kernprof -l -v train.py

Parte 6: perfilar la memoria

Memoria de CPU con tracemalloc

import tracemalloc

tracemalloc.start()

# tu código aquí
model = build_model()
data = load_dataset()

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:10]:
    print(stat)

Pruébalo aquí: ¿cuánta memoria reserva una lista frente a un generador que produce los mismos valores?

import tracemalloc

def con_lista(n):
    return sum([x * 2 for x in range(n)])

def con_generador(n):
    return sum(x * 2 for x in range(n))

for funcion in (con_lista, con_generador):
    tracemalloc.start()
    funcion(200_000)
    _, pico = tracemalloc.get_traced_memory()   # (memoria actual, pico máximo)
    tracemalloc.stop()
    print(f"{funcion.__name__:<14} pico de memoria: {pico / 1024 / 1024:.2f} MB")

La lista guarda los 200.000 valores a la vez; el generador, uno cada vez. Con datasets grandes, esa diferencia es la que separa un pipeline que funciona de un Out of Memory.

Memoria de CPU con memory_profiler

pip install memory_profiler
from memory_profiler import profile

@profile
def load_data():
    raw = read_csv("data.csv")       # mira cómo sube la memoria aquí
    processed = preprocess(raw)       # y aquí
    return processed

Ejecútalo con python -m memory_profiler tu_script.py para ver el uso de memoria línea a línea.

Memoria de GPU con PyTorch

import torch

if torch.cuda.is_available():
    print(torch.cuda.memory_summary())

    print(f"Asignada: {torch.cuda.memory_allocated() / 1e9:.2f} GB")
    print(f"Reservada: {torch.cuda.memory_reserved() / 1e9:.2f} GB")

Cuando te quedes sin memoria (OOM, Out of Memory):

  1. Reduce el tamaño del lote (lo primero que debes probar, siempre)
  2. Usa torch.cuda.empty_cache() para liberar la memoria cacheada
  3. Usa del tensor seguido de torch.cuda.empty_cache() para resultados intermedios grandes
  4. Usa precisión mixta (torch.cuda.amp) para reducir el uso de memoria a la mitad
  5. Usa gradient checkpointing en modelos muy profundos

Parte 7: bugs típicos de IA y cómo cazarlos

Formas incompatibles

El bug más frecuente. Un tensor tiene forma [batch, features] cuando el modelo espera [batch, channels, height, width].

def check_shapes(model, sample_input):
    print(f"Entrada: {sample_input.shape}")
    hooks = []

    def make_hook(name):
        def hook(module, inp, out):
            in_shape = inp[0].shape if isinstance(inp, tuple) else inp.shape
            out_shape = out.shape if hasattr(out, "shape") else type(out)
            print(f"  {name}: {in_shape} -> {out_shape}")
        return hook

    for name, module in model.named_modules():
        hooks.append(module.register_forward_hook(make_hook(name)))

    with torch.no_grad():
        model(sample_input)

    for h in hooks:
        h.remove()

Ejecútalo una vez con un lote de ejemplo: te dibuja el mapa de todas las transformaciones de forma de tu modelo.

Pérdida NaN

Una pérdida NaN significa que algo ha explotado. Causas habituales:

  • Learning rate demasiado alto
  • División por cero en una función de pérdida propia
  • Logaritmo de cero o de un número negativo
  • Gradientes que explotan en RNNs
def detect_nan(model, loss, step):
    if torch.isnan(loss):
        print(f"Pérdida NaN en el paso {step}")
        for name, param in model.named_parameters():
            if param.grad is not None:
                if torch.isnan(param.grad).any():
                    print(f"  Gradiente NaN en {name}")
                if torch.isinf(param.grad).any():
                    print(f"  Gradiente Inf en {name}")
        return True
    return False

Fuga de datos (data leakage)

Tu modelo saca un 99 % de accuracy en el conjunto de test. Suena genial. Es un bug.

def check_data_leakage(train_set, test_set, id_column="id"):
    train_ids = set(train_set[id_column].tolist())
    test_ids = set(test_set[id_column].tolist())
    overlap = train_ids & test_ids
    if overlap:
        print(f"FUGA DE DATOS: {len(overlap)} ejemplos están en train y en test")
        return True
    return False

Pruébalo aquí con un caso muy típico: el dataset tiene duplicados y, al dividir después de barajar sin quitarlos, algunos ejemplos acaban a la vez en train y en test:

import random

random.seed(1)
textos = [f"reseña {i}" for i in range(800)]
textos += random.sample(textos, 200)          # 200 duplicados, como pasa en datos reales
random.shuffle(textos)

train, test = textos[:800], textos[800:]
repetidos = set(train) & set(test)
print(f"Sin limpiar: {len(repetidos)} de {len(set(test))} ejemplos de test también están en train")

unicos = list(dict.fromkeys(textos))           # quita duplicados manteniendo el orden
train, test = unicos[:640], unicos[640:]
print(f"Quitando duplicados antes de dividir: {len(set(train) & set(test))} en común")

Revisa también la fuga temporal: usar datos del futuro para predecir el pasado. Ordena por fecha antes de dividir.

Dispositivo equivocado

Mezclar tensores de dispositivos distintos (CPU y GPU) provoca errores en ejecución. Pero a veces un tensor se queda en silencio en la CPU mientras todo lo demás está en la GPU, y el entrenamiento simplemente va lento.

def check_devices(model, *tensors):
    model_device = next(model.parameters()).device
    print(f"Dispositivo del modelo: {model_device}")
    for i, t in enumerate(tensors):
        if t.device != model_device:
            print(f"  AVISO: el tensor {i} está en {t.device} y el modelo en {model_device}")

Parte 8: lo básico de TensorBoard

TensorBoard te enseña lo que ocurre dentro del entrenamiento a lo largo del tiempo.

pip install tensorboard
from torch.utils.tensorboard import SummaryWriter

writer = SummaryWriter("runs/experiment_1")

for step in range(num_steps):
    loss = train_step(model, batch)

    writer.add_scalar("loss/train", loss.item(), step)
    writer.add_scalar("lr", optimizer.param_groups[0]["lr"], step)

    if step % 100 == 0:
        for name, param in model.named_parameters():
            writer.add_histogram(f"weights/{name}", param, step)
            if param.grad is not None:
                writer.add_histogram(f"grads/{name}", param.grad, step)

writer.close()

Lánzalo:

tensorboard --logdir=runs

Qué buscar:

  • La pérdida no baja: learning rate demasiado bajo o un problema en la arquitectura
  • La pérdida oscila como loca: learning rate demasiado alto
  • La pérdida se vuelve NaN: inestabilidad numérica (mira la sección de NaN)
  • La pérdida de train baja y la de validación sube: sobreajuste (overfitting)
  • Los histogramas de pesos colapsan a cero: gradientes que se desvanecen
  • Los histogramas de gradientes explotan: necesitas recortar gradientes (gradient clipping)

Parte 9: el depurador de VS Code

Para depurar de forma interactiva, configura VS Code con un launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Training",
            "type": "debugpy",
            "request": "launch",
            "program": "${file}",
            "console": "integratedTerminal",
            "justMyCode": false
        }
    ]
}

Pon breakpoints haciendo clic en el margen. Usa el panel de variables para inspeccionar los tensores y la consola de depuración para ejecutar cualquier expresión de Python a mitad de la ejecución.

Es especialmente útil para recorrer pipelines de preprocesado y ver cada transformación.

En la práctica

Este es el flujo de depuración que caza la mayoría de bugs de IA:

  1. Antes de entrenar: ejecuta check_shapes con un lote de ejemplo. Comprueba que las dimensiones de entrada y salida son las esperadas.
  2. Primeros 10 pasos: usa debug_print con la pérdida, las salidas y los gradientes. Confirma que no hay NaNs y que los valores están en rangos razonables.
  3. Durante el entrenamiento: registra la pérdida, el learning rate y las normas de los gradientes. Visualízalos con TensorBoard.
  4. Cuando algo se rompa: pon un breakpoint() en el punto del fallo e inspecciona los tensores.
  5. Para el rendimiento: cronometra carga de datos, forward y backward. Perfila la memoria si estás cerca del OOM.

Truco de profesional: antes de entrenar con todo el dataset, intenta sobreajustar un único lote. Si tu modelo no consigue llevar la pérdida casi a cero con 8 ejemplos, hay un bug en el modelo o en el bucle de entrenamiento, no en los datos.

Tu caja de herramientas

Descarga herramientas_depuracion.py desde «Archivos de la lección» y ejecútalo para ver una demostración de cada herramienta:

python herramientas_depuracion.py

Incluye el cronómetro, el profiling con cProfile, la memoria por línea, la detección de NaN y de fugas de datos, el logging y, si tienes PyTorch, utilidades para tensores, gradientes y dispositivos. Úsalo en tus proyectos con from herramientas_depuracion import Cronometro, fuga_de_datos.

Y un prompt para diagnosticar bugs específicos de IA con ayuda de un asistente. Cópialo, pégalo y añade tu código y el síntoma:

Eres un experto en depurar código de machine learning y deep learning (PyTorch, NumPy, Hugging Face).

1. Clasifica primero el bug:
   - Formas: errores de dimensiones, broadcasting inesperado, batch mal apilado.
   - Numérico: pérdida NaN o infinita, gradientes que explotan o desaparecen, learning rate excesivo.
   - Datos: fuga entre train y test, etiquetas mal alineadas, normalización distinta en train e inferencia.
   - Dispositivo: tensores en CPU y GPU mezclados, falta de memoria (OOM).
   - Lógica de entrenamiento: olvidar zero_grad(), model.train()/model.eval(), torch.no_grad() en evaluación.
   - Rendimiento: carga de datos lenta, GPU infrautilizada.
2. Pídeme el diagnóstico concreto que necesites (formas de los tensores, la pérdida de los primeros
   pasos, la salida de nvidia-smi, un perfil con cProfile…).
3. Explícame la causa raíz, no solo el síntoma.
4. Dame la corrección con el código exacto y una comprobación para confirmar que funciona
   (por ejemplo, sobreajustar un único lote).

Mi código y lo que pasa:

Retos para tu ordenador

  1. Ejecuta herramientas_depuracion.py y lee la salida de cada sección. Después añade a la lista de pérdidas de la demostración un valor float("nan") y comprueba que valores_invalidos lo detecta.
  2. Perfila un bucle de entrenamiento con cProfile e identifica la función más lenta.
  3. Usa tracemalloc para encontrar qué línea de tu pipeline de carga de datos reserva más memoria.
  4. Configura TensorBoard en un entrenamiento sencillo y averigua si el modelo está sobreajustando.
  5. Usa breakpoint() dentro de un bucle de entrenamiento y practica a inspeccionar formas, dispositivos y gradientes desde el depurador.

🧪 Practica esta lección

En el curso, esta lección incluye ejercicios interactivos con corrección automática y ejemplos de Python que ejecutas en tu navegador, sin instalar nada. Es gratis.

Practicar gratis en el curso →

📥 Archivos de la lección