// M3 · lección 03
El encoder: los labs
Los seis labs con sus salidas reales: el encoder bidireccional y el enmascarado, las tres cabezas con el tronco congelado, las tres etapas y la curva por etiquetas, la entrada por casillas desde cero, la medición contra la heurística, y la exportación con los embeddings. Después, el encoder de 39 millones que se publicó y la barra de evaluación en vivo.
Parte 3 de 3 del módulo «El encoder». Viene de «El encoder: medir sin engañarse» y cierra el módulo.
Labs
Al terminar esta sección tendrás el encoder preentrenado, las tres cabezas afinadas en los tres
modos, la curva por número de etiquetas, la comparación contra la heurística, el modelo exportado y
los datos de la isla de esta página. Todos los comandos se ejecutan desde la raíz del repo rukh.
Los directorios de las tiradas llevan una marca de tiempo (unique_run_name: true, para que una
segunda tirada no pise los checkpoints de la primera); en los comandos se escriben sin ella para que
se lean. Debajo de cada comando va la salida real de la tirada de referencia, sin recortar.
Lab 1 · El encoder bidireccional y el enmascarado
Primero, demuestra que el modelo es lo que dices que es. En M2 escribiste un test que exigía que
cambiar un token futuro no moviera los logits pasados; aquí el test tiene que exigir lo
contrario. Guarda este script como labs/m3/bidirectional.py en el repo rukh:
"""Show that the encoder is not causal, and that the masking recipe does what it claims."""
import torch
from rukh.models import EncoderConfig, PositionEncoderfrom rukh.models.encoder import MMM_IGNORE_INDEXfrom rukh.train.mmm import MaskingConfig, apply_masking, control_ids, mask_id, masking_generator
torch.manual_seed(0)cfg = EncoderConfig(n_layer=2, n_head=2, d_model=64, block=16)model = PositionEncoder(cfg).eval()print(f"preset parameters: {PositionEncoder(EncoderConfig()).num_params(False):,}")
# 1. Bidirectionality: change the LAST token and watch the FIRST hidden state move.idx = torch.randint(100, cfg.vocab_size, (1, 8))with torch.no_grad(): before = model(idx)[0, 0].clone() idx[0, -1] = (idx[0, -1] + 1) % cfg.vocab_size after = model(idx)[0, 0]delta = (before - after).abs().max().item()print(f"max |delta| on the FIRST token after changing the LAST one: {delta:.6f}")print("causal" if delta == 0.0 else "bidirectional: the future reaches the past")
# 2. Padding: the real tokens of a sequence must not depend on how much padding travels with it.short = torch.tensor([[7, 8, 9, 0, 0, 0]])tight = torch.tensor([[7, 8, 9]])with torch.no_grad(): padded = model(short, PositionEncoder.padding_mask(short))[0, :3] exact = model(tight, PositionEncoder.padding_mask(tight))[0]print(f"max |delta| between padded and tight: {(padded - exact).abs().max().item():.6f}")
# 3. The 80/10/10 recipe, counted over 10 000 tokens instead of trusted.masking = MaskingConfig()batch = torch.randint(max(control_ids("moves")) + 1, cfg.vocab_size, (100, 100))inputs, labels = apply_masking(batch, masking, cfg.vocab_size, "moves", masking_generator(0))selected = labels != MMM_IGNORE_INDEXtotal = int(selected.sum())masked = int((inputs[selected] == mask_id("moves")).sum())kept = int((inputs[selected] == batch[selected]).sum())print(f"selected {total / batch.numel():.3%} of the tokens (target 15 %)")print(f" <mask> {masked / total:.1%} kept {kept / total:.1%} random {1 - (masked + kept) / total:.1%}")uv run python labs/m3/bidirectional.pySalida real de una ejecución de comprobación (RTX 5090, 2026-09-21, dos minutos):
preset parameters: 15,052,800max |delta| on the FIRST token after changing the LAST one: 0.239878bidirectional: the future reaches the pastmax |delta| between padded and tight: 0.000001selected 14.910% of the tokens (target 15 %) <mask> 80.6% kept 9.9% random 9.5%Las tres comprobaciones salen como tienen que salir. La primera es la del módulo entero: cambiar el
último token mueve el estado oculto del primero en 0,24, un número que en el decoder de M2
sería exactamente 0,000000. La segunda dice que el relleno no contamina: la diferencia entre pasar
la secuencia rellena y pasarla justa es 1e-6, o sea ruido de bf16 y no información filtrada. La
tercera cuenta el sorteo en lugar de creérselo: 14,91 % de tokens seleccionados sobre un objetivo
del 15 %, repartidos 80,6 / 9,9 / 9,5.
El kept que imprime la tercera parte es una cota superior del 10 % real, porque una sustitución
aleatoria puede caer por casualidad en el mismo token; con un vocabulario de 1 968 jugadas la
diferencia es despreciable, pero saber por qué el número no es exacto es parte del lab.
La función que el script está comprobando es esta, y son veinte líneas:
MOVE_CONTROL_IDS = frozenset(range(CONTROL_COUNT)) # los 62 ids que no son jugadas
def apply_masking(batch, cfg, vocab, scheme="moves", generator=None): """Tapa parte de `batch`; devuelve `(inputs, labels)` con la misma forma.
`labels` lleva el token original donde la posición fue elegida y `MMM_IGNORE_INDEX` en todo lo demás, así que la pérdida puntúa exactamente las posiciones elegidas y nada más. """ control = control_ids(scheme) if vocab <= max(control): raise ValueError(f"un vocabulario de {vocab} tokens es todo tokens de control") device = batch.device is_control = torch.isin(batch, torch.tensor(sorted(control), device=device)) draw = torch.rand(batch.shape, generator=generator, device=device) selected = (draw < cfg.prob) & ~is_control labels = torch.where(selected, batch, torch.full_like(batch, MMM_IGNORE_INDEX))
decision = torch.rand(batch.shape, generator=generator, device=device) inputs = batch.clone() inputs[selected & (decision < cfg.mask_ratio)] = mask_id(scheme) random_upto = cfg.mask_ratio + cfg.random_ratio replace = selected & (decision >= cfg.mask_ratio) & (decision < random_upto) if bool(replace.any()): # Se sortea **por encima** del bloque de control: una jugada aleatoria, nunca un Elo ni un # resultado falsos. Esto solo se puede escribir así porque los tokens de control son un # prefijo del vocabulario, que es la decisión de enumeración de M1. low = max(control) + 1 noise = torch.randint(low, vocab, (int(replace.sum()),), generator=generator, device=device, dtype=batch.dtype) inputs[replace] = noise return inputs, labelsFíjate en que hay dos sorteos independientes: uno decide qué posiciones entran (el 15 %) y otro, sobre las elegidas, reparte el 80/10/10. Un solo sorteo con tres umbrales sobre el mismo número correlacionaría las dos decisiones: las posiciones con el valor más bajo serían siempre las enmascaradas, que no es lo mismo que un reparto al azar dentro de las elegidas.
Y el tercer bloque del script del lab es la razón por la que este fichero tiene un test que
cuenta en vez de uno que comprueba formas. Un ignore_index mal elegido no lanza ninguna
excepción: simplemente deja de puntuar un puñado de posiciones, o puntúa algunas que no debía, y la
curva de pérdida sale preciosa.
Y ahora el preentrenamiento. Doce mil pasos, lotes de 96 secuencias de 200 tokens con acumulación de
gradiente 3 —288 secuencias efectivas—, bf16 y torch.compile, sobre el mismo flujo empaquetado
que come el decoder:
uv run rukh train encoder --config configs/train/encoder-mmm.yamlSalida real de la ejecución de referencia (RTX 5090, 2026-09-19). Son las últimas seis evaluaciones de validación de las doce mil de la tirada, que tardó dieciséis minutos:
step 9500 val/loss 1.0621 val/top1 0.7396step 10000 val/loss 1.0456 val/top1 0.7430step 10500 val/loss 1.0291 val/top1 0.7467step 11000 val/loss 1.0195 val/top1 0.7483step 11500 val/loss 1.0106 val/top1 0.7507step 12000 val/loss 1.0032 val/top1 0.7518
input: movesmasking: 15% at 80%/10%/10%steps: 12000checkpoint: checkpoints/encoder-mmm-20260919-093554/step-12000.pt75,2 % de acierto en las jugadas tapadas. Tres de cada cuatro huecos se rellenan bien, y merece la pena pararse a entender por qué esa cifra no es comparable con el 51,1 % de top-1 del decoder de M2 aunque las dos se llamen “acierto sobre jugadas”. El decoder adivina el futuro: ve la partida hasta el ply n y tiene que elegir el ply n+1 entre treinta jugadas legales, muchas de ellas razonables, en una situación donde no hay una respuesta correcta. El encoder rellena un hueco con la partida entera alrededor: ve lo que vino después, y lo que vino después restringe brutalmente lo que pudo haber pasado. Si en el ply n+2 hay una torre en d1, la jugada tapada era seguramente la que la puso ahí. Es la misma asimetría que hay entre escribir la siguiente palabra de una frase y rellenar un hueco en una frase que ya está escrita entera.
Que la curva siga bajando en el paso 12 000 —de 1,0621 a 1,0032 en los últimos 2 500 pasos, sin aplanarse— dice además que el preentrenamiento no ha terminado de exprimirse. No se alargó porque dieciséis minutos es el presupuesto del módulo, y eso se escribe aquí en vez de fingir que el número es un techo.
Fíjate en la métrica que el bucle registra aparte de la pérdida: masked_tokens_per_s. Es la que de
verdad manda, porque los otros tokens_per_s cuentan también los tokens que no puntúa nadie. Y
fíjate en un detalle del bucle que parece contabilidad y no lo es: la pérdida del paso se pondera
por número de posiciones tapadas, porque un micro-lote donde el sorteo tapó pocas jugadas no debe
pesar lo mismo que uno donde tapó muchas.
// Ejercicio 01¿Por qué la validación del MLM se reinicia la semilla en cada evaluación?
Mira evaluate_mmm en src/rukh/train/mmm.py: antes de recorrer los lotes de validación crea un
generador nuevo con masking.seed, en vez de seguir usando el del entrenamiento. Explica qué
pasaría si no lo hiciera, y por qué eso hace que dos tiradas distintas se puedan comparar.
// SoluciónVer la solución
Si la validación usara el generador del entrenamiento, cada evaluación taparía posiciones distintas, porque el generador ha avanzado. La pérdida de validación del paso 500 y la del paso 1 000 se estarían calculando sobre dos tareas diferentes: una podría haber tapado jugadas fáciles (recapturas forzadas, enroques) y la otra difíciles, y la curva mezclaría la mejora del modelo con la dificultad del sorteo. Con la semilla reiniciada, todas las evaluaciones de la tirada tapan exactamente las mismas posiciones, así que la curva mide una sola cosa.
El beneficio se extiende entre tiradas: como la semilla está en la configuración y no depende del
estado del proceso, la evaluación de otra tirada con la misma masking.seed usa el mismo
conjunto de huecos. Comparar dos preentrenamientos deja de tener el asterisco de “sobre tareas
distintas”. Es la misma idea que fijar el conjunto de validación: la tarea de evaluación es
parte del instrumento de medida, y un instrumento que cambia entre medidas no mide.
Y el bucle de validación que ese ejercicio pregunta por qué reinicia la semilla:
@torch.no_grad()def evaluate_mmm(model, loader, batches, cfg, vocab, device, scheme="moves", autocast=None): """Pérdida y top-1 sobre las posiciones tapadas.
El generador se construye **de nuevo desde `cfg.seed`** en cada llamada, no se arrastra del entrenamiento. Si se arrastrara, cada evaluación taparía posiciones distintas: la pérdida del paso 500 y la del 1 000 se medirían sobre dos tareas diferentes —una puede haber tapado recapturas forzadas y la otra jugadas difíciles de medio juego— y la curva mezclaría el progreso del modelo con la dificultad del sorteo.
Con la semilla reiniciada, **todas** las evaluaciones de la tirada tapan exactamente las mismas posiciones. Y como la semilla está en la configuración y no en el estado del proceso, también lo hacen las de cualquier otra tirada con la misma semilla: la tarea de evaluación es parte del instrumento de medida, y un instrumento que cambia entre medidas no mide. """ model.eval() generator = masking_generator(cfg.seed, device) loss_sum = weighted = hits = counted = 0 for index, (x, _) in enumerate(loader): if index >= batches: break inputs, labels = apply_masking(x.to(device), cfg, vocab, scheme, generator) with autocast if autocast is not None else nullcontext(): logits, loss = model.masked_lm(inputs, labels) mask = labels != MMM_IGNORE_INDEX tokens = int(mask.sum()) if loss is not None and torch.isfinite(loss) and tokens: loss_sum += loss.float().item() * tokens weighted += tokens hits += int((logits.argmax(dim=-1) == labels)[mask].sum()) counted += tokens model.train() return (loss_sum / weighted if weighted else math.nan, hits / counted if counted else 0.0)Y el detalle del bucle de entrenamiento que la lección menciona de pasada: la pérdida se pondera
por el número de posiciones tapadas y no por los tokens del lote, porque un micro-lote donde el
sorteo tapó pocas jugadas no debe pesar lo mismo que uno donde tapó muchas. La métrica que se
registra al lado es masked_tokens_per_s por la misma razón: tokens_per_s contaría también el
85 % de posiciones que no puntúa nadie.
La tabla supervisada, antes de afinar nada
Las «438 093 posiciones etiquetadas» de los labs que vienen no existen hasta que alguien las
construye. Salen de positions-eval.parquet —una fila por FEN distinto, con la partida de la que
vino y el veredicto de Stockfish— y de tres decisiones que esta lección argumenta largo y que
conviene ver escritas.
def build_labels(cfg: LabelsConfig, frame=None) -> pl.DataFrame: source = frame if frame is not None else pl.read_parquet(resolve(cfg.positions_eval)) rows = ( source.select(SOURCE_COLUMNS) .with_columns( score_white=_score_white(), # la convención de `scoring.py`, no otra mover=pl.when(pl.col("fen").str.split(" ").list.get(1) == "w").then(1).otherwise(-1), ) .drop_nulls("score_white") .with_columns(score_mover=pl.col("mover") * pl.col("score_white")) ) # El self-join que hace posible la etiqueta de error: la fila del ply N-1 de la misma partida, # alineada con el ply N. Un left join, así que una posición cuyo predecesor falta conserva un # `before_best` nulo y, tres líneas más abajo, un `blunder` nulo. previous = rows.select( pl.col("game_id"), (pl.col("ply") + 1).alias("ply"), pl.col("score_mover").alias("before_best"), ) joined = rows.join(previous, on=["game_id", "ply"], how="left").with_columns( # Una **suma** y no una resta: el `score_mover` de aquí es del rival, que es quien mueve # ahora, así que lo que consiguió el que movió es menos eso. antes + aquí = lo que tiró. loss_cp=(pl.col("before_best") + pl.col("score_mover")).cast(pl.Int64) ) ... return labelled.with_columns(...)La etiqueta de valor está acotada. tanh(score / 400) desde el punto de vista de las
blancas, para que el número sea una propiedad de la posición y no de quien mueva. La cota no es
pulcritud: un mate vale ±10 000 en la convención del repo, y media docena de mates dominarían un
error cuadrático mientras el modelo gasta su capacidad clavando posiciones que ya están decididas.
La etiqueta de error necesita la posición anterior, y esa es toda la complicación del fichero. Se mide desde el lado del que movió: lo que podía haber tenido allí, contra lo que consiguió aquí.
Y una posición cuyo predecesor no está en la tabla —el primer ply, o un FEN que se dedupó en otra
partida— no tiene etiqueta de error en absoluto: null, nunca 0. Etiquetar «no lo sé» como «no
fue error» es una de las maneras más rápidas de arruinar un clasificador, y con una clase positiva
del 3,72 % envenenaría la clase mayoritaria con exactamente las filas de las que hay menos
evidencia.
El reparto es la otra mitad, y su código son cuatro líneas:
def game_split(game_id: int, val_fraction: float, seed: int) -> str: """De qué lado del reparto cae una partida: función pura de su id y de la semilla.""" digest = zlib.crc32(f"{seed}:{game_id}".encode()) return "val" if (digest % 1_000_000) / 1_000_000 < val_fraction else "train"
def counts(frame: pl.DataFrame) -> dict[str, int]: """Los conteos que hacen visible la tasa base, que es lo que la parte 2 desmonta.""" per_split = {name: int(frame.filter(pl.col("split") == name).height) for name in SPLITS} return { "positions": int(frame.height), **per_split, "blunder_labelled": int(frame.drop_nulls("blunder").height), "blunders": int(frame.filter(pl.col("blunder") == 1).height), }Por game_id y nunca por posición, por lo que la parte 2 explica en «Fuga de datos». Y función
pura del id y de la semilla, un CRC-32 y no hash(): Python aleatoriza el hash de las cadenas por
proceso. Un reparto que no se puede reproducir no es un reparto.
Fíjate en counts: publica blunder_labelled al lado de positions, y esos dos números son los
denominadores distintos de la parte 2 —10 000 posiciones, 7 453 con etiqueta de error, 3 660
puntuadas—. Una métrica calculada sobre el denominador equivocado de los tres es otra métrica.
Y una función que deliberadamente no es parte de build_labels:
def game_moves(frame: pl.DataFrame, games_dir: str = "data/uci") -> pl.DataFrame: """La fuente de prefijos de `frame`: una fila por **partida**, nunca por posición.
El esquema `squares` lee un FEN y nada más, y meterle dos meses de partidas haría que el camino barato pagara por el caro. El esquema `moves` llama a esto aparte, y escanea en perezoso con un semi join sobre los `game_id` distintos, así que solo se leen las partidas que llevan alguna etiqueta. """ wanted = frame.select(pl.col("game_id").unique()).lazy() return ( pl.scan_parquet((resolve(games_dir) / GAMES_GLOB).as_posix()) .select("game_id", "uci", "white_elo", "black_elo") .join(wanted, on="game_id", how="semi") .unique(subset=["game_id"], keep="first") .collect() )Ese unique(keep="first") es el asterisco de los +3,5 puntos que el callout de la parte 1 pone al
final: la tabla está deduplicada por fen4, así que el game_id de una fila es la partida que
primera llegó a esa posición. El prefijo que se reconstruye con él es una línea que llega
ahí, no necesariamente la que jugó la partida que puso la etiqueta.
Lab 2 · Las tres cabezas con el tronco congelado
Ahora el probe. Cuatro mil pasos, lotes de 256 posiciones, tasa de aprendizaje 1e-3 —mucho más
alta que la del preentrenamiento, porque solo se están moviendo tres capas lineales— y el encoder
congelado y en modo eval.
Los tres modos son dos funciones, y la segunda es la que casi nadie escribe:
def freeze_encoder(model: MultiHead, mode: Mode, last_n: int = 2) -> int: """Pone `requires_grad` según el modo; devuelve cuántos parámetros quedan entrenables.""" for param in model.encoder.parameters(): param.requires_grad_(mode == "full") if mode == "last-n": for block in list(model.encoder.blocks)[-last_n:]: for param in block.parameters(): param.requires_grad_(True) for param in model.encoder.ln_f.parameters(): param.requires_grad_(True) for param in model.head_parameters(): param.requires_grad_(True) return sum(p.numel() for p in model.parameters() if p.requires_grad)
def set_training_mode(model: MultiHead, mode: Mode) -> None: """`train()` en todo y después `eval()` en un encoder congelado.
**Congelar es más que `requires_grad = False`.** Un módulo que se queda en modo `train` sigue aplicando dropout, así que la misma posición daría un vector distinto en cada época: ruido que las cabezas no pueden aprender a compensar —no es una propiedad de la posición— y que el encoder no puede absorber —no está aprendiendo—. """ model.train() if mode == "probe": model.encoder.eval()Esa segunda función es la respuesta del ejercicio de más abajo escrita en el código, y el mismo
detalle que M5 vuelve a necesitar con la referencia congelada de DPO. La tercera fuente de
sorpresas que conviene tener en la cabeza aunque aquí no aplique: en una red con BatchNorm,
congelar los pesos no congela las estadísticas móviles, que se actualizan en modo train y
cambian la salida.
Y la curva por etiquetas necesita una tercera, la de los subconjuntos anidados:
def nested_subsets(game_ids: list[int], seed: int = 0) -> dict[float, np.ndarray]: """Máscaras para la curva: el 10 % dentro del 25 %, dentro del 50 %, dentro del 100 %.
Con cuatro muestras independientes, un bache en el punto del 50 % podría ser simplemente un sorteo desafortunado, y lo que estarías midiendo es la varianza del muestreo en vez del valor de los datos. Haciendo crecer un único conjunto, cada punto es el anterior más filas nuevas. """ keys = np.array([zlib.crc32(f"{seed}:{int(g)}".encode()) for g in game_ids], dtype=np.int64) order = np.argsort(keys, kind="mergesort") out = {} for fraction in (0.10, 0.25, 0.50, 1.00): take = max(1, int(round(fraction * len(order)))) mask = np.zeros(len(order), dtype=bool) mask[order[:take]] = True out[fraction] = mask return outuv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode probeSalida real de la ejecución de referencia (RTX 5090, 2026-09-19), sobre las 438 093 posiciones etiquetadas de la tabla supervisada, con el 10 % de validación repartido por partida:
mode: probe 100% of the labels (438093 rows) val/value_loss 0.0603 val/blunder_loss 0.1285 val/result_loss 0.8844 val/loss 0.6310 val/value_mae 0.1429 val/blunder_acc 0.9678 val/result_acc 0.4586La salida imprime, por cabeza, la pérdida de validación y una métrica legible: val/value_mae (el
error absoluto medio del valor, en la escala acotada, así que 0,1 son unos 40 centipeones en la zona
central), val/blunder_acc —que es exactitud, útil para ver que algo se mueve, pero no la cifra
que se publica, por lo que ya sabes del desbalanceo— y val/result_acc.
Antes de ejecutarlo conviene escribir en algún sitio qué esperas, porque la mitad del valor de un experimento está en haberse comprometido antes. Una expectativa razonable: el resultado de la partida debería ser la más difícil de las tres, porque su etiqueta es una propiedad de la partida y no de la posición; el valor debería ser la más fácil, porque el material está casi explícito en la entrada.
La primera parte se cumple: 45,9 % de acierto en el resultado sobre tres clases, apenas trece puntos por encima de tirar una moneda de tres caras, que para una tarea cuyo techo teórico es bajísimo está donde tiene que estar. La segunda también: 0,1429 de error absoluto medio del valor, unos 57 centipeones en la zona central de la escala.
Y el 96,78 % de val/blunder_acc es la cifra más peligrosa de todo el módulo. Parece que la
cabeza de errores es la que mejor va de las tres; es exactamente al revés. Los errores son el
3,72 % de las filas, así que un detector que conteste siempre “no hay error” —sin mirar el
tablero, sin saber qué es un alfil— saca 96,3 %. La cabeza está batiendo a esa constante por
cuatro décimas. Guarda esa cifra: en Cómo se mide y contra qué se desmonta del todo, y es la
lección más transferible del módulo.
// Ejercicio 02El probe no mueve el encoder: demuéstralo tú
Escribe un test que cargue un checkpoint del encoder, entrene tres pasos en modo probe y
compruebe que los pesos del tronco salen idénticos. ¿Basta con comparar dos tensores con ==? ¿Y
qué otra cosa, además de los pesos, podría cambiar el vector que ven las cabezas aunque los pesos
no se muevan?
// SoluciónVer la solución
Comparar con torch.equal sobre cada tensor del state_dict sirve, y es mejor que
allclose: aquí la exigencia es igualdad bit a bit, no parecido. Si el test pasara con
allclose pero no con equal, algo estaría moviendo los pesos un poquito, y “un poquito” en
tres pasos es “mucho” en cuatro mil.
Lo otro que puede cambiar el vector sin tocar un solo peso es el dropout. EncoderConfig
trae dropout = 0.1, y un módulo en modo train lo aplica aunque sus parámetros estén
congelados: la misma posición daría un vector distinto en cada pasada. Las cabezas verían ruido
que no pueden compensar, porque no es una propiedad de la posición, y el encoder no puede
absorberlo porque no está aprendiendo. Por eso set_training_mode pone el modelo en train y
acto seguido devuelve el encoder a eval cuando el modo es probe. También hay una tercera
fuente, más sutil, que no aplica aquí pero conviene tener en la cabeza: en una red con
BatchNorm, congelar los pesos no congela las estadísticas móviles, que se actualizan en modo
train y cambian la salida. Congelar es más que requires_grad = False.
Lab 3 · Las tres etapas y la curva por etiquetas
Repite el afinado subiendo la temperatura: primero las dos últimas capas y la normalización final, después el modelo entero.
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode last-nSalida real (RTX 5090, 2026-09-19), sobre las mismas 438 093 filas y los mismos 4 000 pasos:
mode: last-n 100% of the labels (438093 rows) val/value_loss 0.0396 val/blunder_loss 0.1270 val/result_loss 0.8723 val/loss 0.6028 val/value_mae 0.1180 val/blunder_acc 0.9678 val/result_acc 0.4775uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode fullSalida real de la ejecución de referencia (RTX 5090, 2026-09-19), sobre las mismas 438 093 filas:
mode: full 100% of the labels (438093 rows) val/value_loss 0.0578 val/blunder_loss 0.1310 val/result_loss 0.8991 val/loss 0.6384 val/value_mae 0.1354 val/blunder_acc 0.9678 val/result_acc 0.5142Compara las tres columnas, que es todo el punto del lab:
| Métrica | probe (tronco congelado) |
last-n (2 bloques + norma) |
full (todo el modelo) |
|---|---|---|---|
val/value_mae |
0,1429 | 0,1180 | 0,1354 |
val/blunder_acc |
0,9678 | 0,9678 | 0,9678 |
val/result_acc |
0,4586 | 0,4775 | 0,5142 |
val/blunder_loss |
0,1285 | 0,1270 | 0,1310 |
val/loss |
0,6310 | 0,6028 | 0,6384 |
Lee primero la fila del valor, porque dice lo contrario de lo que casi todo el mundo espera: afinar menos red salió mejor que afinarla entera. El error absoluto medio del valor baja de 0,1429 con el tronco congelado a 0,1180 descongelando solo los dos últimos bloques y la normalización final, y vuelve a subir a 0,1354 cuando se descongelan los quince millones de parámetros. El punto intermedio no está entre los dos extremos: los gana a los dos.
La lectura estándar de eso es la de siempre en transferencia, y conviene tenerla a mano porque te la
vas a encontrar fuera del ajedrez. Con 438 093 etiquetas y un tronco de 15 M de parámetros, el
afinado completo tiene grados de libertad de sobra para mover también las capas de abajo, que son
las que el preentrenamiento con masked move modeling dejó representando cosas genéricas de una
partida de ajedrez. Moverlas con la señal ruidosa de tres cabezas y 4 000 pasos las aleja de esa
representación sin construir otra mejor —lo que en la literatura se llama olvido catastrófico
cuando es grave y deriva de la representación cuando es leve—, mientras que la adaptación específica
de la tarea vive donde de verdad le toca vivir: en los últimos bloques, que son los que combinan
rasgos en algo parecido a “esta posición vale tanto”. last-n mueve exactamente esa parte y deja
quieta la otra.
Con una salvedad que no se puede saltar: esto es una tirada por modo, con una sola semilla. Las
tres barras del val/loss (0,6310 / 0,6028 / 0,6384) están en el mismo rango que el ruido entre
tiradas que vas a medir dentro de un momento en la curva por etiquetas, así que la ordenación
last-n > full > probe es sugerente, no establecida. Para establecerla harían falta varias
semillas por modo —tres o cinco— y publicar media y dispersión de cada una: si los intervalos se
solapan, lo honesto es decir que no se distinguen. Nueve entrenamientos no cupieron en el
presupuesto del hito; lo que sí cabe es no contar una tirada como si fuera un resultado.
El resultado de la partida sí ordena distinto, y ahí sigue ganando full con 51,4 % frente a
47,8 %: es la única de las tres tareas cuya etiqueta es una propiedad de la partida entera y no de
la posición, así que es la única que necesita que el tronco vaya a buscar información que el
preentrenamiento no dejó legible. Y fíjate en la fila que empeora al descongelar del todo: la
pérdida de la cabeza de errores sube de 0,1270 a 0,1310, con la exactitud clavada en el mismo
96,78 %. Es el aviso de siempre: cuando tres cabezas comparten tronco, una suma ponderada de
pérdidas puede mejorar una tarea a costa de otra, y el titular agregado no te dice cuál. Por eso el
bucle registra las tres por separado.
Y ahora la curva. --curve ejecuta la misma receta cuatro veces, con el 10, 25, 50 y 100 % de las
etiquetas de entrenamiento, sobre subconjuntos anidados, y nombra cada tirada con su porcentaje:
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode last-n --curveSe ejecuta en el modo que mejor salió, last-n, para que la curva mida el valor de las etiquetas y
no el de un modo de afinado de segunda. Salida real (RTX 5090, 2026-09-19); son cuatro
entrenamientos de 4 000 pasos encadenados, uno por porcentaje:
10% of the labels (43809 rows) val/value_mae 0.1217 val/blunder_acc 0.9559 val/result_acc 0.4794 val/loss 0.7185 25% of the labels (109523 rows) val/value_mae 0.1192 val/blunder_acc 0.9678 val/result_acc 0.4711 val/loss 0.6159 50% of the labels (219046 rows) val/value_mae 0.1210 val/blunder_acc 0.9678 val/result_acc 0.4767 val/loss 0.6105 100% of the labels (438093 rows) val/value_mae 0.1215 val/blunder_acc 0.9678 val/result_acc 0.4709 val/loss 0.6133Puesta en tabla, que es como se lee:
| Etiquetas | Filas | val/value_mae |
val/blunder_acc |
val/loss |
|---|---|---|---|---|
| 10 % | 43 809 | 0,1217 | 0,9559 | 0,7185 |
| 25 % | 109 523 | 0,1192 | 0,9678 | 0,6159 |
| 50 % | 219 046 | 0,1210 | 0,9678 | 0,6105 |
| 100 % | 438 093 | 0,1215 | 0,9678 | 0,6133 |
La curva está plana desde el 25 %. Pasar de 109 523 a 438 093 etiquetas —cuadruplicar el gasto en evaluaciones de Stockfish— mueve el error del valor de 0,1192 a 0,1215, que es peor, y deja la exactitud de errores exactamente igual. A partir de unas cien mil etiquetas, más etiquetas no compran nada medible en este montaje. Esa es la pregunta con la que se abrió el módulo —¿cuántas etiquetas hacen falta de verdad?— respondida con datos en vez de con intuición, y la respuesta es que con la cuarta parte del conjunto habría bastado.
Ahora la parte que hay que leer con la misma honestidad. La diferencia entre el mejor y el peor de
esos cuatro puntos es 0,1217 − 0,1192 = 0,0025. Y ahora mira la tirada last-n de más arriba:
misma receta, mismo modo, el mismo 100 % de las etiquetas, y 0,1180 frente a los 0,1215 del
punto del 100 % de la curva. Eso son 0,0035, todavía más que el ancho entero de la curva. Dos
tiradas con los mismos datos se separan más que dos tiradas con cuatro veces más datos. Eso no invalida la curva: la refuerza, porque significa que la única lectura
posible es “plana” y que cualquier ordenación dentro de esos cuatro puntos sería leer ruido. Y es,
en sí misma, la lección que más veces vas a necesitar: antes de explicar una diferencia pequeña,
mide cuánto se mueve tu montaje cuando no cambias nada. Una segunda tirada idéntica es el
experimento más barato que existe y casi nadie lo hace.
Con una excepción que sí supera ese ruido: la exactitud de errores en el 10 %, 0,9559 frente a 0,9678 en los otros tres. Son 1,2 puntos, y los otros tres puntos coinciden hasta la última cifra entre sí y con la tirada suelta, así que es la única diferencia de toda la tabla que no cabe dentro del ruido y la única que se puede afirmar. Tiene sentido: los errores son el 3,72 % de las filas, así que el 10 % del conjunto deja unas 1 600 posiciones con etiqueta positiva, y por debajo de ese orden de magnitud la clase rara empieza a escasear de verdad. El cuello no es el número de filas, es el número de filas de la clase que importa —y esa es otra regla general: en un problema desbalanceado, el tamaño efectivo del conjunto es el de la clase minoritaria.
// Ejercicio 03Lee tu propia curva antes de verla
Dibuja en un papel las tres formas que puede tener la curva de F1 contra número de etiquetas: (a) plana desde el 10 %, (b) subiendo y aplanándose hacia el 50 %, (c) subiendo todavía en el 100 %. Para cada una, escribe qué harías a continuación con un presupuesto limitado. Y añade una cuarta: ¿qué significaría que la curva baje entre el 50 % y el 100 %?
// SoluciónVer la solución
(a) Plana desde el 10 %: las etiquetas no son el cuello de botella. O la tarea es fácil y ya
está resuelta, o el modelo o la representación limitan. Lo siguiente es cambiar el modo de
afinado (probe → full), cambiar la representación de entrada, o revisar si la etiqueta tiene
tanto ruido que más cantidad no añade información. Etiquetar más es lo único que seguro no hay
que hacer.
(b) Subiendo y aplanándose hacia el 50 %: el punto dulce está ahí, y el 100 % solo compra los últimos decimales. Con presupuesto limitado, congelas el tamaño del conjunto y gastas en otra cosa: más modos de afinado, más semillas para tener barras de error, o una segunda representación.
(c) Subiendo en el 100 %: etiquetar más es la inversión más rentable que te queda, y ninguna idea arquitectónica va a competir con eso. Es también la señal de que cualquier comparación entre arquitecturas hecha con estas etiquetas está limitada por los datos y no por el modelo, así que sus conclusiones son provisionales.
Que baje entre el 50 % y el 100 % no puede deberse a “más datos hacen daño”: con subconjuntos
anidados, el conjunto grande contiene al pequeño. Las explicaciones reales son otras. Puede ser
que el número de pasos esté fijo (max_steps: 4000) y con cuatro veces más filas el modelo dé
muchas menos pasadas por cada una: estás comparando dos regímenes de entrenamiento distintos, no
dos cantidades de datos. Puede ser que las filas nuevas sean sistemáticamente distintas —el
orden es un CRC-32, así que no debería—, o simplemente ruido de una sola semilla, que es el
motivo por el que una curva de un único sorteo se lee con prudencia y una diferencia de dos
puntos no se celebra.
Lab 3b · La otra entrada: las casillas desde cero
Queda la tercera fila de la tabla de la parte 1, la de squares, y es la que el lab 5 necesita:
la isla de esta página y los embeddings salen de este checkpoint y no del de jugadas, porque
los dos scripts tokenizan un FEN. La config es encoder-heads.yaml, la del esquema de casillas,
y tiene una diferencia que decide el modo: encoder_ckpt: null. No hay masked move modeling sobre
casillas —P1 no dejó un flujo empaquetado de tableros—, así que el encoder arranca de pesos
aleatorios, y con pesos aleatorios probe y last-n no significan nada (congelarían ruido). El
único modo con sentido es full, y es el que produjo el checkpoint de referencia:
uv run rukh train heads --config configs/train/encoder-heads.yaml --mode fullCuatro mil pasos, lotes de 256 posiciones —el doble que en jugadas, porque un tablero son siempre 69 tokens y un prefijo hasta 200—, mismas 438 093 filas y mismo reparto por partida. Salida real de una ejecución de comprobación (RTX 5090, 2026-09-21, dos minutos):
step 3500 val/loss 0.6204step 3750 val/loss 0.6184step 4000 val/loss 0.6192mode: full 100% of the labels (438093 rows) val/value_loss 0.0388 val/blunder_loss 0.1477 val/result_loss 0.8653 val/loss 0.6192 val/value_mae 0.1215 val/blunder_acc 0.9629 val/result_acc 0.4779 checkpoint checkpoints/encoder-heads-20260921-185425/step-4000.ptEl directorio que deja no lleva -moves: configs/train/encoder-heads.yaml trae
run_name: encoder-heads, así que sale checkpoints/encoder-heads-<fecha>/; el de la tirada de
referencia es checkpoints/encoder-heads-20260919-100532/, y su best.pt guarda como best_val
un val/loss mínimo de 0,6248 (los de jugadas guardan 0,6274 en full y 0,5997 en last-n).
Evalúalo con la misma suite que el lab 4 pero con otra etapa, para que el informe no pise al del
esquema de jugadas:
uv run rukh eval encoder --model checkpoints/encoder-heads-20260919-100532/best.pt --stage encoder-squaresTitular real de artifacts/eval/encoder-squares/report.md (RTX 5090, 2026-09-19), sobre las
mismas 10 000 posiciones retenidas:
Blunder F1, encoder (tuned, p >= 0.04459) 14.6 %Blunder F1, encoder (fixed, p >= 0.5) 0.0 %Blunder F1, material baseline 8.9 %Margin over the baseline +5.7 pointsMargin >= 5 points yesBlunder ROC AUC 0.696Blunder average precision 0.091Blunder base rate 3.7 %Value vs tanh(cp / 400), Spearman 0.422Value vs tanh(cp / 400), Pearson 0.688Value correlation >= 0.80 noResult accuracy 50.2 %Positions 10,000 (7,453 with a blunder label, 3,660 of them scored)
detector operating point items blunders flagged P R F1encoder tuned, p >= 0.04459 3660 144 596 9.1 % 37.5 % 14.6 %encoder fixed, p >= 0.5 3660 144 0 0.0 % 0.0 % 0.0 %heuristic rule, no threshold to tune 3660 144 2359 4.7 % 77.1 % 8.9 %
encoder probability span: 0.0022 to 0.1930 (mean 0.0287)Son las cifras de la fila squares de la parte 1, y dos de ellas hay que llevarlas apuntadas al
lab 5: el umbral que la mitad tune eligió para este checkpoint es 0,04459 —cada
checkpoint tiene el suyo; el last-n de jugadas sale en 0,0663— y la probabilidad más alta que
esta cabeza le da a una posición es 0,1930, así que en el 0,5 de fábrica tampoco se dispara nunca.
Y fíjate en que, aun sin preentrenamiento y sin ver la jugada anterior, bate a la heurística por
5,7 puntos: la representación por casillas lleva bastante información sobre lo que se acaba de
tirar, solo que menos que la línea entera (0,696 de ROC AUC frente a 0,740).
Lab 4 · Medir contra la heurística
Antes de nada, la ruta del checkpoint, que no es la que uno escribiría de memoria. Las dos configs
traen unique_run_name: true, así que cada ejecución escribe en un directorio con marca de
tiempo, y el run_name distingue el esquema, no el modo: checkpoints/encoder-heads-moves-<fecha>/
para las jugadas (encoder-heads-moves.yaml) y checkpoints/encoder-heads-<fecha>/ para las
casillas (encoder-heads.yaml, lab 3b). El modo (probe, last-n, full) no aparece en el nombre
—lo elige --mode y queda dentro del checkpoint, en cfg.mode—, así que un
checkpoints/encoder-heads-full/ no existe; los tres del lab 3 son tres carpetas con tres fechas.
El último directorio de una serie sale así, y en los comandos de abajo se escriben sin fecha
(checkpoints/encoder-heads-moves/ para jugadas, checkpoints/encoder-heads/ para casillas) por
brevedad: sustitúyelos por los que te salgan aquí.
ls -td checkpoints/encoder-heads-moves-*/ | head -1La suite del encoder se ejecuta sobre un checkpoint de las cabezas, no del preentrenamiento. El
que se evalúa y se publica es el del modo last-n del lab 3, y no por costumbre: se estuvo a punto
de subir ese checkpoint con las métricas del full, que son de otro modelo, y evaluar el que se
publica es lo que destapó que además era el mejor (D-061 en el registro de decisiones):
uv run rukh eval encoder --model checkpoints/encoder-heads-moves-20260919-101904/best.pt --stage encoderTitular real de artifacts/eval/encoder/report.md en la ejecución de referencia (RTX 5090,
2026-09-19), sobre 10 000 posiciones del conjunto retenido:
Blunder F1, encoder (tuned, p >= 0.06631) 18.0 %Blunder F1, encoder (fixed, p >= 0.5) 0.0 %Blunder F1, material baseline 8.9 %Margin over the baseline +9.2 pointsMargin >= 5 points yesBlunder ROC AUC 0.740Blunder average precision 0.112Blunder base rate 3.7 %Value vs tanh(cp / 400), Spearman 0.520Value vs tanh(cp / 400), Pearson 0.648Value correlation >= 0.80 noResult accuracy 50.0 %Positions 10,000 (7,453 with a blunder label, 3,660 of them scored)Y el desglose por punto de operación, que es donde está la historia:
detector operating point items blunders flagged P R F1encoder tuned, p >= 0.06631 3660 144 488 11.7 % 39.6 % 18.0 %encoder fixed, p >= 0.5 3660 144 0 0.0 % 0.0 % 0.0 %heuristic rule, no threshold to tune 3660 144 2359 4.7 % 77.1 % 8.9 %
encoder probability span: 0.0020 to 0.3102 (mean 0.0353)Los dos criterios de GOAL.md salen en direcciones opuestas y los dos se publican tal cual: el de
detección de errores se cumple con +9,2 puntos sobre la heurística; el de correlación de valor
no se cumple, con 0,520 de Spearman frente a un listón de 0,80 al cerrar el hito y 0,6665
después de arreglar la pérdida. La parte anterior, «medir sin
engañarse», explica de dónde sale cada número, por qué el 0,0 % de la fila del umbral fijo no es un
modelo roto y qué falló en el valor.
La salida trae, en este orden: cuántas posiciones se evaluaron y cuántas de ellas tenían etiqueta de
error; precisión, exhaustividad y F1 del encoder y de la heurística, en el umbral ajustado y en el
fijo; el margen en puntos de F1 y si llega al listón de cinco; ROC AUC y precisión media, que no
dependen de ningún umbral; Pearson y Spearman del valor contra tanh(cp/400); la exactitud del
resultado; y la curva por etiquetas si el checkpoint la lleva. Escribe además
artifacts/eval/<stage>/report.md y una fila en artifacts/web/results.json, que es la que llega a
la tabla única de esta web con pnpm sync:data. <stage> es lo que pases en --stage; sin esa
opción es el nombre del directorio del checkpoint, o sea encoder-heads-<marca de tiempo>, y
tanto la carpeta del informe como la fila de la tabla cambiarían de nombre en cada ejecución. Por
eso el comando de arriba lo fija en encoder: el informe queda en artifacts/eval/encoder/report.md
y la fila se actualiza en vez de duplicarse.
La heurística es la parte lenta —un tablero de python-chess por fila—, así que sus veredictos van
a una caché SQLite bajo una clave propia: no dependen de los pesos, así que evaluar un segundo
checkpoint sobre las mismas filas no vuelve a pagarlos. Con --no-cache se recalculan.
// Ejercicio 04Si el encoder no bate a la heurística por cinco puntos, ¿qué haces?
GOAL.md pide un margen de cinco puntos de F1. Supón que sale uno. Enumera, en orden de coste, qué
comprobarías antes de tocar el modelo, y di qué no vale hacer.
// SoluciónVer la solución
Lo primero, y es gratis, es mirar la descomposición en vez del titular. ¿El encoder tiene precisión alta y exhaustividad baja, o al revés? Si marca poquísimo, el umbral de 0,5 puede estar mal para una clase rara y mover ese único número puede valer varios puntos de F1 sin tocar un peso. Eso no es hacer trampa siempre que el umbral se elija en validación y se publique.
Lo segundo: ¿cuántas filas tenían etiqueta de error? Si son pocas, el intervalo de confianza del F1 es ancho y un punto de diferencia no significa nada; antes de rediseñar nada hay que saber si la diferencia es medible.
Lo tercero, ya con coste: la curva por etiquetas. Si sigue subiendo en el 100 %, el camino es
etiquetar más, no cambiar el modelo. Después, --mode full si venías de probe, y después
reabrir la representación de entrada, que es el experimento grande del módulo.
Lo que no vale: mover el listón. Tampoco vale cambiar el conjunto de evaluación, ni redefinir
“error” a 150 centipeones porque así sale mejor, ni comparar el encoder sobre unas filas y la
heurística sobre otras. Si después de todo el margen sigue siendo de un punto, la cifra se
publica tal cual y se escribe por qué, que es exactamente lo que GOAL.md manda hacer. Un
resultado negativo documentado vale más que un resultado positivo cocinado, y además es el que
enseña.
Lab 5 · Exportar, embeddings y los datos de la isla
El navegador necesita el encoder en ONNX, y con dos salidas nada más: la demo pinta el valor y la alerta de error, y no tiene ningún uso para los logits del MLM ni para el resultado.
uv run rukh export --ckpt checkpoints/encoder-heads-moves/best.pt --out artifacts/onnx/encoder --kind encoder --fp16 --int8 --check-paritySalida real de una ejecución de comprobación (RTX 5090, 2026-09-21, dos minutos):
exporter: dynamo (opset 18)kind: encoder -> value, blundershapes: batch dynamic=True, sequence dynamic=True (verified), block=200fp32: artifacts/onnx/encoder/model.onnx (60735659 bytes)fp16: artifacts/onnx/encoder/model-fp16.onnx (30648768 bytes, 0.50 of fp32)int8: artifacts/onnx/encoder/model-int8.onnx (18254207 bytes, 0.30 of fp32)parity fp32: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 1.073e-06)parity fp16: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 0.0009496)parity int8: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 0.0241)60,7 MB en fp32, 30,6 en fp16, 18,3 en int8, y lo importante: el 100 % de las decisiones de error coinciden con PyTorch en las tres precisiones. El valor se mueve 0,024 como mucho en int8, que en la escala acotada son unos diez centipeones y en la barra de la demo no se ve.
Compáralo con lo que te salió en M2: allí el int8 del decoder cambiaba el 4,6 % de sus jugadas.
Es la misma técnica de cuantización sobre dos modelos del mismo proyecto, con resultados opuestos, y
la diferencia no es suerte. El decoder termina en un argmax sobre 2 030 logits: dos jugadas
razonables están a menudo a milésimas, y un error de redondeo que no cambia la evaluación de nada sí
cambia cuál gana. El encoder termina en dos escalares —un tanh y un sigmoide— y la decisión de
error es un umbral sobre uno de ellos: para que cambie, el ruido tiene que cruzar la frontera, y
0,024 solo la cruza si la probabilidad ya estaba pegada al umbral. La sensibilidad a la
cuantización es una propiedad de la cabeza, no del cuerpo, y esa frase vale para cualquier modelo
que vayas a cuantizar: pregúntate siempre cuántos candidatos compiten en la última operación. Es la
diferencia entre una foto finish y un semáforo: en la foto finish, un poco de desenfoque cambia
quién gana, porque dos corredores están a milésimas; en el semáforo, el mismo desenfoque solo
importa si estabas justo en el ámbar.
Tres diferencias con la exportación del decoder que hiciste en M2. La primera: el eje de secuencia
depende del esquema, y la salida lo dice. El checkpoint exportado aquí es el de jugadas, cuya
secuencia crece con la partida, y por eso anuncia sequence dynamic=True (verified); el de
casillas siempre tiene 69 tokens, así que ese eje se fija y --seq-len no pinta nada. No es un
detalle cosmético: un eje que se declara dinámico y no lo es se convierte en un error en el
navegador la primera vez que llega una secuencia de otro tamaño. La segunda: la paridad no se mide sobre la jugada
elegida sino sobre las dos salidas —la coincidencia de la decisión de error y la diferencia
máxima absoluta del valor—, que es lo que de verdad ve el usuario. La tercera: los metadatos
rukh_* que viajan dentro del fichero llevan además rukh_kind=encoder y rukh_heads, de forma
que un .onnx suelto sigue sabiendo decir qué es y qué devuelve.
La salida secundaria del encoder son los embeddings de posición, que no usa la demo pero sí la fase 2 del curso, cuando montes recuperación de posiciones parecidas:
uv run rukh encoder embed --positions data/evals/positions-eval.parquet --out artifacts/embeddings/positions.npy --ckpt checkpoints/encoder-heads/best.ptSalida real, con el checkpoint del esquema de casillas del lab 3b
(checkpoints/encoder-heads-20260919-100532/best.pt), que es el que lee un FEN por fila:
wrote 488159 embeddings of 384 dimensions to artifacts\embeddings\positions.npypositions: 488159dim: 384 (mean pooling, squares scheme)array: artifacts/embeddings/positions.npysidecar: artifacts/embeddings/positions.parquet (row, fen4)Esos 488 159 vectores de 384 dimensiones son exactamente el índice sobre el que buscará la recuperación de posiciones parecidas de A1, en la fase 2: cuando allí preguntes “posiciones como esta”, lo que se compara es un vector de esta matriz contra todos los demás.
Fíjate en que escribe dos ficheros: la matriz .npy y un .parquet de acompañamiento con row
y fen4 en el mismo orden. Una matriz de flotantes sin saber qué fila es qué posición no sirve para
nada, y un .npy no tiene sitio donde decirlo. Es una caja de zapatos llena de fotos sin nombre
detrás: las fotos están, pero nadie sabe de quién son. Es una regla general de los artefactos
numéricos: el índice viaja con los datos o los datos no existen.
Y queda el script que alimenta la isla de esta página. Guarda esto como
labs/m3/value_bar_export.py en el repo rukh: recorre una partida, evalúa cada posición con
las cabezas del encoder y con Stockfish, y escribe artifacts/web/value-bar.json con el esquema
rukh-value-bar/1 documentado en rukh-lab/src/data/README.md.
"""Export one game's encoder and Stockfish evaluations to artifacts/web/value-bar.json."""
import jsonimport mathfrom datetime import UTC, datetimefrom pathlib import Path
import chessimport chess.engineimport torch
from rukh.engine import find_stockfishfrom rukh.models.squares import fen_to_tokensfrom rukh.train import load_any
CKPT = Path("checkpoints/encoder-heads/best.pt") # el de casillas (lab 3b); el directorio real lleva marca de tiempoOUT = Path("artifacts/web/value-bar.json")VALUE_SCALE = 400.0 # the tanh(cp / 400) of rukh.data.labels: both curves must share itTHRESHOLD = 0.5 # the blunder threshold of configs/eval/encoder.yamlDEPTH = 12 # Stockfish depth; deeper is slower and barely moves the curve at club level# Byrne-Fischer, New York 1956: the Game of the Century, where 17...Be6 offers the queen.MOVES = ( "g1f3 g8f6 c2c4 g7g6 b1c3 f8g7 d2d4 e8g8 c1f4 d7d5 d1b3 d5c4 b3c4 c7c6 e2e4 b8d7 " "a1d1 d7b6 c4c5 c8g4 f4g5 b6a4 c5a3 a4c3 b2c3 f6e4 g5e7 d8b6 f1c4 e4c3 e7c5 f8e8 " "e1f1 g4e6").split()
model, _payload, kind = load_any(CKPT)if kind != "encoder": raise SystemExit(f"{CKPT} is a {kind} checkpoint; the value bar needs the encoder heads")model.eval()
engine_path = find_stockfish()if engine_path is None: raise SystemExit("Stockfish not found: the second curve is the whole point of this figure")
board = chess.Board()san: list[str] = []fens: list[str] = []for uci in MOVES: # fail loudly if the line is not legal move = chess.Move.from_uci(uci) san.append(board.san(move)) board.push(move) fens.append(board.fen())
idx = torch.tensor([fen_to_tokens(fen) for fen in fens], dtype=torch.long)with torch.no_grad(): outputs = model(idx)values = outputs["value"].float().tolist()blunders = torch.sigmoid(outputs["blunder"].float()).tolist()
stockfish: list[float | None] = []with chess.engine.SimpleEngine.popen_uci(str(engine_path)) as engine: for fen in fens: info = engine.analyse(chess.Board(fen), chess.engine.Limit(depth=DEPTH)) score = info["score"].white() cp = score.score(mate_score=10000) stockfish.append(None if cp is None else math.tanh(cp / VALUE_SCALE))
OUT.parent.mkdir(parents=True, exist_ok=True)OUT.write_text( json.dumps( { "schema": "rukh-value-bar/1", "game": {"moves": MOVES, "san": san}, "series": [ { "ply": ply + 1, "encoder": round(values[ply], 4), "stockfish": None if stockfish[ply] is None else round(stockfish[ply], 4), "blunder": blunders[ply] > THRESHOLD, } for ply in range(len(MOVES)) ], "meta": { "model": "rukh-encoder", "checkpoint": str(CKPT), "white": "Byrne, D.", "black": "Fischer, R.", "event": "New York, 1956", "result": "0-1", "value_scale": VALUE_SCALE, "threshold": THRESHOLD, "generated": datetime.now(UTC).isoformat(timespec="seconds"), }, } ), encoding="utf-8",)print(f"wrote {OUT} ({len(MOVES)} plies, depth {DEPTH})")uv run python labs/m3/value_bar_export.pySalida real de una ejecución de comprobación (RTX 5090, 2026-09-21, dos minutos):
wrote artifacts/web/value-bar.json (34 plies, depth 12)El fichero llega a esta web con pnpm sync:data, igual que los dos de M2. La figura que hay más
abajo es exactamente ese JSON, generado con el checkpoint de casillas del lab 3b
(checkpoints/encoder-heads-20260919-100532/best.pt): el script tokeniza un FEN con
fen_to_tokens, así que el esquema de jugadas no le sirve tal cual.
Y hay una cosa que mirar en el fichero antes de mirar el gráfico: ningún ply sale marcado como
error. No es un fallo del script ni del modelo. El THRESHOLD = 0.5 que tiene escrito es el de
configs/eval/encoder.yaml, y ya sabes por la evaluación del lab 3b que las probabilidades de esta
cabeza no pasan de 0,1930: en 0,5 no se dispara nunca. La figura enseña, sin querer, la misma
lección que la tabla de puntos de operación, y por eso se publica así en vez de bajarle el umbral a
mano hasta que salgan verticales bonitas. Cambiar THRESHOLD a 0,04459 —el umbral que la mitad
tune eligió para este checkpoint de casillas; el del last-n de jugadas es 0,0663 y no vale
aquí, porque cada cabeza tiene su propia escala— es el lab que te llevas a casa.
// Ejercicio 05¿Por qué la partida del script termina en 17…Ae6 y no sigue?
El script se para justo en la jugada del sacrificio. Explica qué se vería si continuara hasta el final de la partida, y por qué para esta figura concreta cortar ahí enseña más. Después piensa en el problema técnico: ¿qué le pasaría al eje vertical si la partida llegara al mate?
// SoluciónVer la solución
Si continuara, las dos curvas se separarían cada vez más en la dirección de las negras hasta pegarse al suelo, porque la partida se decide. La figura pasaría a estar dominada por un tramo final en el que las dos fuentes están de acuerdo y no hay nada que mirar, y el punto interesante —el ply donde la heurística, y quizá también el encoder, llaman error a una jugada genial— quedaría aplastado en un rincón del eje horizontal. Cortar en el sacrificio deja la figura centrada en la pregunta del módulo.
Lo técnico: la escala es tanh(cp/400) y un mate se convierte con mate_score=10000, así que
vale exactamente ±1 y se pega al borde del eje. Como el eje está fijado a [-1, 1] en vez
de ajustarse a los datos, el mate no deforma nada: se dibuja donde le toca. Un eje ajustado
automáticamente sí sería un problema, porque un solo mate al final comprimiría todo el resto de
la partida contra la línea del cero y una partida tranquila parecería un electrocardiograma. Es
la razón por la que la isla no ajusta su eje: las dos series están acotadas por construcción, y
ajustar una escala ya acotada solo sirve para exagerar.
El encoder que se publicó: 39 millones y una pérdida de orden
Todo lo anterior es la tirada del 19 de septiembre con el encoder de 15 millones. El que está en
chorcat/rukh-encoder, el que sirve la demo, es otro, y la diferencia se decidió con dos
mediciones que llegaron después de cerrar el módulo, mientras el decoder pasaba por la misma
revisión (M2, parte 4).
La primera es de pérdida, no de tamaño. La cabeza de valor entrenaba con F.mse_loss sobre
tanh(cp/400): cuánto se acerca cada predicción a su etiqueta. GOAL.md la puntúa con Spearman,
que solo mira el orden. Son objetivos distintos y la diferencia era visible en la tabla de la parte
anterior: el esquema de casillas tenía mejor Pearson y peor Spearman que el de jugadas, es decir,
aprendía la escala y se dejaba el ranking. pairwise_rank_loss añade un término por pares,
ponderado por la distancia entre etiquetas, detrás de HeadWeights.value_rank (a cero por defecto,
para que las corridas anteriores sigan siendo reproducibles). Mismo encoder de 15 M, mismo afinado
last-n, solo cambia la pérdida:
value_rank |
Pearson | Spearman | F1 de error | margen |
|---|---|---|---|---|
| 0 (MSE sola) | 0,6476 | 0,5205 | 0,1804 | +9,17 |
| 1,0 | 0,6621 | 0,6395 | 0,1774 | +8,87 |
| 3,0 | 0,6114 | 0,6601 | 0,1188 | +3,01 |
| 8,0 | 0,5497 | 0,6596 | 0,1663 | +7,76 |
No era un intercambio. Se esperaba ceder Pearson para ganar Spearman y sube todo con peso 1, así que la pérdida anterior estaba peor alineada con la tarea en los dos ejes. El Spearman se satura en torno a 0,66 a partir de ahí, y se elige 1,0 por el mejor Pearson y el margen holgado. (El peso 3,0, con su F1 hundido a +3,01 y el 8,0 de vuelta a +7,76, no es monótono: es ruido de tirada única, el mismo aviso de la curva por etiquetas.)
La segunda es de tamaño y de corpus. El encoder sube a 12 capas y 512 dimensiones —38 971 392
parámetros, el tamaño de small— y se preentrena sobre los 1 681 millones de tokens de
tokens-v4 en vez de los 240 del corpus de M1. Acierta el 81,4 % de las jugadas tapadas frente
al 75,2 % del de 15 M.
uv run rukh train encoder --config configs/train/encoder-mmm-v4.yamluv run rukh train heads --config configs/train/encoder-heads-v4.yaml --mode last-nuv run rukh eval encoder --model checkpoints/encoder-heads-v4/step-4000.pt --stage encoder-v4uv run rukh export --ckpt checkpoints/encoder-heads-v4/step-4000.pt --out artifacts/onnx/encoder --kind encoder --fp16 --int8 --check-parityEl preentrenamiento tardó 40 minutos en la RTX 5090; el afinado, dos y medio; la evaluación,
cinco. encoder-heads-v4.yaml lleva value_rank: 1.0 y mode: last-n, que es lo que la parte
anterior midió como mejor. Si no quieres los 40 minutos, uv run rukh pull encoder-mmm-v4 deja el
preentrenado exactamente donde la config lo lee; rukh pull encoder-v4 deja el afinado.
Las tres filas que resumen el camino —15 M con MSE, 15 M con orden, 39 M con orden— están en la parte anterior, en «La pérdida no medía lo que el criterio mide», y no se repiten aquí; lo que dicen es dónde estaba el valor: la pérdida dio +0,119 de Spearman y triplicar la capacidad +0,027. Subir a 87 millones daría un par de centésimas y no acercaría el listón. Y el listón sigue sin cumplirse —0,6665 frente a 0,80— por una razón que la misma sección mide por bandas de |cp| sobre diez mil posiciones: donde la ventaja está decidida (más de 400 centipeones, el 6 % del conjunto) el encoder ya pasa de 0,80 con 0,8797; falla en las posiciones casi igualadas, que son el 61 %, y ordenarlas exige ver táctica: este modelo no tiene búsqueda. El criterio global está dominado por el caso en el que el enfoque tiene un techo estructural, y el número que se publica es el real, con el desglose al lado.
Ver las dos evaluaciones: ValueBar
Al terminar esta sección sabrás mirar dos curvas de evaluación juntas y distinguir dónde el modelo coincide con el motor, dónde se separa por una razón entendible y dónde simplemente se equivoca.
La isla de abajo recorre una partida ply a ply. La línea continua es el valor del encoder; la de
trazos, el cp de Stockfish convertido a la misma escala acotada. Las dos miran desde el punto de
vista de las blancas: por encima del cero, ventaja blanca; por debajo, ventaja negra. El deslizador
elige una media jugada, la barra de arriba muestra su valor, y el texto de debajo dice de quién es
la ventaja con palabras, porque el signo de una evaluación no puede depender de distinguir dos
colores. Las verticales punteadas son los plies que la cabeza de error marca, y están además
enumerados en texto justo debajo del deslizador.
Cuatro cosas concretas que mirar, en este orden:
- Dónde se separan las dos líneas. Mientras van juntas, el encoder está haciendo el trabajo de un motor con una sola pasada hacia delante, que ya es un resultado. Las separaciones son lo interesante, y casi siempre ocurren en el mismo tipo de posición: cuando la ventaja es táctica y depende de una secuencia forzada. El encoder no busca; evalúa de un vistazo. Todo lo que exija calcular tres jugadas por delante es, por construcción, su punto ciego.
- Si el encoder es sistemáticamente más plano. Es lo esperable: un
tanhentrenado con error cuadrático tiende a la media, así que las ventajas grandes se quedan cortas. Si lo ves, mira Pearson y Spearman de la evaluación: es exactamente el caso en el que Spearman es alto y Pearson más bajo, y verlo en una partida concreta es la mejor manera de entender esas dos cifras. - Qué plies marca como error: ninguno. No hay una sola vertical punteada en toda la partida, y
ya sabes por qué: el script escribe la marca con el umbral de fábrica de 0,5 y las
probabilidades de esta cabeza no llegan a 0,20. La figura acaba siendo la mejor ilustración
posible de la sección de métricas, porque aquí no hay tabla que te consuele: el punto de
operación equivocado convierte a un detector con 0,696 de ROC AUC en un detector que no detecta
nada. Si quieres ver las verticales, cambia
THRESHOLDa 0,04459 —el que la mitadtuneeligió para este checkpoint de casillas en el lab 3b— y vuelve a ejecutar el script. - El sacrificio, y el escalón que sí está. La partida termina en 17…Ae6, la jugada que la
heurística de material llama error garrafal. Mira la curva de trazos en el ply 22: Stockfish
pasa de −0,015 a −0,52 y se queda ahí el resto de la partida, porque ahí es donde la partida
se decide a favor de las negras. Y ahora mira la línea continua: el encoder se mueve entre 0,00
y 0,16 de principio a fin, siempre del lado de las blancas, y no registra el escalón. Ese
dibujo es el Spearman con los ojos, y es mucho más elocuente que el número: el modelo no
está mintiendo, está aplastado contra el cero. Esa fue la explicación durante un tiempo —el
tanhcomprimiendo donde hay densidad— y resultó ser la menor de las dos: el modelo estaba aplastado sobre todo porque su pérdida premiaba acercarse a la etiqueta y no ordenarlas. Con el término de ordenación esta misma curva se despega, aunque el escalón del ply 22 sigue sin aparecer, que es la parte que necesita táctica y no pérdida.
tanh(cp/400). Línea continua, el encoder; línea de trazos, Stockfish. Las verticales punteadas son los plies que la cabeza blunder marca como error, y están enumerados en texto debajo del deslizador. encoder (cabeza de valor) Stockfish (cp convertido a la misma escala) error marcado por el encoder
1. Nf3 (g1f3, ply 1): posición igualada, +0,01 según el encoder y +0,05 según Stockfish. El encoder no marca esta jugada como error.
- Ply
- 1
- Encoder
- +0,01
- Stockfish
- +0,05
- Diferencia
- −0,04
El encoder no marca ninguna jugada de esta partida como error.
Qué has aprendido, cómo se mide
Has escrito un encoder bidireccional que es, literalmente, el modelo de M2 con un booleano cambiado,
y sabes explicar por qué ese booleano lo cambia todo: qué tarea habilita, qué tarea imposibilita y
por qué la máscara es una consecuencia del objetivo y no al revés. Lo has preentrenado con masked
move modeling sabiendo defender cada número de la receta —el 15 %, las tres ramas del 80/10/10, los
tokens de control intocables y el -100 que no es un 0—, has reducido sus salidas a un vector con
un pooling que ignora el relleno por un motivo que puedes explicar en una frase, y has montado
encima tres cabezas lineales precisamente porque son demasiado tontas para hacer trampa. Ese
preentrenamiento acertó el 75,2 % de las jugadas tapadas en dieciséis minutos, y la comparación
entre las dos entradas —la pregunta abierta con la que empezó el módulo— tiene ya una respuesta que
no es la que nadie habría apostado entera: ver el tablero es mejor para cuánto vale esto, ver la
línea es mejor para qué se acaba de tirar.
Y te llevas el resultado incómodo, que es el que más enseña: de los dos criterios de GOAL.md, el
de detección de errores se cumple con +9,2 puntos sobre la heurística y el de correlación de
valor no se cumple, con 0,520 de Spearman frente a un listón de 0,80. Los dos se publican igual.
Te llevas también dos resultados que contradicen el instinto y que son el motivo de existir del módulo. Uno: afinar menos red salió mejor que afinarla entera —0,1180 de error de valor con los dos últimos bloques frente a 0,1354 con los quince millones de parámetros—, porque descongelarlo todo con pocas etiquetas aleja las capas de abajo de la representación que el preentrenamiento había construido. Dos: la curva por etiquetas es plana desde el 25 %, así que las tres cuartas partes del conjunto no compraron nada, y la hipótesis de “faltan etiquetas” que parecía obvia queda descartada por medida y no por opinión. Y la coda que amarra las dos: la dispersión entre esos puntos es del tamaño del ruido entre dos tiradas idénticas, así que las dos conclusiones se enuncian como lo que son —una ordenación sugerente y una meseta clara— y no como lo que gustaría.
Y has aprendido a medir. Contra una línea base deliberadamente mala, sobre las mismas filas, con métricas que no se dejan engañar por una clase rara y con un reparto por partida que cierra la fuga que este módulo tenía servida en bandeja. Si de todo el módulo solo te quedas con una cosa, que sea esta: con una tasa base del 3,7 %, la exactitud del 96,78 % no dice nada y el F1 de 0,0000 en el umbral de 0,5 tampoco; el mismo modelo tiene 0,740 de ROC AUC, y el número publicable sale de elegir el umbral en unas filas y puntuar en otras. No es una particularidad del ajedrez: es lo que hay que hacer con cualquier detector de algo raro.
Cómo se mide el módulo 3. Todo lo que sigue es un número o un test:
- F1 de detección de errores, del encoder y de la heurística de material sobre las mismas filas
de validación, con su precisión y su exhaustividad al lado, en el umbral ajustado y en el fijo, y
con ROC AUC y precisión media, que no dependen de ningún umbral. El umbral se elige en una mitad
tuney el F1 se mide en la mitadscore, partidas porgame_id. Objetivo deGOAL.md: al menos cinco puntos de margen. Medido: 0,180 contra 0,089, +9,2 puntos. Se cumple. - Correlación del valor con
tanh(cp/400), Pearson y Spearman, esta última como titular. Objetivo: ≥ 0,80. Medido: 0,520 al cerrar el hito y 0,6665 tras cambiar la pérdida por una que mira el orden. No se cumple ni con lo segundo, y el desglose por dificultad dice dónde: 0,88 donde la ventaja está decidida, 0,46 en el 61 % de posiciones igualadas. - Exactitud del resultado sobre las tres clases, con la advertencia de que su techo es bajo por
construcción. Medido: 51,4 % en validación con el modelo entero descongelado, 50,0 % en la
evaluación del
last-nsobre las 10 000 posiciones retenidas. - Curva por número de etiquetas al 10, 25, 50 y 100 %, sobre subconjuntos anidados, en modo
last-n. Medida: plana desde el 25 % —error del valor 0,1217 / 0,1192 / 0,1210 / 0,1215—, así que más allá de unas cien mil etiquetas más evaluaciones de Stockfish no compran nada medible. La única diferencia que supera el ruido entre tiradas es la exactitud de errores en el 10 % (0,9559 frente a 0,9678), que es lo que pasa cuando la clase rara se queda en unas 1 600 filas. - Modo de afinado, los tres a 4 000 pasos sobre las mismas etiquetas. Medido:
last-ngana con 0,1180 de error de valor, frente a 0,1354 defully 0,1429 deprobe;fullsolo gana en la exactitud del resultado (51,4 %). Es una tirada por modo, así que la ordenación es sugerente y no establecida: para cerrarla harían falta varias semillas por modo. - Paridad ONNX de las dos salidas que usa la demo: coincidencia de la decisión de error y diferencia máxima del valor, para fp32, fp16 e int8. Medido: 100 % en las tres, con 60,7 / 30,6 / 18,3 MB y un desvío máximo del valor de 0,024 en int8. El contraste con el decoder de M2, cuyo int8 cambiaba el 4,6 % de las jugadas, es una de las cosas que conviene recordar del módulo.
- Tests unitarios que se quedan para siempre: que el modelo no es causal, que el relleno no
influye en los tokens reales, que
pool("mean")lo ignora, que un FEN va y vuelve casilla a casilla, que las proporciones 80/10/10 se cumplen sobre diez mil tokens, que los tokens de control nunca se tapan, que el modoprobedeja el encoder idéntico bit a bit, que ningúngame_idcruza el reparto y que el F1 calculado a mano sobre un caso pequeño coincide con el del código. - Reproducibilidad: el hash del vocabulario de casillas fijado en un test, la semilla del enmascarado en la configuración, el reparto como función pura del id y de la semilla, y cada tirada en MLflowMLflowRegistro de experimentos: cada entrenamiento guarda su configuración, sus métricas por paso y sus artefactos en una base SQLite local (`rukh mlflow ui` la abre en el navegador). Las model cards del curso se generan desde ahí para que ningún número se escriba a mano. con sus curvas.
Lo siguiente es M4, donde vuelves al decoder de M2 y le enseñas a jugar como un 1500 o como un 2200
cambiando dos tokens del principio, con LoRA para no tener que mover los ciento quince millones de
pesos del medium cada vez. El encoder que acabas de construir no se queda ahí parado: su barra sigue en la
demo, sus embeddings son la entrada de la recuperación de posiciones de la fase 2, y
su probe linealProbe linealCongelar un modelo y entrenar encima solo una capa lineal para una tarea nueva. Si la capa lineal acierta, la información ya estaba en la representación y el preentrenamiento la puso ahí; si no acierta, no se puede concluir que no esté, solo que no está de forma linealmente legible. En Rukh es el modo `probe` de `rukh train heads`, y un test comprueba que los pesos del encoder salen idénticos bit a bit. es la misma técnica con la que en M2 leíste que se
puede
extraer el tablero de las activaciones de un modelo de ajedrez —solo que ahora la has montado tú
sobre tu propio modelo en vez de citarla de un artículo.
La cheatsheet del módulo, once preguntas con su respuesta corta, está justo debajo.
// cheatsheet M3
Once preguntas para llevarte
- 01¿En qué se diferencia un encoder bidireccional de un decoder causal?
- En una línea: la máscara. El decoder pasa `is_causal=True` a la atención y cada posición solo ve hacia atrás, que es lo que hace posible generar de izquierda a derecha sin hacer trampa; el encoder pasa `causal=False` y cada posición ve toda la secuencia, incluidas las jugadas posteriores. Todo lo demás —embeddings, multi-cabeza, bloques pre-norm, LayerNorm final— es el mismo código: en Rukh, `PositionEncoder` y `MoveDecoder` construyen sus bloques desde el mismo `rukh.models.layers`. Lo que se gana es un contexto completo para representar; lo que se pierde es la capacidad de generar, porque la tarea de predecir el siguiente token con el siguiente token a la vista es trivial.
- 02¿Qué es el MLM y por qué 80/10/10?
- Masked language modeling: se esconde el 15 % de los tokens y el modelo los reconstruye desde los dos lados. De los tokens elegidos, el 80 % se sustituye por `<mask>`, el 10 % por otro token al azar y el 10 % se deja como está. Si todos fueran `<mask>`, el modelo solo vería ese símbolo durante el preentrenamiento y nunca durante el afinado, así que aprendería una representación que únicamente funciona cuando falta algo. El 10 % aleatorio le obliga a desconfiar de lo que lee (un token que *está* puede ser falso) y el 10 % intacto le obliga a representar todas las posiciones, no solo las marcadas. En Rukh se aplica a jugadas y los tokens de control (`<bos>`, Elo, resultado, `<eos>`) nunca se tapan: son la condición, no la señal.
- 03¿Qué es el pooling y cuándo usar CLS o media?
- Reducir los T vectores que devuelve el encoder a uno solo que represente la secuencia. `cls` toma la posición 0, un token que no aporta contenido y cuya única función es acumular el resumen que la atención le traiga; `mean` promedia los vectores de los tokens reales. La media es más robusta y es el defecto de Rukh, pero tiene una condición: hay que ignorar el relleno, porque promediar los vectores de los `<pad>` diluye la representación en una cantidad que depende de cuánto se rellenó ese lote, es decir, el vector de una misma posición cambiaría según con quién le toque viajar. `PositionEncoder.pool` lee la máscara de `idx` cuando no se le pasa una.
- 04¿Qué diferencia hay entre un probe lineal y un fine-tuning completo, y qué mide cada uno?
- El probe congela el encoder y entrena solo una capa lineal encima del vector agrupado: mide si la información ya estaba en la representación, porque una capa lineal no puede aprenderla por su cuenta. El fine-tuning completo mueve todos los pesos: mide lo mejor que puede hacer esa arquitectura con esas etiquetas, y suele ganar, pero necesita más etiquetas, se sobreajusta antes y ya no dice nada sobre el preentrenamiento. Entre los dos está `last-n`, que descongela las últimas capas y la norma final. Y el hito midió lo contrario de lo esperable: `last-n` sacó el mejor error de valor (0,1180) por delante de `full` (0,1354) y del probe (0,1429), porque descongelarlo todo con 438 093 etiquetas aleja las capas de abajo de la representación que dejó el preentrenamiento; `full` solo gana en la exactitud del resultado. Es una tirada por modo, así que la ordenación es sugerente y harían falta varias semillas para establecerla. En Rukh son los tres modos de `rukh train heads --mode probe|last-n|full`, y un test comprueba que en `probe` los pesos del encoder salen idénticos bit a bit.
- 05¿Qué mide el F1, cuándo no sirve la exactitud y dónde se elige el umbral?
- F1 es la media armónica de precisión (de lo que marqué, cuánto era de verdad) y exhaustividad (de lo que había, cuánto marqué). La exactitud deja de servir en cuanto una clase es rara, y en M3 lo es: los errores son el **3,72 %** de las filas, así que un detector que conteste siempre «no hay error» saca **96,3 %** sin mirar el tablero y la cabeza real saca 96,78 %. Peor todavía, el F1 en un umbral arbitrario tampoco mide la representación: las probabilidades de esta cabeza van de 0,0020 a 0,3102, así que en el umbral de fábrica de 0,5 no se dispara nunca y su F1 es **exactamente 0,0000**. El mismo modelo tiene **0,740 de ROC AUC**. El procedimiento honesto es partir las filas etiquetadas en dos por `game_id`, elegir el umbral en la mitad `tune` (aquí 0,0663) y publicar el F1 medido en la mitad `score` (aquí 0,180), que no ha visto ningún umbral. Elegir el umbral en las filas que luego se puntúan convierte la estimación en un récord personal.
- 06¿Qué es una fuga de datos y por qué aquí el split va por partida?
- Cualquier camino por el que información de validación llega al entrenamiento. Nunca falla con un error: los números salen mejores y el problema se descubre en producción. En M3 el peligro concreto es partir por posición: dos posiciones consecutivas de la misma partida se diferencian en una jugada, así que la del ply 30 en entrenamiento y la del 31 en validación es casi la misma posición y un modelo que memoriza puntúa como uno que entiende. Rukh parte por `game_id` con una función pura de CRC-32 sobre el id y la semilla, y un test comprueba que ningún `game_id` está en los dos lados.
- 07¿Por qué se correlaciona el valor con el `cp` de Stockfish y por qué dos correlaciones?
- Porque `cp` es la única referencia objetiva de «cuánto vale esta posición» que hay a escala, y porque una regresión necesita una medida de calidad que no dependa de un umbral. Se correlaciona contra `tanh(cp / 400)`, la escala acotada con la que se entrena la cabeza, y nunca contra el `cp` en bruto: un mate vale ±9 999 centipeones y media docena de filas así decidirían el Pearson del conjunto entero. Se publican las dos correlaciones: Pearson mide si los valores se alinean en una recta, Spearman solo si el orden coincide. Discrepan exactamente cuando el modelo tiene bien el orden y mal la escala, que es lo que le hace un `tanh` acotado, así que dar solo una oculta la mitad del diagnóstico. `GOAL.md` pide ≥ 0,80 sobre Spearman y **el checkpoint publicado (`last-n`, jugadas) se queda en 0,520, la entrada por casillas en 0,422 y la pérdida con término de orden sube a 0,6665: criterio no cumplido**, publicado sin cumplir y con el diagnóstico al lado.
- 08¿Qué es una línea base y por qué la de M3 es deliberadamente tonta?
- El rival contra el que se mide el modelo para saber si su número significa algo. La de M3 cuenta material (peón 1, caballo y alfil 3, torre 5, dama 9), le suma movilidad y llama error a toda jugada que pierda un punto neto tras la captura más rentable del rival a un ply. Es tonta a propósito: una línea base que ya entendiera los sacrificios no sería un suelo, sería un competidor, y el margen dejaría de decir «el modelo aprendió algo más que contar piezas». Lo esencial es medirla exactamente igual que al modelo: mismas filas, mismas etiquetas, misma definición de acierto. Medido: la heurística saca 8,9 % de F1 y el encoder publicado 18,0 %, **+9,2 puntos**, por encima de los cinco que pide `GOAL.md`. Y la comparación no es simétrica en dos sentidos opuestos que el informe dice en voz alta: la heurística ve la posición anterior y la jugada, que el encoder no ve; y la heurística es una regla sin umbral que ajustar, mientras que el modelo se mide en su mejor punto de operación.
- 09¿Qué aporta la curva por número de etiquetas?
- Dice cuántas etiquetas hacen falta de verdad. Se entrena la misma receta con el 10, 25, 50 y 100 % de las etiquetas de entrenamiento y se dibuja la métrica contra el número de filas. Si la curva se aplana pronto, etiquetar más es tirar dinero y el cuello de botella está en el modelo o en la tarea; si sigue subiendo al 100 %, etiquetar más es la inversión más rentable que queda. Los subconjuntos son **anidados** (el 10 % está dentro del 25 %, y este dentro del 50 %) porque con muestras independientes un bache a mitad de la curva podría ser mala suerte del sorteo y estaríamos midiendo ruido de muestreo en vez del valor de los datos. Medido en M3: plana desde el 25 % (0,1217 / 0,1192 / 0,1210 / 0,1215 de error de valor al 10, 25, 50 y 100 %), así que más allá de unas cien mil etiquetas más evaluaciones de Stockfish no compran nada. Y la dispersión entre esos cuatro puntos es la misma que entre dos tiradas idénticas, que es la segunda lección: antes de explicar una diferencia pequeña, mide cuánto se mueve tu montaje cuando no cambias nada.
- 10¿En qué se diferencia este modelo del de M2?
- En la máscara, en la tarea y en para qué sirve. El de M2 es causal, se entrena a predecir la siguiente jugada y genera: es lo que juega en la demo. El de M3 es bidireccional, se entrena tapando jugadas en medio de la partida y no genera nada: produce un vector por posición del que salen el valor, la detección de errores y el resultado. También es más pequeño (8 capas, d=384, 15 052 800 parámetros frente a los 12 capas, d=512 y 38 971 392 del `small` de M2; el `medium` que afina M4 tiene 115 M) y usa `ignore_index=-100` en vez del `0` del decoder, porque en la entrada por casillas el `0` es un token que sí se puede pedir que prediga. En la demo conviven: el decoder elige jugada, el encoder pinta la barra, y cada uno en su propio worker.
- 11¿Qué es un punto de operación y qué métricas no dependen de él?
- El umbral a partir del cual una probabilidad se convierte en una decisión («p ≥ 0,0663 es error»). Precisión, exhaustividad y F1 dependen de él: moverlo cambia las tres sin tocar un peso, y multiplicar todas las probabilidades por tres convierte un F1 de 0,0000 en 0,5 en otro número sin que el modelo sepa nada nuevo. ROC AUC (probabilidad de que un error al azar puntúe más que una jugada tranquila al azar; 0,5 es una moneda) y precisión media (área bajo precisión-exhaustividad; su referencia es la tasa base, 0,037) solo miran el orden y no se mueven con ningún umbral. En M3 el checkpoint publicado tiene 0,740 y 0,112; el de casillas, 0,696 y 0,091, y cada uno tiene su propio umbral ajustado (0,0663 y 0,04459), que no se pueden intercambiar.