rukh · lab

// M3 · lección 11

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.

  • tiempo de trabajo95 min
  • además, ejecución sin supervisión+ 90 min de GPU y red
  • nivel base
  • actualizado el23 de septiembre de 2026

La lección que cierra el módulo: los dos scripts de labs/m3/ enteros, los comandos y las salidas reales de la tirada de referencia. El código del motor está en las lecciones 2 a 9; aquí se ejecuta.

Labs

Al terminar tendrás el encoder preentrenado, las 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. Los comandos se ejecutan desde la raíz del repo rukh; donde pone <fecha> va la de tu tirada (ls -td checkpoints/encoder-mmm-*/ | head -1 te da la última). Debajo de cada comando va la salida real de la tirada de referencia.

// Antes de empezarQué cuesta cada lab, y cuál puedes saltarte

LabRutaRelojDejaAtajo
Lab 1El encoder bidireccional y el enmascaradocuesta máquinasegundos el script, 15 min el preentrenamientocheckpoints/encoder-mmm/best.pt, 75,2 % en jugadas tapadasrukh pull encoder-mmm-v4 trae la versión grande
Lab 2Las tres cabezas con el tronco congeladoimprescindible1-3 minel probe, y la prueba de que no mueve el troncono hay
Lab 3Las tres etapas y la curva por etiquetascuesta máquina1-3 min cada etapa, ~6 la curvala curva 10/25/50/100 % dentro del checkpointno hay
Lab 3bLa otra entrada: las casillas desde cerocuesta máquina~10 minel contraste que justifica la entrada elegidano hay
Lab 4Medir contra la heurísticacuesta máquina~5 minF1 frente a la línea base, y las correlacionesno hay
Lab 5La versión v4, exportar, embeddings y la islacuesta máquina40 + 3 + 5 + 5 minel encoder que sirve la demo, y value-bar.jsonrukh pull encoder-mmm-v4 y rukh pull encoder-v4
cuesta máquina
Tiempo real de GPU, red o motor. El reloj es el de la RTX 5090 de referencia.
imprescindible
Segundos o pocos minutos, y el lab siguiente da por hecho que lo corriste.

De los dos scripts de labs/m3/, bidirectional.py construye su propio encoder de juguete y no necesita nada; value_bar_export.py necesita un checkpoint de las cabezas y Stockfish, y escribe artifacts/web/value-bar.json, que llega a este sitio con pnpm sync:data.

Lab 1 · El encoder bidireccional y el enmascarado

Primero, demuestra que el modelo es lo que dices que es. En M2 exigías que cambiar un token futuro no moviera los logits pasados; aquí se exige lo contrario. El script es labs/m3/bidirectional.py:

labs/m3/bidirectional.py
"""Show that the encoder is not causal, and that the masking recipe does what it claims."""
import sys
import torch
from rukh.models import EncoderConfig, PositionEncoder
from rukh.models.encoder import MMM_IGNORE_INDEX
from rukh.train.mmm import MaskingConfig, apply_masking, control_ids, mask_id, masking_generator
# The Windows console is cp1252 by default and DuckDB draws its tables with box characters, so a
# plain `print` of a result set dies with UnicodeEncodeError. Ask for UTF-8 before printing
# anything; on a terminal that already speaks UTF-8 this is a no-op.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
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_INDEX
total = 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 %)")
# One line in the lesson; split here only so it fits the project's 100-column ruff rule.
shares = f" <mask> {masked / total:.1%} kept {kept / total:.1%}"
print(f"{shares} random {1 - (masked + kept) / total:.1%}")

labs/m3/bidirectional.pylíneas 1-52 · p3

Terminal
uv run python labs/m3/bidirectional.py

Salida real de una ejecución de comprobación (RTX 5090, 2026-09-21, dos minutos):

preset parameters: 15,052,800
max |delta| on the FIRST token after changing the LAST one: 0.239878
bidirectional: the future reaches the past
max |delta| between padded and tight: 0.000001
selected 14.910% of the tokens (target 15 %)
<mask> 80.6% kept 9.9% random 9.5%

Cambiar el último token mueve el estado oculto del primero en 0,24, un número que en el decoder de M2 sería 0,000000. El relleno no contamina (1e-6 es ruido numérico). Y el sorteo, contado en vez de creído, selecciona el 14,91 % de los tokens con un reparto 80,6 / 9,9 / 9,5 (el kept es una cota superior del 10 % real, lección 5).

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:

Terminal
uv run rukh train encoder --config configs/train/encoder-mmm.yaml

Salida real de la ejecución de referencia (RTX 5090, 2026-09-19), las últimas seis evaluaciones de una tirada de dieciséis minutos:

step 9500 val/loss 1.0621 val/top1 0.7396
step 10000 val/loss 1.0456 val/top1 0.7430
step 10500 val/loss 1.0291 val/top1 0.7467
step 11000 val/loss 1.0195 val/top1 0.7483
step 11500 val/loss 1.0106 val/top1 0.7507
step 12000 val/loss 1.0032 val/top1 0.7518
input: moves
masking: 15% at 80%/10%/10%
steps: 12000
checkpoint: checkpoints/encoder-mmm-20260919-093554/step-12000.pt

75,2 % de acierto en las jugadas tapadas, que no se compara con el 51,1 % de top-1 del decoder de M2 aunque las dos se llamen «acierto». El decoder ve la partida hasta el plyPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply. n y elige el n+1 entre treinta jugadas legales, muchas razonables. El encoder rellena un hueco con la partida entera alrededor, y lo que vino después restringe mucho lo que pudo pasar: si en el ply n+2 hay una torre en d1, la jugada tapada era seguramente la que la puso ahí. Es la diferencia entre escribir la siguiente palabra de una frase y rellenar un hueco en una frase ya escrita. La pérdida sigue bajando en el paso 12 000, así que dieciséis minutos es el presupuesto del módulo, no un techo.

// 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

Con el generador del entrenamiento, que ha avanzado, cada evaluación taparía posiciones distintas: el paso 500 podría haber tapado recapturas forzadas y el 1 000 jugadas difíciles, y la curva mezclaría la mejora del modelo con la dificultad del sorteo. Con la semilla reiniciada, todas las evaluaciones tapan las mismas posiciones y la curva mide una sola cosa.

Y como la semilla está en la configuración, otra tirada con la misma masking.seed usa los mismos huecos, así que dos preentrenamientos se comparan sobre la misma tarea. La tarea de evaluación es parte del instrumento de medida, y un instrumento que cambia entre medidas no mide.

La tabla supervisada, antes de afinar nada

Las 438 093 posiciones etiquetadas de los labs que vienen salen de positions-eval.parquet con las reglas de src/rukh/data/labels.py (lección 6): valor tanh(score / 400) desde las blancas, error medido desde el lado del que movió (null, nunca 0, si falta la posición anterior) y reparto por game_id. La tabla se construye en memoria al arrancar cada afinado; no hay orden aparte.

Lab 2 · Las tres cabezas con el tronco congelado

Ahora el probe: cuatro mil pasos, lotes de 128 prefijos, tasa de aprendizaje 1e-3 (mucho más alta que en el preentrenamiento, porque solo se mueven tres capas lineales) y el encoder congelado y en modo eval (lección 6).

Terminal
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode probe

Salida real de la ejecución de referencia (RTX 5090, 2026-09-19), 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.4586

Por cabeza salen la pérdida y una métrica legible; val/value_mae es el error absoluto medio en la escala acotada (0,1 son unos 40 centipeones en la zona central).

Antes de ejecutarlo, escribe qué esperas: la mitad del valor de un experimento está en haberse comprometido antes. Lo razonable es que el resultado sea la tarea más difícil, porque su etiqueta es de la partida y no de la posición, y el valor la más fácil. Se cumplen las dos: 45,9 % en el resultado, trece puntos sobre el azar de tres clases, y 0,1429 de error del valor, unos 57 centipeones.

Y el 96,78 % de val/blunder_acc es la cifra más engañosa del módulo: un detector que conteste siempre «no hay error», sin mirar el tablero, saca 96,3 %. La lección 8 la desmonta.

// 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

torch.equal sobre cada tensor del state_dict sirve, y es mejor que allclose: aquí se exige igualdad bit a bit. Si pasara con allclose y no con equal, algo movería los pesos un poquito, y un poquito en tres pasos es mucho en cuatro mil.

Lo otro que cambia el vector sin tocar un peso es el dropout: EncoderConfig trae dropout = 0.1, y un módulo en modo train lo aplica aunque esté congelado, así que la misma posición daría un vector distinto en cada pasada, ruido que las cabezas no pueden compensar. Por eso set_training_mode devuelve el encoder a eval en modo probe. En una red con BatchNorm pasa algo parecido con las estadísticas móviles: congelar es más que requires_grad = False.

Lab 3 · Las tres etapas y la curva por etiquetas

Repite el afinado descongelando más: primero las dos últimas capas y la normalización final, después el modelo entero.

Terminal
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode last-n

Salida 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.4775
Terminal
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode full

Salida 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.5142

Compara 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

La fila del valor dice lo contrario de lo que casi todo el mundo espera: afinar menos red salió mejor que afinarla entera. El error baja de 0,1429 con el tronco congelado a 0,1180 descongelando los dos últimos bloques, y vuelve a subir a 0,1354 con el modelo entero.

Te la vas a encontrar fuera del ajedrez. El afinado completo mueve también las capas de abajo, las que el preentrenamiento dejó representando lo general de una partida, y moverlas con la señal ruidosa de tres cabezas y 4 000 pasos las aleja de esa representación sin construir otra mejor: es el olvido catastróficoOlvido catastróficoCuando afinar un modelo con datos nuevos le hace perder lo que sabía. No avisa: la pérdida sobre los datos nuevos baja mientras la capacidad vieja se deshace. La defensa no es una técnica sino una medición: se afina con tasa baja, pocos pasos, y se vuelve a medir contra el listón anterior. En M4 el listón es el Elo del modelo base; si el afinado por Elo lo hundiera, el eje habría salido caro. en versión leve. last-n solo mueve los últimos bloques, donde vive la adaptación a la tarea. Por lo mismo, con pocas etiquetas, afinar un BERT entero suele salir peor que afinar sus capas altas o ponerle un adaptador.

Eso sí, es una tirada por modo, y las diferencias de val/loss (0,6310 / 0,6028 / 0,6384) están en el rango del ruido entre tiradas que vas a medir enseguida. La ordenación es sugerente; para establecerla harían falta tres a cinco semillas por modo.

El resultado de la partida ordena distinto: gana full, 51,4 % frente a 47,8 %, en la única tarea cuya etiqueta es de la partida entera y necesita que el tronco busque información que el preentrenamiento no dejó legible. Mientras, la pérdida de errores sube de 0,1270 a 0,1310: una suma de pérdidas puede mejorar una tarea a costa de otra, y por eso el bucle registra las tres aparte.

Ahora la curva. --curve ejecuta la misma receta con el 10, 25, 50 y 100 % de las etiquetas, sobre subconjuntos anidados:

Terminal
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode last-n --curve

Va en last-n, el modo que mejor salió, para que la curva mida el valor de las etiquetas. Salida real (RTX 5090, 2026-09-19), cuatro entrenamientos de 4 000 pasos encadenados:

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.6133
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
error de valor (val/value_mae) · eje recortado0,1180,1200,1220,121710 %0,119225 %0,121050 %0,1215100 %0,1180 · otra tirada, mismas etiquetasporcentaje de etiquetas de entrenamientofranja: el rango de toda la curva, 0,0025punto hueco: una tirada idéntica al 100 %, a 0,0035 del punto de la curva
fig. 01La curva por etiquetas del lab 3, error de valor contra porcentaje de etiquetas, con el eje vertical recortado para que se vean las diferencias. Los cuatro puntos unidos son la curva; el punto suelto en el 100 % es otra tirada de la misma receta con las mismas etiquetas. La distancia entre las dos tiradas idénticas es mayor que el ancho de toda la curva.

La curva está plana desde el 25 %. Cuadruplicar las etiquetas, de 109 523 a 438 093, lleva el error del valor de 0,1192 a 0,1215, algo peor, y deja igual la exactitud de errores. La pregunta con la que se abrió el módulo, cuántas etiquetas hacen falta de verdad, tiene respuesta medida: con la cuarta parte habría bastado.

Hay que leerla con la misma honestidad. Entre el mejor y el peor punto hay 0,0025. La tirada last-n de antes, con la misma receta y el mismo 100 % de etiquetas, sacó 0,1180 frente a 0,1215: dos tiradas idénticas se separan 0,0035, más que el ancho entero de la curva. Eso refuerza la lectura «plana», porque ordenar esos cuatro puntos sería leer ruido, y deja la costumbre que más 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 vale igual para comparar dos prompts, dos modelos de embeddings o dos configuraciones de un RAG.

La única diferencia que supera el ruido es la exactitud de errores en el 10 % (0,9559 frente a 0,9678). Con los errores al 3,72 %, ese 10 % deja unas 1 600 posiciones positivas: 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 ya está resuelta, o limitan el modelo o la representación. Toca cambiar el modo de afinado, la representación de entrada, o revisar si la etiqueta es tan ruidosa que más cantidad no informa. 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í. Congelas el tamaño del conjunto y gastas en otra cosa: modos de afinado, semillas para tener barras de error, otra representación.

(c) Subiendo en el 100 %: etiquetar más es la inversión más rentable, y cualquier comparación de arquitecturas con estas etiquetas está limitada por los datos, así que es provisional.

Que baje entre el 50 % y el 100 % no significa que más datos hagan daño: con subconjuntos anidados, el grande contiene al pequeño. Lo probable es que los pasos estén fijos (max_steps: 4000) y con cuatro veces más filas el modelo pase menos veces por cada una, así que comparas dos regímenes de entrenamiento; o simplemente ruido de una sola semilla.

Lab 3b · La otra entrada: las casillas desde cero

Queda la fila squares de la tabla de la lección 1, y el lab 5 la necesita: la isla de esta página y los embeddings salen de este checkpoint, porque los dos scripts tokenizan un FEN. En encoder-heads.yaml, encoder_ckpt: null: sin preentrenamiento sobre casillas, el encoder arranca de pesos aleatorios, y congelarlos sería congelar ruido, así que el único modo con sentido es full:

Terminal
uv run rukh train heads --config configs/train/encoder-heads.yaml --mode full

Lotes de 256 posiciones (el doble que en jugadas: un tablero son 69 tokens y un prefijo hasta 200), mismas filas y mismo reparto. Salida real de una ejecución de comprobación (RTX 5090, 2026-09-21, dos minutos):

step 3500 val/loss 0.6204
step 3750 val/loss 0.6184
step 4000 val/loss 0.6192
mode: 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.pt

El directorio no lleva -moves (en la tirada de referencia, checkpoints/encoder-heads-20260919-100532/). Evalúalo con la suite del lab 4 y otra etapa, para que el informe no pise al de jugadas:

Terminal
uv run rukh eval encoder --model checkpoints/encoder-heads-<fecha>/best.pt --stage encoder-squares

Titular real de artifacts/eval/encoder-squares/report.md (RTX 5090, 2026-09-19), sobre las mismas 10 000 posiciones held-out:

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 points
Margin >= 5 points yes
Blunder ROC AUC 0.696
Blunder average precision 0.091
Blunder base rate 3.7 %
Value vs tanh(cp / 400), Spearman 0.422
Value vs tanh(cp / 400), Pearson 0.688
Value correlation >= 0.80 no
Result 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 F1
encoder 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)

Apunta dos cifras para el lab 5: el umbral elegido para este checkpoint es 0,04459 (el last-n de jugadas sale en 0,0663; cada uno tiene el suyo), y su probabilidad más alta es 0,1930, así que en 0,5 no se dispara nunca. Aun sin preentrenamiento ni la jugada anterior, bate a la heurística por 5,7 puntos: las casillas dicen bastante de lo que se acaba de tirar, aunque menos que la línea.

Lab 4 · Medir contra la heurística

El nombre de la carpeta distingue el esquema, no el modo (que va dentro, en cfg.mode): los tres afinados del lab 3 son tres carpetas encoder-heads-moves-<fecha>. La última sale así:

Terminal
ls -td checkpoints/encoder-heads-moves-*/ | head -1

Evalúa el checkpoint que se va a publicar, el last-n del lab 3, y no uno parecido: las métricas de un checkpoint no valen para otro de la misma receta.

Terminal
uv run rukh eval encoder --model checkpoints/encoder-heads-moves-<fecha>/best.pt --stage encoder

Titular real de artifacts/eval/encoder/report.md en la ejecución de referencia (RTX 5090, 2026-09-19), sobre 10 000 posiciones del conjunto held-out:

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 points
Margin >= 5 points yes
Blunder ROC AUC 0.740
Blunder average precision 0.112
Blunder base rate 3.7 %
Value vs tanh(cp / 400), Spearman 0.520
Value vs tanh(cp / 400), Pearson 0.648
Value correlation >= 0.80 no
Result 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 F1
encoder 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 del hito salen en direcciones opuestas y los dos se publican tal cual: la detección de errores se cumple, con +9,2 puntos sobre la heurística; la correlación del valor no, con 0,520 de Spearman frente a un listón de 0,80. La lección 8 explica cada número.

La orden escribe además una fila en artifacts/web/results.json, que llega a la tabla de esta web con pnpm sync:data. --stage fija el nombre; sin él sería el del directorio, con su fecha, y cada ejecución añadiría una fila. Los veredictos de la heurística, la parte lenta, van a una caché que no depende de los pesos (--no-cache los recalcula).

// Ejercicio 04Si el encoder no bate a la heurística por cinco puntos, ¿qué haces?

El plan del hito 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, gratis: mirar la descomposición en vez del titular. Si el encoder marca poquísimo, el umbral de 0,5 puede estar mal para una clase rara, y moverlo puede valer varios puntos sin tocar un peso. No es trampa si el umbral se elige en validación y se publica.

Lo segundo: ¿cuántas filas tenían etiqueta de error? Si son pocas, el intervalo de confianza del F1 es ancho y un punto no significa nada.

Lo tercero, ya con coste: la curva por etiquetas (si sigue subiendo, etiquetar más), después --mode full si venías de probe, y después reabrir la representación de entrada.

Lo que no vale: mover el listón, cambiar el conjunto de evaluación, redefinir «error» a 150 centipeones porque así sale mejor, o medir encoder y heurística sobre filas distintas. Si el margen sigue en un punto, se publica tal cual y se escribe por qué: un resultado negativo documentado vale más que uno positivo cocinado.

Lab 5 · Exportar, embeddings y los datos de la isla

El navegador necesita el encoder en ONNX con dos salidas, el valor y la alerta de error:

Terminal
uv run rukh export --ckpt checkpoints/encoder-heads-moves-<fecha>/best.pt --out artifacts/onnx/encoder --kind encoder --fp16 --int8 --check-parity

Salida real de una ejecución de comprobación (RTX 5090, 2026-09-21, dos minutos):

exporter: dynamo (opset 18)
kind: encoder -> value, blunder
shapes: batch dynamic=True, sequence dynamic=True (verified), block=200
fp32: 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 y 18,3 en int8, y el 100 % de las decisiones de error coinciden con PyTorch en las tres precisiones. El valor se mueve como mucho 0,024 en int8, unos diez centipeones, invisible en la barra. (Es el checkpoint de jugadas, de ahí el sequence dynamic=True (verified); el de casillas fija ese eje en 69.)

En M2, el int8 del decoder cambiaba el 4,6 % de sus jugadas. Misma técnica, resultados opuestos, y no por suerte. El decoder termina en un argmax sobre 2 030 logits, con jugadas razonables a menudo a milésimas, y un redondeo cambia cuál gana. El encoder termina en un umbral sobre un escalar, y 0,024 solo lo cruza si la probabilidad ya estaba pegada a él. La sensibilidad a la cuantización es una propiedad de la cabeza, no del cuerpo: antes de cuantizar un modelo, pregúntate 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; en el semáforo solo importa si estabas justo en el ámbar. Por eso un clasificador binario aguanta la cuantización mucho mejor que un LLM eligiendo el siguiente token.

Los embeddings de posición no los usa la demo, sino la fase 2 del curso, cuando montes recuperación de posiciones parecidas:

Terminal
uv run rukh encoder embed --positions data/evals/positions-eval.parquet --out artifacts/embeddings/positions.npy --ckpt checkpoints/encoder-heads-<fecha>/best.pt

Salida 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.npy
positions: 488159
dim: 384 (mean pooling, squares scheme)
array: artifacts/embeddings/positions.npy
sidecar: artifacts/embeddings/positions.parquet (row, fen4)

Esos 488 159 vectores de 384 dimensiones son el índice de la fase 2: preguntar por «posiciones como esta» será comparar un vector de esta matriz con todos los demás, igual que un RAG compara el embedding de una pregunta con los de sus fragmentos. Van con su .parquet, porque el índice viaja con los datos (lección 9).

Queda el script que alimenta la isla de esta página, labs/m3/value_bar_export.py: evalúa cada posición de una partida con las cabezas del encoder y con Stockfish, y escribe artifacts/web/value-bar.json.

labs/m3/value_bar_export.py
"""Export one game's encoder and Stockfish evaluations to artifacts/web/value-bar.json."""
import json
import math
from datetime import UTC, datetime
from pathlib import Path
import chess
import chess.engine
import torch
from rukh.engine import find_stockfish
from rukh.models.squares import fen_to_tokens
from rukh.train import load_any
CKPT = Path("checkpoints/encoder-heads-full/best.pt")
OUT = Path("artifacts/web/value-bar.json")
VALUE_SCALE = 400.0 # the tanh(cp / 400) of rukh.data.labels: both curves must share it
THRESHOLD = 0.5 # the blunder threshold of configs/eval/encoder.yaml
DEPTH = 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})")

labs/m3/value_bar_export.pylíneas 1-90 · p3

Terminal
uv run python labs/m3/value_bar_export.py

Salida 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)

Antes de ejecutarlo, cambia su CKPT: checkpoints/encoder-heads-full/best.pt no existe (el modo no entra en el nombre de la carpeta) y el script moriría con un FileNotFoundError. Pon tu checkpoint de casillas del lab 3b, porque el script tokeniza un FEN; es el que generó la figura de más abajo, que llega a esta web con pnpm sync:data.

Mira el fichero antes que el gráfico: ningún ply sale marcado como error. El THRESHOLD = 0.5 es el de fábrica y las probabilidades de esta cabeza no pasan de 0,1930. Se publica así en vez de bajar el umbral a mano hasta que salgan marcas. Cambiarlo a 0,04459, el umbral elegido para este checkpoint, es el lab que te llevas a casa (el 0,0663 del de jugadas no vale: cada cabeza tiene su escala).

// 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 bajarían hasta pegarse al suelo porque la partida se decide, y la figura quedaría dominada por un tramo final en el que las dos fuentes están de acuerdo. El punto interesante, el ply donde la heurística llama error a una jugada genial, quedaría aplastado en un rincón. Cortar en el sacrificio centra la figura en la pregunta del módulo.

Lo técnico: con tanh(cp/400) y mate_score=10000, un mate vale ±1 y se pega al borde. Como el eje está fijado a [-1, 1], no deforma nada. Un eje ajustado a los datos sí sufriría: un mate al final comprimiría el resto contra el cero. Las dos series están acotadas por construcción, y reajustar 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 el encoder de 15 millones. El que sirve la demo desde chorcat/rukh-encoder sale de dos cambios medidos después de cerrar el módulo.

El primero es de pérdida: el pairwise_rank_loss de la lección 8, un término por pares para la cabeza de valor detrás de HeadWeights.value_rank (a cero por defecto, para que las ejecuciones anteriores sigan siendo reproducibles). Mismo encoder de 15 M, mismo last-n, distintos pesos:

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

Con peso 1 sube todo, Pearson incluido, y el Spearman se satura en torno a 0,66; se elige 1,0 por el mejor Pearson y un margen holgado. (El F1 hundido del 3,0 y el +7,76 del 8,0 no son tendencia: es ruido de tirada única, como en la curva por etiquetas.)

El segundo es de tamaño y corpus: 12 capas y 512 dimensiones (38 971 392 parámetros, el tamaño de small), preentrenado sobre los 1 681 millones de tokens de tokens-v4 en vez de los 240 de M1. Acierta el 81,4 % de las jugadas tapadas frente al 75,2 %.

El código de los dos cambios es de la etiqueta p4: en un repositorio en p3 las configuraciones no existen. Necesitan además los tokens de data/tokens-v4/uci/ de «Más datos, no más red»:

Terminal
uv run rukh train encoder --config configs/train/encoder-mmm-v4.yaml
uv run rukh train heads --config configs/train/encoder-heads-v4.yaml --mode last-n
uv run rukh eval encoder --model checkpoints/encoder-heads-v4-<fecha>/step-4000.pt --stage encoder-v4
uv run rukh export --ckpt checkpoints/encoder-heads-v4-<fecha>/step-4000.pt --out artifacts/onnx/encoder --kind encoder --fp16 --int8 --check-parity

El preentrenamiento tardó 40 minutos en la RTX 5090; el afinado, dos y medio; la evaluación, cinco. Antes de la segunda orden, cambia el encoder_ckpt de encoder-heads-v4.yaml, que apunta a la carpeta con fecha de referencia. Si no quieres los 40 minutos, el rukh pull del principio (RUKH_HOME=<ruta-a-tu-repo> uv run rukh pull encoder-mmm-v4 encoder-v4) deja checkpoints/encoder-mmm-v4/best.pt, el preentrenado que va en encoder_ckpt, y checkpoints/encoder-heads-v4/step-4000.pt, el afinado que sustituye a la carpeta con fecha en las dos últimas órdenes.

Como resume la lección 8, la pérdida dio +0,119 de Spearman y triplicar la capacidad, +0,027. El listón sigue sin cumplirse (0,6665 frente a 0,80) porque el modelo falla donde hace falta táctica: pasa de 0,80 en las posiciones decididas y se queda en 0,46 en las igualadas, que son el 61 %. Sin búsqueda, ese techo no lo mueve el tamaño.

Ver las dos evaluaciones: ValueBar

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 en la misma escala acotada, las dos desde las blancas (por encima del cero, ventaja blanca). El deslizador elige una media jugada, y el texto de debajo dice de quién es la ventaja con palabras, para no depender de distinguir colores. Las verticales punteadas, enumeradas también en texto, son los plies que la cabeza de error marca.

Cuatro cosas que mirar, en este orden:

  1. Dónde se separan las dos líneas. Mientras van juntas, el encoder hace el trabajo de un motor con una sola pasada, que ya es un resultado. Se separan cuando la ventaja depende de una secuencia forzada: el encoder evalúa de un vistazo, y calcular tres jugadas por delante es su punto ciego por construcción.
  2. Si el encoder es más plano. Es lo esperable: un tanh entrenado con error cuadrático tiende a la media y se queda corto en las ventajas grandes. Es el caso de Spearman alto y Pearson más bajo, y verlo en una partida es la mejor manera de entender esas dos cifras.
  3. Qué plies marca como error: ninguno. Con el umbral de fábrica de 0,5 y probabilidades que no llegan a 0,20, un detector con 0,696 de ROC AUC no detecta nada. Con THRESHOLD en 0,04459 aparecen las verticales.
  4. El sacrificio y el escalón. La partida termina en 17…Ae6, la jugada que la heurística de material llama error garrafal. En el ply 22, Stockfish pasa de −0,015 a −0,52 y se queda ahí, porque ahí se decide la partida. La línea continua se mueve entre 0,00 y 0,16 de principio a fin, siempre del lado de las blancas, y no registra el escalón: es el Spearman bajo visto con los ojos, un modelo aplastado contra el cero porque su pérdida premiaba acercarse a cada etiqueta y no ordenarlas. Con el término de ordenación la curva se despega, pero el escalón sigue sin aparecer: eso necesita táctica, no otra pérdida.

← negrasigualadablancas →

Valor de la posición según el encoder y según Stockfish, ply a ply34 medias jugadas en la escala acotada tanh(cp/400), de −1 (ganan las negras) a +1 (ganan las blancas). Los valores exactos del ply seleccionado están en la lista que sigue al gráfico, y los plies marcados como error están enumerados debajo.+10−1ply 1ply 34
Eje vertical: valor de la posición desde el punto de vista de las blancas, en la escala acotada 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.

Byrne, D. – Fischer, R., New York, 1956 · 0-1 · rukh-encoder · checkpoint checkpoints/encoder-heads-20260919-100532/best.pt · generado 2026-09-19T10:10:35+00:00

Que dos líneas vayan juntas tampoco demostraría que el modelo entiende la posición como el motor: casi todas las posiciones se explican con material y estructura. Por eso el titular del módulo es el F1 contra el baseline sobre diez mil posiciones no vistas: la figura sirve para entender, la tabla para concluir.

// demoLa barra de evaluación en la demoLa barra es opcional y solo se descarga si aceptas el encoder (~30 MB además del decoder). Sin consentimiento la pantalla queda como estaba.
Abrir la demo?stage=small-int8&encoder=encoder-int8

Qué has aprendido, cómo se mide

Al empezar el módulo, el aprendiz jugaba de oído. Ahora también sabe mirar una posición y juzgarla: dice cuánto vale, avisa cuando la última jugada fue un error y guarda cada posición como un vector que se puede comparar con otras. Todavía juzga sin calcular —no ve la táctica de tres jugadas— y eso marca su techo, pero es la segunda mitad de lo que hace cualquier modelo de lenguaje: además de generar, comprender, clasificar y representar.

Por el camino has escrito un encoder que es el modelo de M2 con un booleano cambiado, y sabes por qué ese booleano lo cambia todo. Lo has preentrenado con masked move modeling y le has puesto encima tres cabezas lineales, demasiado tontas para hacer trampa. Ver el tablero resultó mejor para cuánto vale esto, y ver la línea, para qué se acaba de tirar.

Te llevas dos resultados que contradicen el instinto: afinar menos red salió mejor que afinarla entera, y la curva por etiquetas es plana desde el 25 %, así que «faltan etiquetas» quedó descartado por medida. Y has aprendido a medir: contra un baseline malo a propósito, sobre las mismas filas, con métricas que una clase rara no engaña, un umbral elegido en unas filas y medido en otras, y un reparto por partida que cierra la fuga. Lo harás igual con cualquier clasificador, detector o sistema de recuperación.

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 sobre las mismas filas, con precisión, exhaustividad, ROC AUC y precisión media; umbral elegido en la mitad tune y F1 medido en la score, partidas por game_id. Objetivo: cinco puntos de margen. Medido: 0,180 contra 0,089, +9,2 puntos. Se cumple.
  • Correlación del valor con tanh(cp/400), Spearman como titular. Objetivo: ≥ 0,80. Medido: 0,520 al cerrar el hito y 0,6665 con la pérdida de ordenación. No se cumple: 0,88 donde la ventaja está decidida, 0,46 en el 61 % de posiciones igualadas.
  • Exactitud del resultado sobre tres clases, con un techo bajo por construcción: 51,4 % en validación con full, 50,0 % del last-n sobre las 10 000 posiciones held-out.
  • Curva por número de etiquetas en last-n: plana desde el 25 % (0,1217 / 0,1192 / 0,1210 / 0,1215); solo la exactitud de errores en el 10 % supera el ruido.
  • Modo de afinado, a 4 000 pasos: last-n gana con 0,1180 de error de valor, frente a 0,1354 de full y 0,1429 de probe; full solo gana en el resultado. Una tirada por modo: ordenación sugerente.
  • Paridad ONNX: 100 % de decisiones de error en fp32, fp16 e int8, con 60,7 / 30,6 / 18,3 MB y un desvío máximo del valor de 0,024 en int8.
  • Tests unitarios que se quedan: el modelo no es causal, el relleno no influye, un FEN va y vuelve casilla a casilla, el 80/10/10 se cumple, probe deja el encoder idéntico bit a bit, ningún game_id cruza el reparto y el F1 a mano coincide con el del código.
  • Reproducibilidad: 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., hash del vocabulario de casillas fijado en un test, semilla del enmascarado en la configuración y reparto como función pura del id.

Lo siguiente es M4, las clases particulares: 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 mover los ciento quince millones de pesos del medium. El encoder sigue ahí: su barra en la demo, sus embeddings como entrada 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., la técnica con la que en M2 leíste que se extrae el tablero de las activaciones de un modelo de ajedrez, ahora montada por ti.

La cheatsheet del módulo, once preguntas con su respuesta corta, está justo debajo.

// el recorrido · lo conseguido

  1. M0 El tallerEntorno reproducible(hecho)
  2. M1 Datos y tokensDatos y tokenización(hecho)
  3. M2 El decoderPreentrenar un GPT(hecho)
  4. M3 El encoderEmbeddings y clasificar(hecho)
  5. M4 Fine-tuningSFT y LoRA(por delante)
  6. M5 AlineamientoPreferencias: DPO, GRPO(por delante)
  7. M6 Evaluar y publicarMedir y desplegar(por delante)
  8. FASE 2 El entrenadorAgentes, RAG, MCP

lo que tienes ahora

Una barra de evaluación y una alerta de error en vivo en la demo, y la costumbre de desconfiar de una exactitud alta sobre una clase rara.

siguiente parada · M4 Fine-tuning

Clases particulares: jugar como un 1500 o como los maestros. Con LoRA no se reescribe su libro; se le pegan notas adhesivas encima.

// 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 un baseline y por qué el de M3 es deliberadamente tonto?
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 tonto a propósito: un baseline 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.
Todas las cheatsheets, imprimibles →