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.pty la Fase 19 produjo un checkpoint best-val enexperiments/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]:
- Renderiza el prompt con cada candidato relleno en el hueco
___→Kfrases completas. - 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). - Escoge
argmax_k log_p(c_k). Esa es la predicción. - 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 comomodel_oovy 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.jsoncon 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.jsoncon 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¶
test_score_candidate_picks_correct— sobre un modelo fixture diminuto que hardcodeaworkscomo la continuación top deShe ___, verifica queclassify_probedevuelveworks.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).test_ppl_finite— sobre una frase en la que el modelo asigna probabilidad no nula a todos los tokens, PPL es finita y > 1.test_wilson_at_boundary—wilson_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.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.jsonidéntico byte a byte.
Condiciones de parada¶
Hecho cuando:
pytest tests/eval/test_harness.py -vpasa.just eval CHECKPOINT=...produceresults.json,per_slice.csv,per_slice.pngbajoexperiments/20-eval-report/<name>/.- 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).
- Re-ejecutar el mismo comando da un
results.jsonidéntico byte a byte.
Trampas¶
- Off-by-one en la posición del token candidato. El token en la posición
ide los logits predice el token en la posicióni+1del input. Puntúa el candidato comologits[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.encodeantepone<bos>, tuprefixtiene 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_oovse 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.