// 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.
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
| Lab | Ruta | Reloj | Deja | Atajo |
|---|---|---|---|---|
| Lab 1El encoder bidireccional y el enmascarado | cuesta máquina | segundos el script, 15 min el preentrenamiento | checkpoints/encoder-mmm/best.pt, 75,2 % en jugadas tapadas | rukh pull encoder-mmm-v4 trae la versión grande |
| Lab 2Las tres cabezas con el tronco congelado | imprescindible | 1-3 min | el probe, y la prueba de que no mueve el tronco | no hay |
| Lab 3Las tres etapas y la curva por etiquetas | cuesta máquina | 1-3 min cada etapa, ~6 la curva | la curva 10/25/50/100 % dentro del checkpoint | no hay |
| Lab 3bLa otra entrada: las casillas desde cero | cuesta máquina | ~10 min | el contraste que justifica la entrada elegida | no hay |
| Lab 4Medir contra la heurística | cuesta máquina | ~5 min | F1 frente a la línea base, y las correlaciones | no hay |
| Lab 5La versión v4, exportar, embeddings y la isla | cuesta máquina | 40 + 3 + 5 + 5 min | el encoder que sirve la demo, y value-bar.json | rukh 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:
"""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, PositionEncoderfrom rukh.models.encoder import MMM_IGNORE_INDEXfrom 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_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 %)")# 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%}")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%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:
uv run rukh train encoder --config configs/train/encoder-mmm.yamlSalida 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.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, 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).
uv 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), 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.4586Por 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.
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 |
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:
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode last-n --curveVa 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 |
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:
uv run rukh train heads --config configs/train/encoder-heads.yaml --mode fullLotes 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.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 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:
uv run rukh eval encoder --model checkpoints/encoder-heads-<fecha>/best.pt --stage encoder-squaresTitular 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 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)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í:
ls -td checkpoints/encoder-heads-moves-*/ | head -1Evalú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.
uv run rukh eval encoder --model checkpoints/encoder-heads-moves-<fecha>/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 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 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 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:
uv run rukh export --ckpt checkpoints/encoder-heads-moves-<fecha>/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 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:
uv run rukh encoder embed --positions data/evals/positions-eval.parquet --out artifacts/embeddings/positions.npy --ckpt checkpoints/encoder-heads-<fecha>/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 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.
"""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-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 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)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»:
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-<fecha>/step-4000.pt --stage encoder-v4uv run rukh export --ckpt checkpoints/encoder-heads-v4-<fecha>/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.
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:
- 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.
- Si el encoder es más plano. Es lo esperable: un
tanhentrenado 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. - 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
THRESHOLDen 0,04459 aparecen las verticales. - 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.
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.
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.
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
tuney F1 medido en lascore, partidas porgame_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 % dellast-nsobre 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-ngana con 0,1180 de error de valor, frente a 0,1354 defully 0,1429 deprobe;fullsolo 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,
probedeja el encoder idéntico bit a bit, ningúngame_idcruza 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 uila 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
- M0 El tallerEntorno reproducible(hecho)
- M1 Datos y tokensDatos y tokenización(hecho)
- M2 El decoderPreentrenar un GPT(hecho)
- M3 El encoderEmbeddings y clasificar(hecho)
- M4 Fine-tuningSFT y LoRA(por delante)
- M5 AlineamientoPreferencias: DPO, GRPO(por delante)
- M6 Evaluar y publicarMedir y desplegar(por delante)
- 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.