Skip to content

English · Español

Lab 01 — Cablear el harness de evaluación: perplexity + accuracy por slice

Objetivo: construir el harness de evaluación de extremo a extremo. Cargar un checkpoint, ejecutarlo sobre el conjunto de test held-out (reservado) para PPL, ejecutarlo sobre el conjunto de probes para clasificación, y emitir un blob JSON de resultados más una tabla de accuracy por slice.

Tiempo estimado: 120-180 minutos.

Prerrequisito: Lab 00 hecho (esquema de probes + probe-set validado). La Fase 18 produjo un checkpoint en experiments/18-train/checkpoints/final.pt y la Fase 19 produjo un checkpoint best-val en experiments/19-debug/checkpoints/best.pt.


Lo que produces

Un módulo nuevo:

  • src/eval/harness.py — carga un checkpoint, ejecuta PPL sobre un test set tokenizado, ejecuta clasificación sobre los probes, emite JSON.
  • src/eval/classify.py — la función de scoring por opción múltiple (basada en verosimilitud).
  • tests/eval/test_harness.py — tests unitarios sobre un modelo fixture diminuto.

Output de ejecución:

  • experiments/20-eval-report/<checkpoint_name>/results.json — todos los números en un único sitio.
  • experiments/20-eval-report/<checkpoint_name>/per_slice.csv — tabla de accuracy por slice.
  • experiments/20-eval-report/<checkpoint_name>/per_slice.png — gráfico de barras del mismo.

La función de scoring de clasificación

El modelo es un modelo de lenguaje, no un clasificador. Para puntuar un probe de opción múltiple con candidates = [c_1, ..., c_K]:

  1. Renderiza el prompt con cada candidato relleno en el hueco ___K frases completas.
  2. Para cada frase, calcula la log-verosimilitud del modelo sobre los tokens del candidato condicionados al prefijo del prompt (suma de log p(token_i | context) sobre los tokens del candidato).
  3. Escoge argmax_k log_p(c_k). Esa es la predicción.
  4. La confianza = softmax(log_p)_{argmax} sobre el conjunto de candidatos (no sobre el vocabulario completo).

Casos límite:

  • Candidatos con distinto número de tokens. Usa log-verosimilitud normalizada por longitud (divide entre el conteo de tokens) para evitar sesgar hacia las formas más cortas. Documenta esta elección en el informe.
  • Tokens fuera de vocabulario. Si un candidato se tokeniza como <unk>, la log-prob hace underflow; marca el probe como model_oov y excluye de la agregación de accuracy (cuéntalo aparte).
import numpy as np
import torch

@torch.no_grad()
def score_candidate(model, tokenizer, prompt: str, candidate: str) -> float:
    """Length-normalized log-likelihood of `candidate` given `prompt`."""
    prefix = tokenizer.encode(prompt.replace("___", "").rstrip())
    full = tokenizer.encode(prompt.replace("___", candidate))
    # candidate token ids are the suffix of `full` beyond `prefix`
    cand_ids = full[len(prefix):]
    if not cand_ids:
        raise ValueError("Empty candidate after tokenization")
    # forward pass to get log-probs for the candidate positions
    ids = torch.tensor([full], dtype=torch.long)
    logits = model(ids).logits[0]  # (T, V)
    log_probs = torch.log_softmax(logits, dim=-1)
    total = 0.0
    for i, tid in enumerate(cand_ids):
        # position predicting `cand_ids[i]` is `len(prefix) + i - 1`
        pos = len(prefix) + i - 1
        total += log_probs[pos, tid].item()
    return total / len(cand_ids)


def classify_probe(model, tokenizer, probe) -> tuple[str, float]:
    """Returns (predicted_form, confidence)."""
    scores = [score_candidate(model, tokenizer, probe.prompt, c)
              for c in probe.candidates]
    s = np.array(scores)
    probs = np.exp(s - s.max()); probs /= probs.sum()  # softmax over candidate set
    k = int(np.argmax(probs))
    return probe.candidates[k], float(probs[k])

TODOs

Bloque A — esqueleto de src/eval/harness.py

from pathlib import Path
import json

def run_eval(checkpoint_path: Path,
             probes_path: Path,
             test_path: Path,
             out_dir: Path,
             seed: int = 42) -> dict:
    """Load checkpoint, compute PPL on `test_path`, classify probes, write
    results.json + per_slice.csv + per_slice.png. Returns the results dict."""
    ...
  • seed_everything(seed) en la entrada.
  • Cargar modelo + tokenizer desde checkpoint_path.
  • Calcular ppl_test, ppl_train, ppl_val (los dos últimos para sanity — train debería ser el más bajo, test el más alto; si no, algo está filtrándose).
  • Calcular PPL desglosada por idioma (PPL solo-EN, PPL solo-ES).
  • Iterar probes, llamar a classify_probe, recopilar tuplas (probe.id, predicted, confidence, correct).
  • Agregar accuracy por slice: overall, by_language, by_regularity, by_tense, by_person, by_verb.
  • Escribir results.json con todos los números + la lista de predicciones por probe.
  • Escribir per_slice.csv (una fila por slice, columnas: slice_name, slice_value, n, correct, accuracy, wilson_lo, wilson_hi).
  • Pintar per_slice.png (gráfico de barras, 4 paneles: idioma, regularidad, tiempo, persona).
  • Persistir manifest.json con el hash del modelo, hash del probe-set, hash de commit del código, timestamp de eval, seed.

Bloque B — cómputo de perplexity

@torch.no_grad()
def compute_ppl(model, tokenizer, sentences: list[str]) -> float:
    total_nll = 0.0
    total_tokens = 0
    for s in sentences:
        ids = torch.tensor([tokenizer.encode(s)], dtype=torch.long)
        # teacher-forced loss over (ids[:-1] → ids[1:])
        logits = model(ids[:, :-1]).logits[0]
        targets = ids[0, 1:]
        nll = torch.nn.functional.cross_entropy(
            logits, targets, reduction="sum"
        ).item()
        total_nll += nll
        total_tokens += targets.numel()
    return float(np.exp(total_nll / total_tokens))
  • Vectoriza: agrupa las frases en batches con padding + máscara si el modelo lo soporta (el modelo de la Fase 17 debería). Si no, una por una está bien para nuestro N.
  • Añade un helper de intervalo de Wilson (se usa para cotas de accuracy, no para PPL).

Bloque C — intervalo de Wilson

import math

def wilson_interval(c: int, n: int, z: float = 1.96) -> tuple[float, float]:
    if n == 0:
        return (0.0, 0.0)
    p = c / n
    denom = 1 + z*z / n
    centre = (p + z*z / (2*n)) / denom
    margin = (z * math.sqrt(p*(1-p)/n + z*z/(4*n*n))) / denom
    return (max(0.0, centre - margin), min(1.0, centre + margin))

Se usa por slice. Con N=10-20 por celda, los intervalos de confianza (confidence intervals) serán amplios; documéntalo en el informe.

Bloque D — tests/eval/test_harness.py

  1. test_score_candidate_picks_correct — sobre un modelo fixture diminuto que hardcodea works como la continuación top de She ___, verifica que classify_probe devuelve works.
  2. test_length_normalization — dados dos candidatos con distinto número de tokens, verifica que el más largo no queda penalizado injustamente por la log-prob cruda (sin normalizar).
  3. test_ppl_finite — sobre una frase en la que el modelo asigna probabilidad no nula a todos los tokens, PPL es finita y > 1.
  4. test_wilson_at_boundarywilson_interval(0, 10) devuelve (0.0, algo_pequeño); wilson_interval(10, 10) devuelve (algo_cerca_de_1, 1.0). Simétrico a la aproximación normal rompiendo en los bordes.
  5. test_slice_aggregation — dado un conjunto conocido de predicciones de probes, el agregador produce los conteos y accuracies por slice esperados.

Bloque E — ejecutarlo

just eval CHECKPOINT=experiments/18-train/checkpoints/final.pt
just eval CHECKPOINT=experiments/19-debug/checkpoints/best.pt

Ambas ejecuciones producen sus propios subdirectorios bajo experiments/20-eval-report/. El Lab 03 los compara.

Restricciones

  • Greedy / temperature-0 solo. Sin sampling en el Lab 01. El Lab 02 añade métricas basadas en confianza; el Lab 03 (o la Fase 21) hace sampling.
  • Solo CPU está bien. N ≈ 80 probes; PPL test set ≈ 200 frases. Debería correr en < 60 segundos en el i5-8250U de Borja.
  • Determinista. Mismo checkpoint + mismo probe-set + misma seed = results.json idéntico byte a byte.

Condiciones de parada

Hecho cuando:

  1. pytest tests/eval/test_harness.py -v pasa.
  2. just eval CHECKPOINT=... produce results.json, per_slice.csv, per_slice.png bajo experiments/20-eval-report/<name>/.
  3. El gráfico de barras por slice muestra el desglose EN/ES, regular/irregular, por tiempo, por persona — cuatro paneles — y las diferencias relativas pasan el test visual (regular > irregular esperado; EN > ES si el corpus es EN-pesado).
  4. Re-ejecutar el mismo comando da un results.json idéntico byte a byte.

Trampas

  • Off-by-one en la posición del token candidato. El token en la posición i de los logits predice el token en la posición i+1 del input. Puntúa el candidato como logits[len(prefix)-1, cand_ids[0]] + logits[len(prefix), cand_ids[1]] + .... Si fallas en esto puntuarás los tokens del prompt, no los del candidato.
  • El tokenizer añade un token BOS. Si tokenizer.encode antepone <bos>, tu prefix tiene longitud +1 y el slicing baila. Hazlo coincidir con lo que la Fase 11 produjo.
  • El padding afecta a la PPL. Los tokens de pad deben enmascararse de la suma NLL. Más fácil: pre-loop sin batch en la primera pasada; vectoriza una vez verificada la corrección.
  • Probes en español rompen la tokenización. Si tu BPE (Fase 11) era solo-EN, los probes ES se tokenizarán mal y el contador model_oov se disparará. Decide en el informe si reentrenar el tokenizer (no — eso invalida los resultados de la Fase 18) o reportar la limitación.
  • Tamaños de celda por slice demasiado pequeños para un Wilson CI. Con ≥ 20 verbos y solo ~60 probes, el slice por verbo tiene 2-4 probes por verbo. El CI cubrirá [0.0, 1.0] — inútil. La Fase 20 reporta por-verbo como tabla de conteos crudos, no como porcentajes con CIs.

Cuándo consultar solutions/

Después de que pasen los cinco tests y al menos una ejecución de just eval produzca un per_slice.png no vacío. La solución en solutions/01-harness-ref.md (escrita al abrir la fase) cubre la trampa de la normalización por longitud y el camino de PPL en batch.


Siguiente lab: lab/02-calibration-and-adversarial.md.