Inicio / Artículos / Depuración de un Small GPT en PyTorch: Pruebas que aíslan cada fallo.

Depuración de un Small GPT en PyTorch: Pruebas que aíslan cada fallo.

Un flujo de trabajo paso a paso para depurar un GPT a nivel de carácter en PyTorch, desde los IDs de token y el desplazamiento de destino hasta las gradientes, las pérdidas NaN y los puntos de control.

6828 palabras

Las métricas de evaluación como la pérdida, la perplejidad y la tasa de repetición indican que un modelo GPT pequeño está funcionando mal, pero rara vez explican por qué. La respuesta tentadora es ajustar la tasa de aprendizaje, agregar una capa y esperar. Esta guía reemplaza esa especulación por un flujo de trabajo reproducible para un GPT compacto a nivel de caracteres (Mini-GPT) entrenado en WikiText-2: asignar cada síntoma a causas probables, realizar verificaciones específicas en cada etapa del proceso y solucionar los problemas en el orden en que ocurren. Al final se obtiene un conjunto de afirmaciones y un script de diagnóstico para ejecutar antes de cada tarea de entrenamiento costosa.

Por qué un pipeline GPT puede estar incorrecto sin colapsar

El entrenamiento de un modelo de lenguaje implica una serie de transformaciones, cada una de las cuales utiliza la salida de la anterior:

Raw dataset
→ cleaned text
→ tokenizer
→ token IDs
→ training batches
→ embeddings
→ Transformer blocks
→ vocabulary logits
→ cross-entropy loss
→ gradients
→ optimizer
→ checkpoints
→ generation

Un defecto en cualquier lugar contamina todo lo que sigue. Supongamos que los objetivos no están desplazados una posición con respecto a las entradas:

Input: The cat
Target: The cat

Ahora la red es recompensada por reproducir el token que ya ve en lugar de predecir el siguiente. No hay errores, y la pérdida aún puede disminuir, ya que copiar es sencillo. El modelo simplemente está optimizando el objetivo incorrecto. Esa es la diferencia clave con respecto a la depuración de un servicio web, donde un valor de retorno erróneo suele hacer fallar una prueba o una página:

El código que se ejecuta hasta el final aún puede entrenar un modelo defectuoso.

Trabaje desde el inicio del pipeline hacia el final

Verifique las etapas en el orden en que fluye los datos a través de ellas:

1. Environment
2. Files
3. Tokenizer
4. Token IDs
5. Training batches
6. Model shapes
7. Initial loss
8. Gradients
9. Optimizer
10. Validation behavior
11. Checkpoints
12. Generation

Juzgar la calidad de la generación antes de confirmar el pipeline de datos es una pérdida de tiempo, ya que una muestra defectuosa podría provenir de cualquiera de las once etapas anteriores. En cada etapa, haga una pregunta específica y resúmla con una verificación que sea exitosa o no.

Cuatro familias de fallos

Casi todos los problemas con un modelo como este se clasifican en uno de cuatro grupos, y conocer el grupo permite acotar la búsqueda.

  • Fallas de corrección: el código es lógicamente incorrecto. Los objetivos no se desplazan, la máscara causal permite que las posiciones vean tokens posteriores, la función de pérdida utiliza tensores con un formato incorrecto, o los IDs del tokenizador no coinciden con el vocabulario para el que se construyó el modelo.
  • Fallas numéricas: las operaciones matemáticas se vuelven inestables. La función de pérdida se convierte en NaN, los gradientes explotan, los logits se desbordan hacia el infinito, o softmax recibe una fila sin valores válidos.
  • Fallas de optimización: la implementación es correcta pero el aprendizaje es ineficaz, ya sea porque la tasa de aprendizaje es demasiado alta o baja, el modelo es demasiado pequeño, o la ejecución es demasiado corta.
  • Fallas en la generalización y generación: el entrenamiento funciona, pero el modelo no. La pérdida de validación aumenta, los ejemplos se repiten en bucle, la salida ignora la instrucción o el modelo reproduce pasajes del entrenamiento.
  • Comience con una configuración de depuración sencilla

    Depurar un proceso completo convierte cada hipótesis en una espera prolongada. Defina un modelo pequeño que pueda memorizar unos pocos ejemplos en cuestión de segundos:

    debug_config = MiniGPTConfig(
        vocab_size=tokenizer.vocab_size,
        block_size=32,
        embedding_dim=64,
        num_heads=4,
        num_layers=2,
        expansion_factor=4,
        dropout=0.0,
    )
    

    Combínelo con un pequeño lote:

    debug_batch_size = 8
    

    y una ejecución breve:

    debug_steps = 200
    

    El dropout está deliberadamente desactivado:

    dropout = 0.0
    

    El dropout convierte en cero las activaciones aleatorias, por lo que las ejecuciones idénticas difieren. Al eliminarlo (junto con una semilla fija) se logra que cada prueba sea reproducible. Restaure la configuración de producción una vez que el proceso funcione correctamente.

    Confirme el entorno de ejecución

    Antes de tocar el modelo, imprima las versiones de Python y PyTorch y si se puede utilizar CUDA o el backend Metal de Apple (MPS):

    import platform
    import torch
    
    print("Python:", platform.python_version())
    print("PyTorch:", torch.__version__)
    print("CUDA available:", torch.cuda.is_available())
    
    if hasattr(torch.backends, "mps"):
        print(
            "MPS available:",
            torch.backends.mps.is_available(),
        )
    

    Un ayudante elige el mejor dispositivo, prefiriendo CUDA, luego MPS y finalmente la CPU. La protección con hasattr permite que funcione en versiones antiguas sin un backend MPS:

    def get_device():
        if torch.cuda.is_available():
            return torch.device("cuda")
    
        if (
            hasattr(torch.backends, "mps")
            and torch.backends.mps.is_available()
        ):
            return torch.device("mps")
    
        return torch.device("cpu")
    

    Llámelo una vez e infórmese del resultado:

    device = get_device()
    print("Selected device:", device)
    

    Cuando el entrenamiento es misteriosamente lento, esta línea suele ser la explicación: la tarea esperaba una GPU pero tuvo que recurrir a la CPU debido a un problema con el controlador o la instalación.

    Asegúrese de que esté presente cada archivo de entrada

    Confirme que existan la definición del tokenizador y las divisiones codificadas de entrenamiento, validación y prueba; si no, deténgase temprano con un claro FileNotFoundError en lugar de un error confuso dentro del bucle de entrenamiento:

    from pathlib import Path
    
    
    required_paths = [
        Path("tokenizer/char_tokenizer.json"),
        Path("data/encoded/train_ids.pt"),
        Path("data/encoded/val_ids.pt"),
        Path("data/encoded/test_ids.pt"),
    ]
    
    for path in required_paths:
        if not path.exists():
            raise FileNotFoundError(
                f"Required file not found: {path}"
            )
    
        print("Found:", path)
    

    Imprima también los tamaños:

    for path in required_paths:
        print(
            path,
            path.stat().st_size,
            "bytes",
        )
    

    Un archivo vacío o excepcionalmente pequeño suele indicar que una tarea de preprocesamiento se interrumpió y dejó un artefacto truncado.

    Pruebe el tokenizador de forma aislada

    Cargue el tokenizador de caracteres:

    tokenizer = CharTokenizer.from_file(
        "tokenizer/char_tokenizer.json"
    )
    

    Verifique el tamaño del vocabulario y ambos extremos de la lista de caracteres en busca de algo faltante o corrupto:

    print("Vocabulary size:", tokenizer.vocab_size)
    print("First tokens:", tokenizer.chars[:20])
    print("Last tokens:", tokenizer.chars[-20:])
    

    La propiedad de ida y vuelta

    Un tokenizador sin pérdidas devuelve la entrada exacta después de codificarla y decodificarla. Imprimir con repr revela caracteres invisibles como espacios al final:

    sample = "The history of"
    
    encoded = tokenizer.encode(sample)
    decoded = tokenizer.decode(encoded)
    
    print("Encoded:", encoded)
    print("Decoded:", repr(decoded))
    
    assert decoded == sample
    

    La invariante que se está verificando:

    decode(encode(text)) = text
    

    Un fallo ocurre cuando al menos un carácter no puede representarse fielmente, generalmente por la ausencia de un símbolo en el vocabulario. En ese caso, el método encode del script completo lanza un KeyError en lugar de ignorarlo silenciosamente, lo cual es lo que se desea.

    Detener las incoherencias entre el tokenizador y los puntos de control

    Tener tamaños de vocabulario iguales es necesario pero no suficiente. Dos vocabularios pueden contener 100 caracteres cada uno y aún así asignar IDs diferentes:

    Tokenizer A: "a" → 10
    Tokenizer B: "a" → 24
    

    Un modelo entrenado con una asignación de mapeo y utilizado con otra genera resultados sin sentido, aunque la forma de cada tensor parezca correcta. Se debe persistir el vocabulario junto con los puntos de control, o al menos una huella digital de él. Un hash SHA-256 de la lista de caracteres serializada funciona; ajustar los parámetros separators y ensure_ascii garantiza que la misma lista siempre se serialice en los mismos bytes:

    import hashlib
    import json
    
    
    def tokenizer_fingerprint(chars):
        payload = json.dumps(
            chars,
            ensure_ascii=False,
            separators=(",", ":"),
        ).encode("utf-8")
    
        return hashlib.sha256(
            payload
        ).hexdigest()
    

    Calcúlalo para el tokenizador cargado:

    fingerprint = tokenizer_fingerprint(
        tokenizer.chars
    )
    
    print("Tokenizer fingerprint:", fingerprint)
    

    Guárdalo en el diccionario de puntos de control al guardar:

    checkpoint[
        "tokenizer_fingerprint"
    ] = fingerprint
    

    Al cargar, compáralo y rechaza continuar si hay una discrepancia. La condición is not None sigue permitiendo que se carguen puntos de control antiguos sin huella digital:

    saved_fingerprint = checkpoint.get(
        "tokenizer_fingerprint"
    )
    
    if (
        saved_fingerprint is not None
        and saved_fingerprint != fingerprint
    ):
        raise ValueError(
            "Checkpoint and tokenizer do not match"
        )
    

    Inspecciona los IDs de tokens codificados

    Carga la división de entrenamiento en la CPU como enteros de 64 bits, que son lo que esperan los embeddings de tipo y la entropía cruzada:

    train_ids = torch.load(
        "data/encoded/train_ids.pt",
        map_location="cpu",
    ).long()
    

    Imprime la forma, el tipo de dato y el rango:

    print("Shape:", train_ids.shape)
    print("Dtype:", train_ids.dtype)
    print("Minimum ID:", train_ids.min().item())
    print("Maximum ID:", train_ids.max().item())
    

    Cada ID debe encontrarse dentro del vocabulario:

    0 ≤ token ID < vocabulary size
    

    Como afirmaciones:

    assert train_ids.min().item() >= 0
    
    assert (
        train_ids.max().item()
        < tokenizer.vocab_size
    )
    

    Un ID fuera del rango interrumpe la búsqueda de incrustación, y en una GPU el error puede manifestarse como una afirmación opaca del lado del dispositivo lejos de su causa real. Las razones típicas son un archivo de tokenizador incorrecto, archivos codificados dañados, un vocabulario reconstruido después de la codificación o un manejo inconsistente de tokens especiales.

    Lea los datos almacenados como texto

    Incluso los números dentro del rango pueden codificar el texto incorrecto, así que decodifique unos cientos de IDs y léalos:

    sample_ids = train_ids[:500]
    
    sample_text = tokenizer.decode(
        sample_ids.tolist()
    )
    
    print(sample_text)
    

    Debería ver texto WikiText legible con su formato habitual, saltos de línea, encabezados y puntuación, sin secuencias repetidas o caracteres dañados. Si la muestra parece incorrecta, deténgase: ningún cambio en el modelo compensará un tokenizador o conjunto de datos dañado.

    Verifique el desplazamiento de un token entre entradas y objetivos

    Cree un ejemplo a mano, con la ventana de objetivo comenzando una posición más tarde:

    block_size = 32
    start = 100
    
    inputs = train_ids[
        start:
        start + block_size
    ]
    
    targets = train_ids[
        start + 1:
        start + block_size + 1
    ]
    

    Decodificar ambos para compararlos:

    input_text = tokenizer.decode(
        inputs.tolist()
    )
    
    target_text = tokenizer.decode(
        targets.tolist()
    )
    
    print("Input: ", repr(input_text))
    print("Target:", repr(target_text))
    

    El resultado debe ser similar a la entrada, sin su primer carácter y con un nuevo carácter añadido al final. Luego se debe verificar la relación entre los tensores:

    assert torch.equal(
        inputs[1:],
        targets[:-1],
    )
    

    Pocos controles en el proyecto detectan errores más graves. La invariante:

    inputs[1:] == targets[:-1]
    

    Cada posición del resultado contiene el token que sigue a la posición correspondiente de la entrada, lo cual es exactamente lo que se necesita para la predicción del siguiente token.

    El error de rebanadas idénticas

    El error clásico consiste en usar los mismos límites para ambas rebanadas:

    inputs = data[
        start:
        start + block_size
    ]
    
    targets = data[
        start:
        start + block_size
    ]
    

    El modelo entonces aprende una asignación de identidad:

    Current token → current token
    

    Comenzar la rebanada del resultado un token más tarde soluciona el problema:

    targets = data[
        start + 1:
        start + block_size + 1
    ]
    

    y restablece la tarea prevista:

    Current context → next token
    

    Una señal evidente de este error es una pérdida que disminuye de manera sospechosamente rápida al principio.

    Verificar formas de lotes, tipos de datos y dispositivos

    Extraer una muestra de un lote real:

    inputs, targets = get_batch(
        data=train_ids,
        batch_size=8,
        block_size=32,
        device=device,
    )
    

    Imprimir todo lo que podría estar mal:

    print("Input shape:", inputs.shape)
    print("Target shape:", targets.shape)
    print("Input dtype:", inputs.dtype)
    print("Target dtype:", targets.dtype)
    print("Input device:", inputs.device)
    print("Target device:", targets.device)
    

    Para un tamaño de lote de 8 y un tamaño de bloque de 32, se espera:

    Input shape:  [8, 32]
    Target shape: [8, 32]
    Dtype:        torch.int64
    Device:       same as model
    

    Hacer que las expectativas sean permanentes:

    assert inputs.shape == targets.shape
    assert inputs.dtype == torch.long
    assert targets.dtype == torch.long
    assert inputs.device == device
    assert targets.device == device
    

    Corregir incoherencias en dispositivos

    Este error aparece constantemente en el trabajo con PyTorch:

    Expected all tensors to be on the same device
    

    Una operación recibió tensores en dispositivos diferentes, como CPU y GPU. Imprimir dónde se encuentran los parámetros y el lote:

    model_device = next(
        model.parameters()
    ).device
    
    print("Model device:", model_device)
    print("Input device:", inputs.device)
    

    Mover explícitamente el modelo y cada lote:

    model = model.to(device)
    inputs = inputs.to(device)
    targets = targets.to(device)
    

    Hay una fuente más sutil oculta dentro del modelo: torch.arange utiliza por defecto la CPU, así que obtener el dispositivo de los tokens entrantes:

    positions = torch.arange(
        sequence_length,
        device=token_ids.device,
    )
    

    De lo contrario, agregar embeddings de posición a los embeddings de token en CUDA o MPS falla. Vincular el dispositivo a la entrada también mantiene al modelo portátil.

    Validar el paso forward

    Ejecuta un lote con objetivos para que el modelo devuelva logits y pérdida:

    logits, loss = model(
        inputs,
        targets,
    )
    

    Inspeccionalos:

    print("Logits shape:", logits.shape)
    print("Loss shape:", loss.shape)
    print("Loss value:", loss.item())
    

    Los logits necesitan una puntuación por cada entrada del vocabulario para cada posición, y la pérdida debe ser un escalar:

    assert logits.shape == (
        inputs.size(0),
        inputs.size(1),
        tokenizer.vocab_size,
    )
    
    assert loss.ndim == 0
    

    Los logits con forma [B, V, T] indican que una transposición o reshape ha colocado las dimensiones en el orden incorrecto. Dado que F.cross_entropy acepta las puntuaciones de clase en la segunda dimensión, un tensor con dimensiones mal ordenadas a veces puede llegar a calcular la pérdida sin error y producir un resultado sin sentido.

    Comparar la pérdida inicial con el valor de referencia aleatorio

    Un modelo recién inicializado con pesos pequeños predice una distribución casi uniforme, y la entropía cruzada frente a una distribución uniforme sobre V clases es log(V):

    import math
    
    expected_loss = math.log(
        tokenizer.vocab_size
    )
    
    print("Expected loss:", expected_loss)
    print("Actual loss:", loss.item())
    

    Una pequeña desviación es normal; una grande es una pista. Una pérdida inicial muy por encima del valor de referencia sugiere logit extremos, inicialización inestable, IDs de tokens inválidos, objetivos que no coinciden con el vocabulario, o una forma de salida incorrecta. Una pérdida inicial muy por debajo de este valor, algo que un modelo que no sabe nada no puede lograr honestamente, indica fuga de datos, pesos entrenados cargados por error, objetivos iguales a las entradas, tokens futuros visibles, o una reanudación no intencionada de un punto de control.

    Demuestre que la máscara causal funciona

    Un GPT debe predecir cada posición únicamente a partir de tokens anteriores. Crea dos secuencias con un prefijo compartido y sufijos diferentes; si el modelo es causal, los logits del prefijo deben coincidir. El modo de evaluación desactiva el dropout para que la aleatoriedad no interfiera:

    model.eval()
    
    prefix_length = 8
    sequence_length = 16
    
    sequence_a = torch.randint(
        0,
        tokenizer.vocab_size,
        (1, sequence_length),
        device=device,
    )
    
    sequence_b = sequence_a.clone()
    
    sequence_b[
        :,
        prefix_length:
    ] = torch.randint(
        0,
        tokenizer.vocab_size,
        (
            1,
            sequence_length - prefix_length,
        ),
        device=device,
    )
    

    Ejecuta ambos sin gradientes:

    with torch.no_grad():
        logits_a, _ = model(sequence_a)
        logits_b, _ = model(sequence_b)
    

    Mide la mayor diferencia en el prefijo:

    prefix_difference = (
        logits_a[:, :prefix_length, :]
        - logits_b[:, :prefix_length, :]
    ).abs().max().item()
    
    print(
        "Maximum prefix difference:",
        prefix_difference,
    )
    

    Verifica la igualdad dentro de una pequeña tolerancia que absorba el ruido de punto flotante:

    assert torch.allclose(
        logits_a[:, :prefix_length, :],
        logits_b[:, :prefix_length, :],
        atol=1e-5,
    )
    

    Un fallo significa que las posiciones posteriores se filtran en las anteriores, generalmente porque falta la máscara, se aplica a la dimensión incorrecta o se construye a partir del triángulo equivocado. Probar el comportamiento de extremo a extremo es más efectivo que inspeccionar el tensor de máscara.

    Sobreajusta un solo lote

    Si adoptas una técnica de esta guía, elige esta:

    Un modelo con suficiente capacidad debería poder memorizar un pequeño lote de datos.

    Este proceso ejerce simultáneamente los datos, el modelo, la función de pérdida, la retropropagación y el optimizador. Corrija un lote que se reutilice en cada paso:

    fixed_inputs, fixed_targets = get_batch(
        data=train_ids,
        batch_size=8,
        block_size=32,
        device=device,
    )
    

    Construya el modelo pequeño sin dropout:

    debug_config = MiniGPTConfig(
        vocab_size=tokenizer.vocab_size,
        block_size=32,
        embedding_dim=64,
        num_heads=4,
        num_layers=2,
        expansion_factor=4,
        dropout=0.0,
    )
    
    debug_model = MiniGPT(
        debug_config
    ).to(device)
    

    Entrene con ese lote repetidamente, registrando los resultados cada 50 pasos:

    optimizer = torch.optim.AdamW(
        debug_model.parameters(),
        lr=1e-3,
    )
    
    for step in range(500):
        optimizer.zero_grad(
            set_to_none=True
        )
    
        _, debug_loss = debug_model(
            fixed_inputs,
            fixed_targets,
        )
    
        debug_loss.backward()
        optimizer.step()
    
        if step % 50 == 0:
            print(
                step,
                debug_loss.item(),
            )
    

    La función de pérdida debería disminuir significativamente por debajo del valor de referencia. Si no ocurre, sospeche que la función de pérdida esté dañada, que los gradientes no lleguen a algunos parámetros, que los objetivos no estén desplazados, que el modelo sea demasiado pequeño incluso para este caso, que la tasa de aprendizaje esté mal elegida, que la máscara causal tenga errores o que el optimizador no esté actualizando nada. Considere las pruebas como un filtro para cualquier ejecución completa.

    Confirme que los gradientes lleguen a cada parámetro

    Ejecute una pasada hacia adelante y otra hacia atrás:

    optimizer.zero_grad(
        set_to_none=True
    )
    
    _, loss = model(
        inputs,
        targets,
    )
    
    loss.backward()
    

    Informe de cada parámetro entrenable cuyo .grad siga siendo None, y de la norma del resto:

    for name, parameter in (
        model.named_parameters()
    ):
        if not parameter.requires_grad:
            continue
    
        if parameter.grad is None:
            print(
                "NO GRADIENT:",
                name,
            )
        else:
            print(
                name,
                parameter.grad.norm().item(),
            )
    

    Una gradiente ausente suele indicar una capa definida en __init__ pero no utilizada en forward, un llamado accidental a .detach(), una rama de proceso que omite algún componente, una pérdida calculada a partir de un tensor desconectado, o requires_grad=False.

    Controlar la norma global de la gradiente

    Las normas por parámetro permiten detectar capas inactivas; un valor agregado permite monitorear la estabilidad a lo largo del tiempo. Esta función combina todas las normas L2 de las gradientes, que son las mismas que se utilizan para el recorte:

    def calculate_gradient_norm(model):
        squared_norm = 0.0
    
        for parameter in model.parameters():
            if parameter.grad is None:
                continue
    
            parameter_norm = (
                parameter.grad
                .detach()
                .norm(2)
                .item()
            )
    
            squared_norm += (
                parameter_norm ** 2
            )
    
        return squared_norm ** 0.5
    

    Regístrela después de cada paso hacia atrás:

    gradient_norm = (
        calculate_gradient_norm(model)
    )
    
    print("Gradient norm:", gradient_norm)
    

    Preste atención a las normas que sean exactamente cero, extremadamente grandes o cambien bruscamente, NaN, o inf.

    Detectar NaN e infinito temprano

    Un ayudante se activa tan pronto como un tensor contiene un valor no finito, nombrando dicho tensor:

    def assert_finite_tensor(
        tensor,
        name,
    ):
        if not torch.isfinite(
            tensor
        ).all():
            raise FloatingPointError(
                f"{name} contains NaN or infinity"
            )
    

    Áplíquelo a los logits y a la pérdida:

    assert_finite_tensor(
        logits,
        "logits",
    )
    
    assert_finite_tensor(
        loss,
        "loss",
    )
    

    y a cada gradiente después de backward():

    for name, parameter in (
        model.named_parameters()
    ):
        if parameter.grad is not None:
            assert_finite_tensor(
                parameter.grad,
                f"gradient for {name}",
            )
    

    Revisar varios puntos revela el primer lugar donde aparecen números inválidos, lo cual es mucho más útil que detectar una pérdida NaN cientos de pasos después.

    Por qué la pérdida se convierte en NaN

    Causas frecuentes: una tasa de aprendizaje demasiado alta, gradientes explosivos, una fila de atención con todas las posiciones enmascaradas, entrada inválida para softmax, desbordamiento de precisión mixta, parámetros ya dañados, división por cero, un logaritmo de cero o de un valor negativo, y logits infinitos. Cuando ocurre esto:

    1. Detenga la ejecución.
    2. Encuentre el último paso con una pérdida finita.
    3. Disminuya la tasa de aprendizaje.
    4. Habilite el recorte de gradientes.
  • Vuelva a verificar la máscara causal.
  • Desactive la precisión mixta.
  • Analice los parámetros y gradientes en busca de valores no finitos.
  • Nunca continúe con los pasos una vez que los parámetros contengan NaN; cada actualización propaga la corrupción, por lo que es mejor reanudar desde el último punto de control válido.

    Utilice el recorte de gradientes como medida de protección

    El recorte reescala los gradientes cuya norma combinada excede un umbral, por lo que debe aplicarse entre backward() y optimizer.step():

    loss.backward()
    
    gradient_norm = (
        torch.nn.utils.clip_grad_norm_(
            model.parameters(),
            max_norm=1.0,
        )
    )
    
    optimizer.step()
    

    clip_grad_norm_ devuelve la norma medida antes del recorte, lo que también sirve como herramienta de monitoreo:

    print(
        "Gradient norm before clipping:",
        float(gradient_norm),
    )
    

    Si se supera el umbral casi en cada paso, el recorte está ocultando un problema como una tasa de aprendizaje excesiva o inestabilidad numérica. Protege contra lotes defectuosos ocasionales, pero no sustituye a una tasa de aprendizaje adecuada.

    Verifique que el optimizador cambie los pesos

    Copie un parámetro antes de una actualización. El método .clone() es importante: sin él, el valor de before comparte almacenamiento con el parámetro y también cambia:

    parameter_name, parameter = next(
        model.named_parameters()
    )
    
    before = parameter.detach().clone()
    

    Ejecute un paso de entrenamiento:

    optimizer.zero_grad(
        set_to_none=True
    )
    
    _, loss = model(
        inputs,
        targets,
    )
    
    loss.backward()
    optimizer.step()
    

    Mida el cambio:

    after = parameter.detach()
    
    maximum_change = (
        after - before
    ).abs().max().item()
    
    print(
        "Maximum parameter change:",
        maximum_change,
    )
    

    y asegúrese de que haya uno:

    assert maximum_change > 0
    

    Los pesos sin cambios indican una tasa de aprendizaje nula, un optimizador creado sin los parámetros del modelo (por ejemplo, antes de reemplazar el modelo), la falta de gradientes, la ausencia de optimizer.step(), o parámetros congelados.

    Ajuste la tasa de aprendizaje en ambas direcciones

    Una tasa demasiado alta se manifiesta en un aumento rápido de la pérdida o en fluctuaciones extremas, normas de gradientes muy altas, una pérdida de tipo NaN, y muestras que nunca mejoran. Una primera solución es reducir ese valor máximo, por ejemplo a:

    max_learning_rate = 1e-4
    

    en lugar de:

    max_learning_rate = 1e-3
    

    Registre la tasa de aprendizaje junto con la pérdida. Con el warmup, la inestabilidad suele comenzar exactamente en el pico, lo cual un gráfico de pérdida por sí solo oculta.

    Una tasa demasiado baja se manifiesta de manera diferente: la pérdida disminuye muy lentamente aunque existan gradientes, los parámetros apenas se mueven e incluso las pruebas con un solo lote requieren muchos pasos. Entonces aumentéela, por ejemplo a:

    max_learning_rate = 3e-4
    

    en lugar de:

    max_learning_rate = 1e-5
    

    No existe un valor universalmente correcto; varía según el tamaño del modelo y del lote, el optimizador y el conjunto de datos. Realice experimentos cortos en los que solo cambie la tasa de aprendizaje.

    Una lista de verificación para una pérdida que no disminuye

    Responda a estas preguntas en orden. Datos:

    Are targets shifted by one token?
    Are token IDs within range?
    Does decoded input look correct?
    

    Modelo:

    Are logits shaped [B, T, V]?
    Is the causal mask valid?
    Are positions on the correct device?
    

    Pérdida:

    Does cross-entropy receive raw logits?
    Are logits and targets flattened correctly?
    

    Introducir probabilidades softmax en cross_entropy es un error clásico, ya que la función aplica log-softmax por sí misma. Gradientes:

    Do all important parameters receive gradients?
    Are gradient norms finite and nonzero?
    

    Optimizador:

    Is the learning rate positive?
    Does optimizer.step() run?
    Do parameters change?
    

    Capacidad:

    Can the model overfit one batch?
    This order avoids random trial and error.
    

    Este orden elimina una clase de causa a la vez en lugar de depender de la prueba y el error.

    Reconocer sobreajuste e subajuste

    El sobreajuste se manifiesta como curvas que divergen:

    Training loss: continues decreasing
    Validation loss: stops decreasing or increases
    

    Cuantificar la brecha:

    generalization_gap = (
        validation_loss
        - training_loss
    )
    

    Las soluciones incluyen mantener el mejor punto de control de validación, aumentar el dropout o la atenuación de pesos, reducir el modelo, agregar más datos o datos más variados, y detenerse antes. Elija el punto de parada con la división de validación; usar la división de prueba filtra información e infla la puntuación final.

    El subajuste se manifiesta como dos curvas que permanecen altas juntas:

    Training loss:   remains high
    Validation loss: remains similarly high
    

    Las razones más probables son una capacidad insuficiente, un entrenamiento demasiado corto, una tasa de aprendizaje baja, una ventana de contexto corta, datos demasiado complejos para la arquitectura, o una tokenización que desperdicia contexto. Las opciones incluyen más pasos, una dimensión de embedding mayor, más capas Transformer, un contexto más largo, codificación por pares de bytes (BPE) en lugar de caracteres, y ajustar nuevamente la tasa de aprendizaje. Primero ejecute nuevamente la prueba de lote único: si el modelo no puede memorizar un lote, el problema está en la corrección u optimización, no en la capacidad.

    Diagnóstico de generación repetitiva

    La repetición se presenta así:

    the the the the
    

    o, con marcadores de encabezado de WikiText:

    = = = = = = =
    

    Las causas incluyen decodificación codiciosa, una temperatura muy baja, un top-k muy pequeño, un modelo subentrenado o sobreajustado, estructuras repetidas en los datos, y una ventana de contexto corta. Pruebe un muestreo más equilibrado:

    temperature = 0.8
    top_k = 20
    top_p = 0.9
    

    Una penalización por repetición puede ser útil si es suave:

    repetition_penalty = 1.05
    

    En los modelos de caracteres, una penalización excesiva desalienta el reuso de letras y arruina rápidamente la ortografía. Si cada configuración de decodificación sigue en bucle, el punto débil es el modelo, no el muestreador. Para conocer cómo interactúan estas configuraciones, consulte nuestra guía sobre temperatura, top-k y top-p.

    Diagnóstico de generación caótica

    El fallo opuesto produce símbolos extraños, palabras rotas, exceso de puntuación, cambios bruscos de tema y cadenas ilegibles. Las causas probables son una temperatura alta, falta de filtrado top-k o top-p, un tokenizador inadecuado, el checkpoint incorrecto, un modelo subentrenado con alta pérdida de validación, o pesos que nunca se cargaron. Intente un muestreo más estricto:

    temperature = 0.6
    top_k = 10
    top_p = 0.9
    

    Confirme que los pesos provienen realmente del punto de control:

    model.load_state_dict(
        checkpoint["model_state_dict"]
    )
    

    y que el dropout está desactivado durante la toma de muestras:

    model.eval()
    

    Cuando la salida ignora el prompt

    El prompt puede ser muy corto o diferente de los datos de entrenamiento; el modelo podría ser pequeño, insuficientemente entrenado, débil en las dependencias a largo plazo o estar limitado por un contexto corto; además, los tokens de caracteres dificultan el aprendizaje de patrones semánticos. Pruebe prompts más largos al estilo WikiText. Compare uno mínimo con otro más detallado:

    "The "
    

    con uno más rico en información:

    "The history of the city began"
    

    El segundo proporciona al modelo mucho más sobre lo cual basarse para tomar decisiones. Si las continuaciones siguen desviándose, examine la pérdida de validación y la implementación de la atención.

    Puntos de control que se niegan a cargarse

    Los errores son conocidos:

    Missing key(s) in state_dict
    Unexpected key(s) in state_dict
    Size mismatch
    

    Quieren decir que el modelo que construyó no es el que guardó: su configuración, cantidad de capas, dimensión de los embeddings, tamaño del vocabulario o los pesos han cambiado, las clases o atributos fueron renombrados, o se está cargando el estado de un optimizador de otra arquitectura. Inspeccione la configuración guardada:

    print(
        checkpoint["config"]
    )
    

    Construya el modelo a partir de ella en lugar de usar los valores predeterminados actuales:

    config = MiniGPTConfig(
        **checkpoint["config"]
    )
    
    model = MiniGPT(config)
    

    Luego cargue el diccionario de estado. Construir un modelo a partir de una nueva configuración y esperar que los pesos antiguos se adapten causa la mayoría de estos errores.

    Lista de claves faltantes e inesperadas

    Solo para diagnóstico, cargue los datos de forma no estricta e imprima las discrepancias:

    load_result = model.load_state_dict(
        checkpoint["model_state_dict"],
        strict=False,
    )
    
    print(
        "Missing keys:",
        load_result.missing_keys,
    )
    
    print(
        "Unexpected keys:",
        load_result.unexpected_keys,
    )
    

    Las listas suelen revelar la causa, como un submódulo con nombre cambiado. Para la inferencia o la reanudación del entrenamiento, mantenga una carga estricta, de modo que un punto de control incompatible falle de manera evidente en lugar de dejar las capas con valores iniciales aleatorios.

    Mueva el estado del optimizador al dispositivo adecuado

    Después de restaurar un optimizador, sus tensores internos (como las estimaciones de momento de AdamW) pueden encontrarse en un dispositivo diferente al del modelo. Este complemento mueve cada tensor del estado:

    def move_optimizer_to_device(
        optimizer,
        device,
    ):
        for state in optimizer.state.values():
            for key, value in state.items():
                if torch.is_tensor(value):
                    state[key] = value.to(
                        device
                    )
    

    Llámelo justo después de cargarlo:

    optimizer.load_state_dict(
        checkpoint[
            "optimizer_state_dict"
        ]
    )
    
    move_optimizer_to_device(
        optimizer,
        device,
    )
    

    Es especialmente importante al guardar en una máquina y reanudar en otra, por ejemplo de CUDA a MPS o CPU.

    Agregue las verificaciones clave en una función de estado

    Reúna las afirmaciones más importantes en una sola función: el tipo y rango de los tokens, el desplazamiento del objetivo, la forma de los logits, una pérdida finita y una comparación con el valor base aleatorio:

    def run_model_health_checks(
        model,
        tokenizer,
        train_ids,
        device,
    ):
        model.eval()
    
        assert train_ids.dtype == torch.long
    
        assert train_ids.min().item() >= 0
    
        assert (
            train_ids.max().item()
            < tokenizer.vocab_size
        )
    
        batch_size = 4
        block_size = min(
            32,
            model.config.block_size,
        )
    
        inputs, targets = get_batch(
            data=train_ids,
            batch_size=batch_size,
            block_size=block_size,
            device=device,
        )
    
        assert inputs.shape == targets.shape
    
        assert torch.equal(
            inputs[:, 1:],
            targets[:, :-1],
        )
    
        with torch.no_grad():
            logits, loss = model(
                inputs,
                targets,
            )
    
        assert logits.shape == (
            batch_size,
            block_size,
            tokenizer.vocab_size,
        )
    
        assert torch.isfinite(loss)
    
        expected_loss = math.log(
            tokenizer.vocab_size
        )
    
        print("Current loss:", loss.item())
        print(
            "Random baseline:",
            expected_loss,
        )
    
        print("Model health checks passed.")
    

    En el caso de un punto de control entrenado, la pérdida debe estar claramente por debajo del valor de referencia; de lo contrario, los pesos no se cargaron o el tokenizador no coincide.

    Localizar problemas numéricos con ganchos forward

    Cuando aparece un NaN en algún lugar desconocido, los ganchos forward inspeccionan la salida de cada módulo durante el paso. Este gancho maneja tensores y tuplas individuales y lanza una excepción indicando el nombre de la clase del módulo al encontrar el primer valor no finito:

    def finite_output_hook(
        module,
        inputs,
        output,
    ):
        tensors = []
    
        if torch.is_tensor(output):
            tensors = [output]
    
        elif isinstance(output, tuple):
            tensors = [
                item
                for item in output
                if torch.is_tensor(item)
            ]
    
        for tensor in tensors:
            if not torch.isfinite(
                tensor
            ).all():
                raise FloatingPointError(
                    "Nonfinite output detected in "
                    f"{module.__class__.__name__}"
                )
    

    Asócialo a cada módulo lineal, de normalización de capa e incrustación, y conserva los identificadores correspondientes:

    hooks = []
    
    for module in model.modules():
        if isinstance(
            module,
            (
                torch.nn.Linear,
                torch.nn.LayerNorm,
                torch.nn.Embedding,
            ),
        ):
            hooks.append(
                module.register_forward_hook(
                    finite_output_hook
                )
            )
    

    Ejecuta un paso forward; como las capas se ejecutan en orden, la primera excepción indica el tipo de capa que falló. Luego elimina los ganchos:

    for hook in hooks:
        hook.remove()
    

    Los ganchos se ejecutan en cada llamada directa y ralentizan el modelo, por lo que úsalos solo al buscar un error. Para obtener la ruta exacta del módulo, registra los nombres de named_modules() al momento de registrarlo.

    Un script de diagnóstico completo

    Todas las verificaciones se combinan en una sola herramienta de línea de comandos. Guárdala como:

    debug_mini_gpt.py
    

    El script define un CharTokenizer mínimo, la selección del dispositivo, la huella digital, una herramienta de agrupamiento y las utilidades numéricas. Construye el modelo a partir de la propia configuración del punto de control, rechaza un tokenizador cuyo tamaño de vocabulario difiere y luego ejecuta ocho pruebas numeradas: ida y vuelta del tokenizador, rango de tokens, desplazamiento por lotes, paso forward, independencia causal, gradientes, actualización del optimizador y, opcionalmente, sobreajuste de un solo lote en un modelo de depuración nuevo, todo bajo una semilla fija. Dos detalles merecen atención: la prueba del tokenizador decodifica los IDs almacenados y los vuelve a codificar, validando así el conjunto de datos real, y la prueba del optimizador utiliza una instancia nueva de AdamW para que el estado obsoleto no interfiera.

    import argparse
    import hashlib
    import json
    import math
    from pathlib import Path
    
    import torch
    
    from mini_gpt import MiniGPT
    from mini_gpt import MiniGPTConfig
    
    
    class CharTokenizer:
        def __init__(self, chars):
            self.chars = chars
            self.vocab_size = len(chars)
    
            self.stoi = {
                char: index
                for index, char in enumerate(chars)
            }
    
            self.itos = {
                index: char
                for index, char in enumerate(chars)
            }
    
        @classmethod
        def from_file(cls, path):
            with open(
                path,
                "r",
                encoding="utf-8",
            ) as file:
                data = json.load(file)
    
            return cls(data["chars"])
    
        def encode(self, text):
            return [
                self.stoi[char]
                for char in text
            ]
    
        def decode(self, token_ids):
            return "".join(
                self.itos[int(token_id)]
                for token_id in token_ids
            )
    
    
    def get_device():
        if torch.cuda.is_available():
            return torch.device("cuda")
    
        if (
            hasattr(torch.backends, "mps")
            and torch.backends.mps.is_available()
        ):
            return torch.device("mps")
    
        return torch.device("cpu")
    
    
    def tokenizer_fingerprint(chars):
        payload = json.dumps(
            chars,
            ensure_ascii=False,
            separators=(",", ":"),
        ).encode("utf-8")
    
        return hashlib.sha256(
            payload
        ).hexdigest()
    
    
    def get_batch(
        data,
        batch_size,
        block_size,
        device,
    ):
        start_positions = torch.randint(
            low=0,
            high=len(data) - block_size,
            size=(batch_size,),
        )
    
        inputs = torch.stack([
            data[
                position:
                position + block_size
            ]
            for position in start_positions
        ])
    
        targets = torch.stack([
            data[
                position + 1:
                position + block_size + 1
            ]
            for position in start_positions
        ])
    
        return (
            inputs.to(device),
            targets.to(device),
        )
    
    
    def assert_finite_tensor(
        tensor,
        name,
    ):
        if not torch.isfinite(
            tensor
        ).all():
            raise FloatingPointError(
                f"{name} contains NaN or infinity"
            )
    
    
    def calculate_gradient_norm(model):
        squared_norm = 0.0
    
        for parameter in model.parameters():
            if parameter.grad is None:
                continue
    
            norm = (
                parameter.grad
                .detach()
                .norm(2)
                .item()
            )
    
            squared_norm += norm ** 2
    
        return squared_norm ** 0.5
    
    
    def load_model(
        checkpoint_path,
        device,
    ):
        checkpoint = torch.load(
            checkpoint_path,
            map_location=device,
        )
    
        config = MiniGPTConfig(
            **checkpoint["config"]
        )
    
        model = MiniGPT(config)
    
        model.load_state_dict(
            checkpoint["model_state_dict"]
        )
    
        model = model.to(device)
    
        return model, checkpoint
    
    
    def test_tokenizer(
        tokenizer,
        train_ids,
    ):
        print("\n1. Testing tokenizer")
    
        print(
            "Vocabulary size:",
            tokenizer.vocab_size,
        )
    
        print(
            "Tokenizer fingerprint:",
            tokenizer_fingerprint(
                tokenizer.chars
            ),
        )
    
        sample_ids = train_ids[:300]
    
        sample_text = tokenizer.decode(
            sample_ids.tolist()
        )
    
        round_trip_ids = tokenizer.encode(
            sample_text
        )
    
        assert round_trip_ids == (
            sample_ids.tolist()
        )
    
        print("Decoded sample:")
        print(repr(sample_text))
    
        print(
            "Tokenizer round-trip test passed."
        )
    
    
    def test_token_ids(
        tokenizer,
        train_ids,
    ):
        print("\n2. Testing token IDs")
    
        print("Shape:", train_ids.shape)
        print("Dtype:", train_ids.dtype)
    
        minimum_id = train_ids.min().item()
        maximum_id = train_ids.max().item()
    
        print("Minimum ID:", minimum_id)
        print("Maximum ID:", maximum_id)
    
        assert minimum_id >= 0
    
        assert maximum_id < (
            tokenizer.vocab_size
        )
    
        print("Token ID test passed.")
    
    
    def test_batch(
        tokenizer,
        train_ids,
        block_size,
        device,
    ):
        print("\n3. Testing batches")
    
        inputs, targets = get_batch(
            data=train_ids,
            batch_size=4,
            block_size=block_size,
            device=device,
        )
    
        print("Input shape:", inputs.shape)
        print("Target shape:", targets.shape)
    
        assert inputs.shape == targets.shape
        assert inputs.dtype == torch.long
        assert targets.dtype == torch.long
    
        assert torch.equal(
            inputs[:, 1:],
            targets[:, :-1],
        )
    
        input_text = tokenizer.decode(
            inputs[0].cpu().tolist()
        )
    
        target_text = tokenizer.decode(
            targets[0].cpu().tolist()
        )
    
        print("Input sample:")
        print(repr(input_text))
    
        print("Target sample:")
        print(repr(target_text))
    
        print("Batch shift test passed.")
    
        return inputs, targets
    
    
    def test_forward_pass(
        model,
        tokenizer,
        inputs,
        targets,
    ):
        print("\n4. Testing forward pass")
    
        model.eval()
    
        with torch.no_grad():
            logits, loss = model(
                inputs,
                targets,
            )
    
        print("Logits shape:", logits.shape)
        print("Loss:", loss.item())
    
        assert logits.shape == (
            inputs.size(0),
            inputs.size(1),
            tokenizer.vocab_size,
        )
    
        assert_finite_tensor(
            logits,
            "logits",
        )
    
        assert_finite_tensor(
            loss,
            "loss",
        )
    
        print(
            "Random baseline:",
            math.log(tokenizer.vocab_size),
        )
    
        print("Forward-pass test passed.")
    
    
    def test_future_independence(
        model,
        tokenizer,
        device,
    ):
        print(
            "\n5. Testing causal independence"
        )
    
        model.eval()
    
        sequence_length = min(
            16,
            model.config.block_size,
        )
    
        prefix_length = (
            sequence_length // 2
        )
    
        sequence_a = torch.randint(
            0,
            tokenizer.vocab_size,
            (1, sequence_length),
            device=device,
        )
    
        sequence_b = sequence_a.clone()
    
        sequence_b[
            :,
            prefix_length:
        ] = torch.randint(
            0,
            tokenizer.vocab_size,
            (
                1,
                sequence_length
                - prefix_length,
            ),
            device=device,
        )
    
        with torch.no_grad():
            logits_a, _ = model(sequence_a)
            logits_b, _ = model(sequence_b)
    
        difference = (
            logits_a[
                :,
                :prefix_length,
                :,
            ]
            - logits_b[
                :,
                :prefix_length,
                :,
            ]
        ).abs().max().item()
    
        print(
            "Maximum shared-prefix difference:",
            difference,
        )
    
        assert torch.allclose(
            logits_a[
                :,
                :prefix_length,
                :,
            ],
            logits_b[
                :,
                :prefix_length,
                :,
            ],
            atol=1e-5,
        )
    
        print(
            "Causal independence test passed."
        )
    
    
    def test_gradients(
        model,
        inputs,
        targets,
    ):
        print("\n6. Testing gradients")
    
        model.train()
    
        model.zero_grad(
            set_to_none=True
        )
    
        _, loss = model(
            inputs,
            targets,
        )
    
        loss.backward()
    
        missing_gradients = []
        nonfinite_gradients = []
    
        for name, parameter in (
            model.named_parameters()
        ):
            if not parameter.requires_grad:
                continue
    
            if parameter.grad is None:
                missing_gradients.append(name)
                continue
    
            if not torch.isfinite(
                parameter.grad
            ).all():
                nonfinite_gradients.append(
                    name
                )
    
        print(
            "Gradient norm:",
            calculate_gradient_norm(model),
        )
    
        if missing_gradients:
            print(
                "Missing gradients:",
                missing_gradients,
            )
    
        if nonfinite_gradients:
            print(
                "Nonfinite gradients:",
                nonfinite_gradients,
            )
    
        assert not missing_gradients
        assert not nonfinite_gradients
    
        print("Gradient test passed.")
    
    
    def test_optimizer_update(
        model,
        inputs,
        targets,
    ):
        print("\n7. Testing optimizer update")
    
        optimizer = torch.optim.AdamW(
            model.parameters(),
            lr=1e-3,
        )
    
        name, parameter = next(
            model.named_parameters()
        )
    
        before = parameter.detach().clone()
    
        optimizer.zero_grad(
            set_to_none=True
        )
    
        _, loss = model(
            inputs,
            targets,
        )
    
        loss.backward()
    
        torch.nn.utils.clip_grad_norm_(
            model.parameters(),
            max_norm=1.0,
        )
    
        optimizer.step()
    
        maximum_change = (
            parameter.detach() - before
        ).abs().max().item()
    
        print("Tracked parameter:", name)
    
        print(
            "Maximum parameter change:",
            maximum_change,
        )
    
        assert maximum_change > 0
    
        print(
            "Optimizer update test passed."
        )
    
    
    def run_single_batch_overfit(
        tokenizer,
        train_ids,
        device,
        steps,
    ):
        print(
            "\n8. Running single-batch "
            "overfitting test"
        )
    
        config = MiniGPTConfig(
            vocab_size=tokenizer.vocab_size,
            block_size=32,
            embedding_dim=64,
            num_heads=4,
            num_layers=2,
            expansion_factor=4,
            dropout=0.0,
        )
    
        model = MiniGPT(config).to(device)
    
        inputs, targets = get_batch(
            data=train_ids,
            batch_size=8,
            block_size=config.block_size,
            device=device,
        )
    
        optimizer = torch.optim.AdamW(
            model.parameters(),
            lr=1e-3,
        )
    
        initial_loss = None
        final_loss = None
    
        for step in range(steps):
            optimizer.zero_grad(
                set_to_none=True
            )
    
            _, loss = model(
                inputs,
                targets,
            )
    
            if initial_loss is None:
                initial_loss = loss.item()
    
            assert_finite_tensor(
                loss,
                "single-batch loss",
            )
    
            loss.backward()
    
            torch.nn.utils.clip_grad_norm_(
                model.parameters(),
                max_norm=1.0,
            )
    
            optimizer.step()
    
            final_loss = loss.item()
    
            if (
                step % 50 == 0
                or step == steps - 1
            ):
                print(
                    f"Step {step:4d}: "
                    f"loss {final_loss:.4f}"
                )
    
        print(
            "Initial loss:",
            initial_loss,
        )
    
        print(
            "Final loss:",
            final_loss,
        )
    
        assert final_loss < initial_loss
    
        print(
            "Single-batch overfitting "
            "test passed."
        )
    
    
    def parse_args():
        parser = argparse.ArgumentParser(
            description=(
                "Run Mini-GPT diagnostic tests"
            )
        )
    
        parser.add_argument(
            "--checkpoint",
            type=str,
            default=(
                "checkpoints/mini_gpt_best.pt"
            ),
        )
    
        parser.add_argument(
            "--tokenizer",
            type=str,
            default=(
                "tokenizer/char_tokenizer.json"
            ),
        )
    
        parser.add_argument(
            "--train-data",
            type=str,
            default=(
                "data/encoded/train_ids.pt"
            ),
        )
    
        parser.add_argument(
            "--overfit-steps",
            type=int,
            default=300,
        )
    
        parser.add_argument(
            "--skip-overfit",
            action="store_true",
        )
    
        return parser.parse_args()
    
    
    def main():
        args = parse_args()
    
        torch.manual_seed(42)
    
        device = get_device()
    
        print("Using device:", device)
    
        tokenizer = CharTokenizer.from_file(
            args.tokenizer
        )
    
        train_ids = torch.load(
            args.train_data,
            map_location="cpu",
        ).long()
    
        checkpoint_path = Path(
            args.checkpoint
        )
    
        if not checkpoint_path.exists():
            raise FileNotFoundError(
                f"Checkpoint not found: "
                f"{checkpoint_path}"
            )
    
        model, checkpoint = load_model(
            checkpoint_path=checkpoint_path,
            device=device,
        )
    
        if (
            tokenizer.vocab_size
            != model.config.vocab_size
        ):
            raise ValueError(
                "Tokenizer and model vocabulary "
                "sizes do not match"
            )
    
        print(
            "Checkpoint step:",
            checkpoint.get("step"),
        )
    
        test_tokenizer(
            tokenizer,
            train_ids,
        )
    
        test_token_ids(
            tokenizer,
            train_ids,
        )
    
        test_block_size = min(
            32,
            model.config.block_size,
        )
    
        inputs, targets = test_batch(
            tokenizer=tokenizer,
            train_ids=train_ids,
            block_size=test_block_size,
            device=device,
        )
    
        test_forward_pass(
            model=model,
            tokenizer=tokenizer,
            inputs=inputs,
            targets=targets,
        )
    
        test_future_independence(
            model=model,
            tokenizer=tokenizer,
            device=device,
        )
    
        test_gradients(
            model=model,
            inputs=inputs,
            targets=targets,
        )
    
        test_optimizer_update(
            model=model,
            inputs=inputs,
            targets=targets,
        )
    
        if not args.skip_overfit:
            run_single_batch_overfit(
                tokenizer=tokenizer,
                train_ids=train_ids,
                device=device,
                steps=args.overfit_steps,
            )
    
        print(
            "\nAll requested diagnostics passed."
        )
    
    
    if __name__ == "__main__":
        main()
    

    Las versiones recientes de PyTorch han cambiado el comportamiento por defecto de torch.load para que cargue únicamente los pesos, por lo que, dependiendo de su versión y del contenido del punto de control, es posible que necesite establecer explícitamente weights_only; consulte la documentación actual.

    Ejecución de los diagnósticos

    Ejecute el conjunto completo con rutas por defecto:

    python debug_mini_gpt.py
    

    Omita la fase de sobreajuste para una verificación rápida. La bandera es --skip-overfit, con dos guiones al principio:

    python debug_mini_gpt.py - skip-overfit
    

    Utilice otro punto de control:

    python debug_mini_gpt.py \
      --checkpoint checkpoints/mini_gpt_latest.pt
    

    Dé al test de sobreajuste más pasos:

    python debug_mini_gpt.py \
      --overfit-steps 500
    

    El backslash al final continúa una orden en shells de estilo Unix; si el suyo no lo soporta, coloque la orden en una sola línea.

    El orden de depuración en la práctica

    Cuando el modelo funcione incorrectamente, siga estos pasos sin saltarse ninguno.

    Pasos del 1 al 5: datos y formas

    Lea los datos decodificados:

    Does the tokenized dataset decode correctly?
    

    Verifique el desplazamiento del destino:

    Does inputs[:, 1:] equal targets[:, :-1]?
    

    Verifique el rango de tokens:

    Are all IDs between 0 and vocab_size - 1?
    

    Verifique las formas del tensor:

    Inputs: [B, T]
    Targets: [B, T]
    Logits: [B, T, V]
    

    Verifique la pérdida inicial:

    Is it near log(vocab_size) for a new model?
    

    Pasos del 6 al 10: comportamiento del modelo y entrenamiento

    Verifique la independencia causal:

    Can changing the future affect prefix logits?
    

    La única respuesta aceptable es no. Verifique las gradientes:

    Are gradients present, finite, and nonzero?
    

    Verifique las actualizaciones de parámetros:

    Does optimizer.step() change weights?
    

    Memorice un lote:

    Can the model memorize a tiny fixed batch?
    

    Solo entonces inicie el entrenamiento completo:

    Only after all earlier tests pass should you invest in a long training run.
    

    Lista de verificación para cada fase

    Antes del entrenamiento:

    □ Dataset files exist
    □ Tokenizer round-trip works
    □ Token IDs are within vocabulary range
    □ Decoded data looks correct
    □ Inputs and targets are shifted by one
    □ Batch tensors use torch.long
    □ Model and batch use the same device
    □ Logits have shape [B, T, V]
    □ Initial loss is near log(V)
    □ Future-independence test passes
    □ All important parameters receive gradients
    □ Optimizer changes parameters
    □ Model can overfit one batch
    

    Durante el entrenamiento:

    □ Loss remains finite
    □ Gradient norms remain finite
    □ Learning rate follows the intended schedule
    □ Training loss decreases
    □ Validation loss is evaluated in eval mode
    □ Best checkpoint updates when validation improves
    □ Samples become more structured
    

    Durante la generación:

    □ Best checkpoint is loaded
    □ Matching tokenizer is loaded
    □ Model is in evaluation mode
    □ Context is cropped to block size
    □ Only final-position logits are sampled
    □ Temperature is positive
    □ Top-k does not exceed vocabulary size
    □ Repetition is measured, not only observed
    

    Hábitos que dificultan la depuración

    Cambiar muchos ajustes a la vez

    Si un experimento modifica todos estos elementos al mismo tiempo, no se puede atribuir el resultado a ninguno de ellos:

    Learning rate
    Batch size
    Dropout
    Model size
    Context length
    

    Cambie una variable importante por experimento.

    Juzgar solo por las muestras

    Un texto de calidad deficiente puede deberse a un entrenamiento insuficiente, una decodificación deficiente, el uso de un punto de control o tokenizador incorrecto, sobreajuste o subajuste, y las muestras no permiten distinguir entre estas causas. Primero examine las métricas y las pruebas del proceso.

    Silenciar advertencias

    Las advertencias que mencionan tensores reconfigurados, el uso de otro dispositivo como solución alternativa, valores NaN o infinitos, o claves de punto de control no coincidentes suelen indicar errores reales; entiéndalos antes de suprimirlos.

    Saltar las pruebas preliminares

    Antes de ejecutar una prueba prolongada, comience con algo sencillo:

    Tiny model
    Tiny batch
    Short context
    Few training steps
    

    Luego aumente la escala gradualmente.

    Tratar el recorte como una solución

    El recorte puede absorber una actualización de tamaño excesivo, pero un recorte constante indica que algo más profundo requiere atención: la tasa de aprendizaje, la inicialización, el escalado de la pérdida, la precisión numérica o las anomalías en los datos.

    Ejercicios: romper intencionadamente la cadena de procesamiento

    Se confía más en una prueba después de verla fallar, por lo que cada ejercicio incluye un error conocido.

    Eliminar el desplazamiento del objetivo

    Haga que las entradas y los objetivos sean idénticos, confirme que la prueba por lotes falla y luego restablezca el desplazamiento.

    Inyectar un token fuera del rango

    Establezca el ID de un token en:

    tokenizer.vocab_size
    

    La verificación de rango debe fallar, ya que el ID válido más alto es:

    vocab_size - 1
    

    Desactivar la máscara causal

    Elimine temporalmente la máscara y ejecute la prueba de independencia futura; al cambiar solo el sufijo, ahora deberían cambiar los logit del prefijo.

    Usar una tasa de aprendizaje absurda

    Establezca:

    learning_rate = 0.1
    

    Registra la pérdida, la norma del gradiente, los valores de los parámetros y las verificaciones de valores no finitos, y mantén la ejecución breve.

    Congela el modelo

    Aplica lo siguiente y observa cómo los tests de gradiente y optimizador lo reportan:

    for parameter in model.parameters():
        parameter.requires_grad = False
    

    Cargar en la arquitectura incorrecta

    Carga un punto de control en un modelo que difiere en uno de estos aspectos, y luego revisa los errores de clave faltante, clave inesperada y desfase de tamaño:

    Vocabulary size
    Embedding dimension
    Number of layers
    

    Comparar configuraciones de dropout

    Ejecuta la prueba con un solo lote con ambos valores y compara cuán rápido cada uno memoriza:

    dropout = 0.0
    dropout = 0.2
    

    Escribir un informe de depuración

    Guarda estos resultados en formato JSON para poder comparar ejecuciones y reproducir fallos:

    Tokenizer fingerprint
    Vocabulary size
    Token range
    Batch shape
    Initial loss
    Expected baseline
    Gradient norm
    Missing gradients
    Parameter update size
    Single-batch final loss
    

    Conclusiones clave

    • Un trabajo que finaliza sin errores aún puede aprender la tarea incorrecta.
  • Depure en orden de flujo de datos; la prueba de ida y vuelta, la verificación de rango y la afirmación de desplazamiento de un token capturan la mayoría de los errores en los datos.
  • Los valores lógicos deben ser [B, T, V], y un modelo nuevo debe comenzar cerca de log(vocab_size).
  • La prueba de prefijo compartido demuestra la causalidad a través del comportamiento.
  • Las verificaciones de gradiente y la comparación de parámetros antes y después confirman que puede ocurrir el aprendizaje; el sobreajuste en un solo lote confirma todo el ciclo.
  • Las verificaciones de valores no finitos y los mecanismos de detección localizan los fallos numéricos; el recorte solo los contiene.
  • Las curvas de entrenamiento y validación separan el sobreajuste del subajuste, y tanto la calidad del modelo como la decodificación determinan el texto generado.
  • Vuelva a construir los modelos a partir de la configuración del punto de control y verifique el tokenizador mediante su huella digital.
  • El flujo completo:

    Environment
    → files
    → tokenizer
    → token IDs
    → batch shifting
    → shapes
    → initial loss
    → causal independence
    → gradients
    → optimizer updates
    → one-batch overfitting
    → full training
    → evaluation
    → generation
    

    Detrás de todo esto hay una regla: no depure por intuición; escriba una prueba que aísle una suposición, confírmela y siga adelante.

    El siguiente paso lógico es una mejor representación de los datos. Los tokens de caracteres generan secuencias largas, mientras que los vocabularios de palabras se vuelven enormes; la codificación por pares de bytes aprende fusiones para secuencias de caracteres frecuentes, acortando las secuencias para que la misma ventana de contexto albergue más texto. Adoptarla implica entrenar las fusiones, recodificar WikiText-2 y redimensionar el vocabulario del modelo, y todas las verificaciones aquí aplican sin cambios:

    Character tokens
    → learned subword merges
    → shorter sequences
    → better use of the context window
    

    Lecturas relacionadas

  • Construyendo un tokenizador de codificación de pares de bytes desde cero para un pequeño GPT — Implemente un tokenizador BPE a nivel de carácter en Python, entrénelo con WikiText-2, guárdelo y obtenga su huella digital, y vuelva a entrenar un pequeño GPT con secuencias de tokens más cortas y densas.
  • De GPT-1 a los modelos de razonamiento: qué cambió cada generación para los desarrolladores — Trace la familia GPT desde el preentrenamiento de 2018 hasta los modelos de razonamiento, vea qué ideas añadió cada generación y utilice la visión GPT-4o y las salidas estructuradas desde Python.