// M3 · lección 01
El encoder: entender la posición
El mismo bloque del decoder sin la máscara causal: un encoder bidireccional de quince millones de parámetros que acierta el 75,2 % de las jugadas que se le tapan, con tres cabezas que dicen cuánto vale la posición, si la última jugada fue un error y quién va a ganar. Bate a la heurística de material por nueve puntos de F1 y se queda a la mitad del listón de correlación; afinar solo los dos últimos bloques sale mejor que afinar el modelo entero, y la curva por número de etiquetas se aplana en el 25 %, de modo que las tres cuartas partes de las evaluaciones de Stockfish no compraron nada. Y por el camino enseña por qué un 96,78 % de exactitud sobre una clase del 3,7 % no significa absolutamente nada.
Qué vas a construir
Al terminar esta sección sabrás qué objeto vas a escribir, cuánto ocupa, en qué se parece y en qué se diferencia del modelo de M2, y qué se ve en pantalla cuando funciona.
Vas a construir un encoderEncoderTransformer con atención bidireccional: cada posición ve toda la secuencia. No genera; representa. En Rukh el encoder lee una partida entera y produce un vector por posición del que salen el valor y la detección de errores. bidireccional de 15 052 800 parámetros —quince millones, poco más de un tercio del decoder de M2— que no juega. Mira una posición y dice tres cosas: cuánto vale, si la jugada que llevó hasta ella fue un error, y cómo acabó la partida de la que salió. Es el mismo bloque que escribiste en el módulo anterior, con la misma atención, el mismo MLP y la misma normalización previa; lo único que cambia es la máscara, y ese cambio de una línea convierte un generador en un lector.
Ocho capas, seis cabezas de atenciónAtenciónOperación que mezcla las posiciones de una secuencia: cada posición emite una consulta (Q), cada una ofrece una clave (K) y un valor (V); el producto escalar entre consulta y claves, escalado por 1/√d y pasado por softmax, da los pesos con los que se promedian los valores. Es la única parte del Transformer donde las posiciones se hablan entre sí., 384 dimensiones, y dos representaciones de entrada que vas a comparar: la partida como secuencia de jugadas UCIUCIDos cosas con el mismo nombre. La notación UCI escribe una jugada como casilla de origen, casilla de destino y promoción opcional (`e7e8q`): no depende del contexto, por eso es la tokenización por defecto. El protocolo UCI es la forma en que hablamos con Stockfish desde `python-chess`. —los mismos 2 030 tokensTokenUnidad mínima que el modelo lee y escribe. En Rukh, por defecto, un token es una jugada completa en notación UCI (`e2e4`); en otras tokenizaciones puede ser un carácter o un trozo de texto aprendido por BPE. El modelo nunca ve letras ni tableros: ve identificadores enteros de tokens. del decoder— o la posición como 69 casillas leídas de un FENFENCadena de texto que describe una posición completa: piezas por fila, turno, derechos de enroque, casilla al paso y contadores de jugadas. Es la clave con la que se cruzan las posiciones de las partidas con las evaluaciones públicas de Stockfish.. El preentrenamiento es masked move modelingMLM (masked language modeling)Objetivo de preentrenamiento de BERT: se esconde una parte de los tokens y el modelo, que ve la secuencia por los dos lados, tiene que reconstruirlos. En Rukh se llama masked move modeling porque el token es una jugada: se tapa el 15 % de las jugadas de la partida y de esas el 80 % se sustituye por `<mask>`, el 10 % por otra jugada al azar y el 10 % se deja tal cual. Los tokens de control (`<bos>`, Elo, resultado, `<eos>`) nunca se tapan: son la condición, no la señal.: se tapa el 15 % de las jugadas de una partida y el modelo las reconstruye mirando a los dos lados del agujero. Encima de eso montas tres cabezas lineales y las afinas en tres etapas, midiendo en cada una si el trabajo lo estaba haciendo el preentrenamiento o la cabeza.
Al terminar tendrás:
src/rukh/models/encoder.py, el modelo, ysrc/rukh/models/squares.py, el traductor de FEN a 69 tokens. Los dos con tests que comprueban lo que hay que comprobar: que no hay causalidad (cambiar un token futuro sí mueve los logits pasados; es la prueba que en M2 tenía que fallar), que el relleno no influye, y que la traducción de un FEN es reversible casilla a casilla.- Un encoder preentrenado con masked move modeling sobre el mismo flujo de tokens del decoder, para que los dos modelos hayan visto exactamente las mismas partidas.
- Tres cabezas afinadas en tres modos —probeProbe 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. con el tronco congelado, últimas capas, y completo— sobre 438 093 posiciones etiquetadas, y una curva por número de etiquetas al 10, 25, 50 y 100 %, que responde a cuántas etiquetas hacen falta de verdad antes de que etiquetar más deje de pagar.
- Una fila de la tabla única con precisiónPrecisiónDe todo lo que el detector marcó, qué fracción era de verdad: `VP/(VP+FP)`. Responde a «¿me puedo fiar cuando avisa?». Es la mitad de F1 que le importa a la demo: una barra que grita «error» en jugadas correctas se apaga a los cinco minutos. No confundir con exactitud, que cuenta también los aciertos sobre la clase mayoritaria., exhaustividadExhaustividadDe todo lo que había que marcar, qué fracción marcó el detector: `VP/(VP+FN)`. Responde a «¿cuántos errores se me escapan?». Se mueve en contra de la precisión: bajar el umbral captura más errores reales y también más falsas alarmas, y elegir el punto de esa curva es una decisión de producto, no de entrenamiento. En Rukh el umbral por defecto es 0,5 y está en `configs/eval/encoder.yaml`. y F1F1Media armónica de precisión y exhaustividad: `2·P·R/(P+R)`. Se usa cuando una clase es rara y la exactitud engaña: en Rukh, un detector que dijera «no es error» siempre acertaría el 90 % de las veces y tendría F1 cero. La media armónica castiga el desequilibrio, así que solo sube si las dos suben. El listón de `GOAL.md` para M3 es superar en cinco puntos de F1 a la heurística de material. de detección de errores, del encoder y de una línea baseLínea baseEl rival tonto contra el que se mide un modelo, para saber si su número significa algo. En M3 es una heurística de material (peón 1, caballo y alfil 3, torre 5, dama 9) más movilidad, que llama error a toda jugada que pierda un punto neto. Se mide sobre las mismas filas y con la misma definición de acierto que el encoder: un margen medido sobre dos conjuntos distintos no es un margen. de material medida sobre las mismas filas, más la correlaciónCorrelación (Pearson y Spearman)Dos maneras de medir si un número predicho acompaña al real. Pearson mide si los valores se alinean en una recta; Spearman es Pearson sobre los rangos, así que solo mide si el orden coincide. Se publican las dos porque discrepan exactamente cuando el modelo tiene bien el orden y mal la escala, que es lo que le hace un `tanh` acotado a una puntuación en centipeones. `GOAL.md` pide ≥ 0,8 entre el valor del encoder y el `cp` de Stockfish. entre el valor predicho y los centipeonesCentipeón (cp)Unidad de la evaluación de un motor: 100 centipeones equivalen a un peón de ventaja. Es la etiqueta que Rukh cruzó en M1 desde las evaluaciones de Lichess y la que el encoder aprende a predecir, acotada con `tanh(cp/400)` para que un mate valga ±1 en vez de diez mil y no domine la pérdida. Un error se define como perder ≥ 100 cp respecto a la mejor línea de la posición anterior. de Stockfish.
- El modelo en ONNXONNXFormato abierto para describir el grafo de una red y sus pesos, independiente del framework que la entrenó. Es lo que permite entrenar en PyTorch y ejecutar en el navegador con `onnxruntime-web`, sin Python ni backend. con dos salidas —
valueyblunder— y la barra de evaluación en vivo en la demo, junto al tablero, mientras el decoder sigue eligiendo su jugada en otro worker. - Y en esta misma página, una visualización de una partida entera con las dos curvas de evaluación, la del encoder y la de Stockfish, para ver de un vistazo dónde acierta y dónde se inventa cosas.
Todas las salidas de esta lección son de la tirada real del 19 de septiembre de 2026 en una RTX
5090 —los tres modos de afinado, la curva completa, la exportación y los embeddings—, y la figura
del final dibuja un JSON generado con un checkpoint de verdad. No queda ningún hueco, y por eso la
lección está vigente. Y adelantamos los titulares, porque un curso que esconde su resultado hasta
el final está vendiendo, no enseñando: uno de los dos criterios de GOAL.md se cumple con holgura
y el otro no se cumple en absoluto; afinar solo los dos últimos bloques dio mejor error de valor
que afinar los quince millones de parámetros enteros; y la curva por etiquetas se aplana en el
25 %, o sea que las tres cuartas partes de las evaluaciones de Stockfish que costó montar el
conjunto no compraron nada medible.
Teoría justa
Al terminar esta sección podrás explicar qué cambia exactamente entre el modelo de M2 y este, por
qué el preentrenamiento es el que es hasta en los decimales de sus proporciones, y qué decisión de
EncoderConfig habilita cada idea.
Causal frente a bidireccional: la línea que lo cambia todo
Abre src/rukh/models/decoder.py y src/rukh/models/encoder.py uno al lado del otro. Los dos
importan sus bloques del mismo sitio, rukh.models.layers. Los dos suman
un embeddingEmbeddingTabla que asigna un vector aprendido a cada id del vocabulario, y por extensión ese vector. En `rukh-small` la tabla es de 2 030 × 512: cada jugada UCI entra en el modelo como un punto en un espacio de 512 dimensiones, aprendido a la vez que el resto de la red. de token y uno de posición, aplican dropout, pasan por una
pila de bloques pre-norm y terminan en un LayerNorm. La atención es la misma llamada,
F.scaled_dot_product_attention, con las mismas proyecciones fusionadas. La diferencia cabe en un
argumento:
# decoder.py: cada posición solo mira hacia atráslayers.Block(d_model, n_head, ff, dropout, causal=True)
# encoder.py: cada posición mira toda la secuencialayers.Block(d_model, n_head, ff, dropout, causal=False)Ese booleano acaba en el is_causal de la llamada de atención, que es lo que decide si la matriz de
pesos se recorta a su triángulo inferior o se queda entera. Nada más. No hay un “modelo encoder” y un
“modelo decoder” en el sentido de dos arquitecturas distintas: hay un Transformer y una máscara.
Vale la pena detenerse aquí porque es el punto donde mucha gente se confunde al leer sobre BERT y
GPT como si fueran mundos separados. Lo que los separa es la tarea, y la máscara es la
consecuencia de la tarea. Si tu objetivo es “predice el siguiente token”, tienes que impedir que la
posición t lea el token t+1, porque si no la tarea se convierte en copiar. Si tu objetivo es
“reconstruye lo que he tapado”, no solo puedes dejar mirar hacia delante: tienes que dejarlo,
porque la mitad de la información sobre un hueco está a su derecha.
En ajedrez la imagen es muy concreta. Para saber si la jugada 20 fue un error, lo que más ayuda es la jugada 21: si el rival capturó una dama, ya sabes bastante. Un modelo causal nunca puede usar ese dato en el momento de evaluar la jugada 20. El encoder sí, y por eso es la herramienta correcta para valorar, clasificar y detectar, y la herramienta equivocada para jugar.
El resto de EncoderConfig es un presupuesto deliberadamente más pequeño que el del decoder:
n_layer = 8, n_head = 6, d_model = 384 (así que head_dim son 64, igual que en M2: seis
cabezas de 64 en vez de ocho), d_ff implícito en 4 × d_model = 1 536, block = 200,
dropout = 0.1 —el decoder entrena sin dropout y este no, porque va a ver muchas menos etiquetas en
el afinado— y posiciones aprendidas. La cuenta, bloque a bloque:
por bloque: 2·384 (ln1) + (384·1152 + 1152) (qkv) + (384·384 + 384) (proyección) + 2·384 (ln2) + (384·1536 + 1536) + (1536·384 + 384) = 1 774 464
8 bloques = 14 195 712embedding de tokens 2030 · 384 = 779 520posiciones 200 · 384 = 76 800LayerNorm final 2 · 384 = 768cabeza MMM atada al embedding = 0 ----------total = 15 052 800La última fila es la que más gente se salta, y sin ella la cuenta sale en 15 832 320: la cabeza que
predice el token tapado es una matriz de 384 × 2030, exactamente la traspuesta del embedding, y el
código ata las dos (tie_embeddings, en EncoderConfig), así que son los mismos 779 520 números
contados una vez y no dos. Atar no es solo ahorrar memoria: obliga a que “el vector que representa
e2e4” y “el vector contra el que se puntúa e2e4” sean el mismo objeto, que es lo que uno quiere
que signifique un embedding. Solo se ata en el esquema de jugadas, donde entrada y salida son el
mismo vocabulario de 2 030 movimientos; con 47 tokens de casillas no compensa y el código no lo hace.
Quince millones. El plan de P3 llegó a proponer “entre 18 y 25 millones” a ojo y la configuración del spec dice otra cosa; manda la configuración, y la errata está anotada en el plan. Es un detalle pequeño con una moraleja que no lo es: las cifras a ojo se corrigen contra la aritmética, no al revés, y un proyecto serio deja escrito cuándo se equivocó.
Qué se pierde y qué se gana al dejar de predecir el futuro
Lo que se pierde es la generación. Un modelo bidireccional no puede escribir la siguiente jugada, porque su entrenamiento nunca le pidió producir algo sin verlo antes. Si intentaras muestrear de él token a token, cada paso le daría una secuencia incompleta cuya mitad derecha no existe, un régimen que jamás vio. No hay truco: el encoder de M3 no juega, y la demo sigue pidiéndole la jugada al decoder de M2.
Lo que se gana son tres cosas.
Contexto completo por token. En un decoder, el vector de la posición 5 resume las jugadas 1 a 5. En el encoder resume la partida entera desde el punto de vista de la jugada 5. Para clasificar, valorar o detectar, esa asimetría es enorme.
Eficiencia de la señal. Un decoder causal aprende de cada token una vez, como objetivo de la posición anterior. El MLM solo puntúa el 15 % de las posiciones por pasada, lo que suena a desperdicio y en parte lo es —por eso BERT necesita más épocas que GPT para el mismo corpus—, pero a cambio cada predicción se hace con el doble de contexto. Es un intercambio, no una mejora gratis, y conviene saber cuál de los dos lados estás comprando.
Un vector de la posición. Esto es lo que de verdad justifica el módulo. Del encoder sale, con un poolingPoolingReducir los T vectores que devuelve un encoder a uno solo que represente la secuencia entera. Rukh implementa los dos clásicos: `cls` toma el vector de la primera posición (`<bos>` en la entrada de jugadas, `<cls>` en la de casillas) y `mean` promedia solo los tokens reales, nunca el relleno. Ese único vector es lo que leen las tres cabezas de M3 y lo que se guarda como embedding de posición., un único vector por posición sobre el que se pueden montar cabezas baratas, y que además sirve para buscar posiciones parecidas en la fase 2 del curso. Un decoder también tiene estados internos, pero están sesgados hacia “qué viene ahora” en vez de hacia “qué es esto”.
Masked move modeling: 15 %, 80/10/10 y un menos cien
El preentrenamiento es el de BERT con las palabras cambiadas por jugadas. Se toma una partida
tokenizada, se elige al azar el 15 % de sus posiciones, y de las elegidas: el 80 % se sustituye por
el token <mask>, el 10 % se sustituye por otra jugada cualquiera del vocabulario, y el 10 %
restante se deja exactamente como estaba. La pérdida es entropía cruzada sobre esas posiciones
elegidas, y solo sobre esas.
Cada número tiene una razón, y las tres ramas del 80/10/10 responden a problemas distintos.
Por qué 15 %. Es un equilibrio entre dos fuerzas opuestas. Si tapas poco, casi todas las pasadas se gastan en procesar contexto que no puntúa nadie y el entrenamiento se vuelve carísimo por señal útil. Si tapas mucho, el contexto que queda deja de ser suficiente para reconstruir: con el 50 % tapado, media partida son huecos y el modelo aprende a adivinar la jugada media en vez de a leer la posición. El 15 % es el valor de BERT, adoptado aquí sin cambios porque el argumento se traslada tal cual; hay trabajos posteriores que suben esa proporción en modelos grandes, y sería un experimento honesto para quien quiera hacerlo con esta base.
Por qué no todo <mask>. Si el 100 % de las posiciones elegidas se sustituyera por <mask>,
el modelo solo vería ese símbolo durante el preentrenamiento y nunca durante el afinado, donde las
secuencias vienen completas. Aprendería una representación que funciona cuando falta algo y se
degrada cuando no falta nada: el clásico desajuste entre preentrenamiento y uso. Ese es el problema
que las otras dos ramas resuelven.
Qué hace el 10 % aleatorio. Sustituir una jugada por otra distinta, sin avisar, obliga al modelo
a desconfiar de lo que lee. Si solo hubiera <mask> y tokens intactos, la política óptima sería
“copia lo que ves salvo donde ponga <mask>”: el modelo solo construiría una representación real de
las posiciones marcadas. Con ruido, cualquier token puede ser mentira, así que a cada posición le
conviene tener su propia opinión sobre qué debería haber ahí. En ajedrez esto es especialmente
sabroso: una jugada aleatoria en medio de una partida suele ser ilegal, y detectar eso exige
mantener el tablero en la cabeza.
Qué hace el 10 % intacto. Es la rama que la gente olvida. Sin ella, el modelo podría aprender que
un token que no es <mask> y no está corrupto no necesita representación propia. Dejando un 10 % de
posiciones elegidas exactamente como estaban —pero puntuándolas igual— se le pide que prediga lo que
ya tiene delante, lo que fuerza a que la representación de toda posición contenga qué jugada es.
Es la rama que convierte el objetivo en “representa la partida entera” en lugar de “rellena huecos”.
Qué nunca se tapa. Los tokens de control: <bos>, los dos tokens de EloEloEscala de fuerza de juego: una diferencia de 200 puntos implica que el mejor gana unas tres de cada cuatro veces. Lichess usa Glicko-2, compatible en la práctica. Rukh estima el Elo de cada modelo jugando contra Stockfish limitado y publica el intervalo de confianza.,
el de resultado y <eos>. Son la condición de la partida, no su contenido. Pedirle a un modelo
bidireccional que prediga el resultado teniendo delante la partida completa es regalarle una tarea
trivial —el mate está a la vista— que no enseña nada de ajedrez y contamina la pérdida. En el código
son los 62 primeros ids del vocabulario, que por construcción forman un prefijo: por eso el ruido
aleatorio se sortea por encima de ese bloque y nunca inventa un Elo o un resultado falso.
Y por qué ignore_index es −100 y no 0. En M2, las posiciones que no había que puntuar eran el
relleno, y el relleno es el token 0; poner ignore_index=0 funcionaba porque “predecir el id 0” y
“no predecir nada” querían decir lo mismo. Aquí no. El id 0 es <pad> en los dos esquemas, pero el
esquema de casillas es un vocabulario pequeño y cerrado en el que un 0 puede aparecer como etiqueta
legítima, así que confundir las dos cosas sería un error silencioso: unas etiquetas dejarían de
puntuar sin que nadie se entere y la pérdida bajaría por el motivo equivocado. La solución es un
valor que no es un id de nada, -100, que es además la convención de PyTorch y de Hugging Face. En
rukh.models.encoder está como MMM_IGNORE_INDEX, con el porqué escrito al lado.
Pooling: una representación de la posición
El encoder devuelve (B, T, d_model): un vector por token. Las cabezas necesitan uno solo por
secuencia. Reducir lo uno a lo otro es el pooling, y hay dos formas clásicas.
CLS. Se reserva la posición 0 para un token que no significa nada por sí mismo —<bos> en el
esquema de jugadas, <cls> en el de casillas— y se usa su vector de salida como representación. La
idea es que, al no tener contenido propio, ese token no tiene nada mejor que hacer que acumular lo
que la atención le traiga del resto, y el entrenamiento lo empuja a resumir. Funciona, pero depende
de que la tarea de afinado lo entrene: en un modelo solo preentrenado con MLM, el vector CLS puede
ser bastante poco informativo.
Media. Se promedian los vectores de todos los tokens. Es más robusta, no depende de que ninguna
posición haya aprendido a ser el resumen, y es la que Rukh usa por defecto (pooling: mean en
configs/train/encoder-heads.yaml).
Ahora la parte importante, la que convierte “promediar” en una operación con condiciones. La media
tiene que ignorar el relleno. Un lote agrupa secuencias de longitudes distintas y las cortas se
completan con <pad> hasta la longitud de la más larga. Si promedias los T vectores sin más,
estás metiendo en el resumen los vectores de posiciones que no existen, y —esto es lo que duele— en
una cantidad que depende de con quién le haya tocado viajar a esa secuencia en el lote. La misma
partida da una representación distinta según el lote. Eso no es ruido: es una dependencia de algo
que no debería existir, y envenena tanto el entrenamiento como la evaluación.
En el código, pool lee la máscara del propio idx cuando no se la pasan:
mask = self.padding_mask(idx) if attention_mask is None else attention_mask.bool()weights = mask.unsqueeze(-1).to(hidden.dtype)return (hidden * weights).sum(dim=1) / weights.sum(dim=1).clamp(min=1.0)Suma solo los tokens reales y divide por cuántos eran. El clamp(min=1.0) evita dividir por cero en
el caso degenerado de una secuencia entera de relleno, que además está prohibido más arriba: una
fila completamente enmascarada haría que el softmax de la atención devolviera NaN, así que el
encoder la rechaza con un error explícito en vez de dejar que aparezca un NaN tres capas después.
“Una representación de la posición” significa exactamente esto: un vector de 384 números del que se puede leer, con una transformación lineal, si las blancas están mejor, si la última jugada fue una barbaridad y quién va a ganar. Si esos 384 números contienen esa información, el preentrenamiento hizo su trabajo. Ese es el experimento, y el pooling es el sitio donde se hace la medida.
Tres cabezas sobre un mismo tronco
Las tres cabezas son una capa lineal cada una. No es pereza: es el diseño del experimento.
class ValueHead(nn.Module): # un escalar, acotado con tanhclass BlunderHead(nn.Module): # un logit, no una probabilidadclass ResultHead(nn.Module): # tres clases: blancas, tablas, negrasSi una capa lineal sobre el vector agrupado basta para decir cuánto vale una posición, entonces esa información ya estaba en el vector, porque una capa lineal no tiene capacidad para inventarla. Si pusiéramos dos capas ocultas y una no linealidad, la cabeza podría aprender la tarea por su cuenta y el resultado no diría absolutamente nada sobre el encoder. La regla es general y vale para cualquier proyecto: cuando quieras medir una representación, usa la sonda más tonta que funcione.
La cabeza de valor es una regresión con tanh, contra la etiqueta tanh(cp / 400). Dos
decisiones ahí. La primera, por qué acotar: los centipeones no están acotados y un mate vale ±10 000
en la convención del repo, así que un puñado de mates dominaría el error cuadrático y el modelo
gastaría su capacidad en clavar posiciones que ya están decididas. El tanh comprime: 400
centipeones —cuatro peones, algo menos que la torre de la tabla de material de aquí abajo, que vale
500— son 0,76; 1 000 son 0,99; un mate es 1. La segunda, por qué 400 y no otro
número: es el divisor que hace que la zona interesante (de −300 a +300 centipeones, donde una
partida todavía se juega) caiga en la parte de la curva que tiene pendiente, y es un parámetro de
configuración (value_scale) por si alguien quiere discutirlo con datos. El repo publica además una
variante en cinco tramos (por debajo de −200, de −200 a −50, de −50 a 50, de 50 a 200, por
encima de 200 centipeones) para poder comparar en las mismas filas una regresión con una
clasificación: es la forma limpia de responder a “¿y si esto fuera mejor como clasificación?” en vez
de opinar.
La cabeza de error emite un logit, no una probabilidad, y eso tiene dos motivos prácticos. Uno
numérico: binary_cross_entropy_with_logits es estable donde aplicar sigmoide y luego el logaritmo
no lo es. Otro de diseño: una buena parte de las filas no tiene etiqueta de error en absoluto
—hace falta la posición anterior de la misma partida para saber cuánto se perdió, y la primera
jugada no la tiene—, así que la pérdida se calcula solo sobre las filas etiquetadas, con una máscara,
y la ausencia se representa como null, nunca como un 0. Etiquetar “no lo sé” como “no fue error”
es una de las maneras más rápidas de arruinar un clasificador.
La cabeza de resultado son tres clases. Es la más ruidosa de las tres, porque la etiqueta es
una propiedad de la partida repetida en todas sus posiciones: la jugada 3 de una partida que las
blancas acabaron ganando no es una posición ganadora, y el modelo no puede acertar ahí. Por eso pesa
la mitad que las otras dos en la pérdida conjunta (result: 0.5 en HeadWeights).
Y por qué pesos por cabeza en vez de sumar sin más: las tres pérdidas viven en escalas distintas —un error cuadrático medio pequeño frente a dos entropías cruzadas— y tienen cantidades de datos distintas. Sumarlas a pelo no es “tratarlas por igual”: es dejar que la que tenga los números más grandes decida sola en qué dirección se mueve el tronco, sin que nadie lo haya escrito en ninguna parte. Un peso explícito, aunque valga 1, al menos es una decisión visible.
Afinado por etapas: probe, últimas capas, completo
Los tres modos de rukh train heads son tres preguntas distintas, y se ejecutan en este orden por
una razón.
probe. El encoder se congela entero y solo se mueven las tres cabezas lineales. Pregunta: ¿la
información ya está en la representación? Es la única de las tres que dice algo sobre el
preentrenamiento, porque es la única en la que el preentrenamiento es lo único que puede haber
puesto ahí la información. El test que acompaña a este modo compara los estados del encoder antes y
después y exige que sean idénticos, y hay un detalle fácil de pasar por alto que el código cuida:
un encoder congelado se pone en modo eval, porque si se quedara en modo train seguiría aplicando
dropout y la misma posición daría un vector distinto en cada época —ruido que las cabezas no pueden
aprender a compensar y que el encoder no puede absorber, porque no está aprendiendo.
last-n. Se descongelan las últimas n capas (dos por defecto) y la normalización final.
Pregunta: ¿cuánto se gana dejando que las capas altas se especialicen? Es el punto intermedio
habitual en producción: las capas bajas conservan lo general, las altas se adaptan a la tarea, y el
coste de cómputo y de memoria es una fracción del afinado completo.
full. Se mueve todo. Pregunta: ¿qué es lo mejor que esta arquitectura puede hacer con estas
etiquetas? Se suele dar por hecho que da los mejores números, y es el que más etiquetas necesita,
el que más rápido se sobreajusta y el que menos dice sobre el encoder: cuando todo se mueve, es
imposible separar lo que aportó el preentrenamiento de lo que aportó el afinado. Guarda ese “se
suele dar por hecho”: en el lab 3 no se cumple.
La comparación entre los tres es el resultado interesante, no el número del mejor. Si probe ya va
bien, el preentrenamiento funcionó y tienes un modelo barato de adaptar a tareas nuevas. Si probe
va mal y full va bien, lo que estás midiendo es la arquitectura y las etiquetas, no el
preentrenamiento, y la conclusión honesta es que el MLM aportó poco en este dominio y a esta escala.
La curva por número de etiquetas
Esta es la lección del módulo, y la que más se va a repetir en tu vida profesional.
Se entrena la misma receta cuatro veces, con el 10, el 25, el 50 y el 100 % de las etiquetas de entrenamiento, y se dibuja la métrica contra el número de filas. Lo que la curva responde es una pregunta de presupuesto: ¿cuántas etiquetas hacen falta de verdad? Si se aplana en el 25 %, etiquetar cuatro veces más fue tirar dinero y el cuello de botella está en el modelo, en la tarea o en el ruido de las propias etiquetas. Si sigue subiendo en el 100 %, la inversión más rentable que te queda es etiquetar más, y ninguna idea arquitectónica va a competir con eso. Adelanto para que leas el resto de la sección sabiendo adónde va: en este módulo se aplana en el 25 %, y el lab 3 trae las cuatro filas.
Y un detalle metodológico que separa una curva útil de una anécdota: los subconjuntos son
anidados. El 10 % está dentro del 25 %, que está dentro del 50 %, que está dentro del 100 %. En
el código es un orden estable por fila —un CRC-32 de (semilla, partida, ply)— y un head(n) sobre
ese orden. Si en vez de eso sortearas cuatro muestras independientes, un bache en el punto del 50 %
podría ser simplemente un sorteo desafortunado, y estarías midiendo la varianza del muestreo en
lugar del valor de los datos. Creciendo un único conjunto, cada punto es el anterior más filas
nuevas, y una bajada significa algo.
Dos representaciones de entrada: jugadas o 69 casillas
Rukh implementa las dos entradas del encoder y las compara, porque es la lección de “qué es una buena representación” y porque la respuesta no es evidente.
Jugadas. La secuencia UCI de la partida hasta ahora, exactamente el vocabulario de 2 030 tokens del decoder. Contiene la posición —el tablero es una función determinista de la lista de jugadas— pero no explícitamente: hay que reconstruirla. Y contiene algo más que la posición: la historia. Cómo se llegó ahí, qué plan siguió cada bando, cuánto tiempo lleva esa torre sin moverse. Para valorar, la historia importa; para detectar un error, la jugada anterior es literalmente el dato central.
Casillas. El FEN convertido en 69 tokens fijos: un <cls>, las 64 casillas (pieza o vacío, en
orden por columnas, a1, a2, …, h8), el turno, los derechos de enroque, la casilla de al paso y el
reloj de 50 jugadas por tramos. El vocabulario es de 47 tokens y está fijado por construcción, con
un hash que lo pinta igual que el del vocabulario UCI. Un aviso sobre el reloj: las filas de P1 se
identifican por un FEN de cuatro campos, sin contadores, así que el esquema las lee todas como
clock:0 y ese token es constante en toda la tabla supervisada. Es decir: en este afinado el
tramo de reloj no aporta ni un bit, y solo empezará a distinguir posiciones cuando los datos traigan
el FEN completo. Aquí la posición está explícita: la capa
1 ya sabe que hay un caballo en f3 sin tener que deducirlo de nada. A cambio, la historia desaparece
por completo.
Cuál gana es una pregunta empírica, es la que docs/spec/02 pone como Componente 2 del hito, y
ahora tiene respuesta. Las hipótesis razonables antes de medir eran que las casillas ganaran en
valor y en resultado, donde lo que importa es el material y la estructura actual, y que las jugadas
ganaran en detección de errores, donde la referencia es la posición anterior y la entrada por
casillas literalmente no la tiene. Escríbelas antes de mirar los números; es la diferencia entre un
experimento y una justificación a posteriori.
El resultado medido
Las dos líneas se entrenaron con las mismas 438 093 posiciones etiquetadas, el mismo reparto por partida, los mismos 4 000 pasos de afinado y la misma evaluación sobre las mismas 10 000 posiciones retenidas. La única diferencia es la entrada —y, con ella, que la línea de jugadas pudo partir del preentrenamiento con masked move modeling y la de casillas no.
| Esquema | F1 de error (umbral ajustado) | Margen sobre la heurística | ROC AUC | Spearman valor | Pearson valor |
|---|---|---|---|---|---|
moves (preentrenado con MMM) |
0,179 | +9,0 | 0,738 | 0,407 | 0,442 |
squares (desde cero) |
0,146 | +5,7 | 0,696 | 0,422 | 0,688 |
Las dos hipótesis se cumplen a medias, y la mitad que falla es la interesante.
Las jugadas ganan en detectar errores, como se esperaba: 0,179 de F1 contra 0,146, y 0,738 de ROC AUC contra 0,696. Tiene todo el sentido, porque un error es una propiedad de la transición, no de la posición: para saber que algo se acaba de tirar hay que saber qué había antes, y la línea de jugadas lleva dentro la partida entera. El preentrenamiento ayuda exactamente ahí: un modelo que ha pasado dieciséis minutos adivinando jugadas tapadas ha aprendido, sin que nadie se lo pida, qué jugadas son plausibles en una posición, y una jugada implausible es el primer indicio de un error.
Las casillas ganan en la escala del valor, y ganan mucho: Pearson 0,688 contra 0,442, más de veinticuatro puntos. Ver las piezas en su sitio es lo que hace falta para contestar “cuánto vale esto”, porque el material y la estructura están en la entrada sin tener que reconstruirlos jugada a jugada. Y aquí aparece la distinción que la sección de métricas explica con cuidado: en Spearman los dos empatan (0,422 contra 0,407, dentro del ruido de una sola semilla). Las dos entradas ordenan las posiciones igual de bien; lo que la entrada por jugadas no consigue es la escala. Ordena bien y calibra mal, que es exactamente el caso en el que Pearson y Spearman se separan.
En una frase: ver el tablero es mejor para “cuánto vale esto”; ver la línea es mejor para “qué se acaba de tirar”. Que la respuesta sea “cada una gana en una cosa” y no “esta es mejor” es el resultado más útil de los dos, porque dice que las dos representaciones llevan información distinta y no una más que la otra. La continuación obvia —darle al mismo modelo las dos entradas— es trabajo de otro hito, y esta tabla es la razón para hacerlo.
Cómo se mide y contra qué
Al terminar esta sección sabrás leer las cifras que produce rukh eval encoder, explicar por qué la
línea base es deliberadamente mala, elegir un punto de operación sin hacer trampa y decir qué
significa cada métrica en un problema donde una clase es rara.
Un encoder no juega partidas, así que no se puede medir como al decoder. Se mide por lo que sabe de
posiciones que no ha visto: la partición de validación del reparto por partidas, que es el 10 %
de las partidas (val_fraction: 0.1, nunca el 10 % de las posiciones sueltas), y con la línea
base medida sobre exactamente las mismas filas.
Estos son los números reales del hito, para que leas el resto de la sección sabiendo adónde va:
Criterio de GOAL.md |
Listón | Medido | ¿Se cumple? |
|---|---|---|---|
| F1 de error sobre la heurística | +5 puntos | +9,0 (0,179 vs 0,089) | sí |
| Correlación del valor con Stockfish | ≥ 0,80 (Spearman) | 0,407 | no |
Un criterio cumplido y otro no, y las dos cosas se publican con el mismo tamaño de letra. Hay además
una tercera cifra, val/blunder_acc = 96,78 %, que no es ningún criterio y que si se leyera como si
lo fuera sería la mentira más cómoda del módulo. Empezamos por ella.
El 96,78 % que no significa nada
Esta es la subsección más transferible del módulo, y no tiene nada que ver con el ajedrez: vale para cualquier detector de algo raro que vayas a construir en tu vida —fraude, fallos de máquina, diagnósticos, moderación—, que es decir, para casi todos.
Los errores son el 3,72 % de las filas etiquetadas. De ahí salen tres consecuencias, en orden de gravedad.
Uno: la exactitud no mide nada. Un detector que conteste siempre “no hay error” acierta el 96,3 % de las veces sin mirar el tablero. Nuestra cabeza saca 96,78 %. Los cuatro decimales de diferencia son todo lo que la exactitud es capaz de decir de un modelo que, como vas a ver, sí ha aprendido algo. Cuando una clase es rara, la exactitud mide la tasa base y disfraza de resultado una propiedad del conjunto de datos.
Dos: el F1 en un umbral arbitrario tampoco mide lo que crees. Esta es la que engaña a gente con experiencia. El F1 de esta cabeza en el umbral fijo de 0,5 es exactamente 0,0000. Precisión cero, exhaustividad cero, cero posiciones marcadas de 3 660. La razón está en una sola línea del informe:
encoder probability span: 0.0040 to 0.2568 (mean 0.0446)La probabilidad más alta que esta cabeza le da a una posición en todo el conjunto es 0,2568. En 0,5 no se dispara nunca, así que ese F1 era cero por construcción antes de entrenar nada. Y no es que el modelo esté roto: es que un sigmoide entrenado con entropía cruzada sobre una clase del 3,7 % aprende, correctamente, que la respuesta esperada casi siempre es “no”, y coloca toda su masa de probabilidad abajo. Ordena bien las posiciones y las ordena todas por debajo de 0,5. Un F1 en un umbral fijo mide la calibración de un sigmoide que nadie calibró, no la calidad de la representación.
Tres: elegir el umbral es legítimo, y hay una única forma honesta de hacerlo. El umbral es un hiperparámetro como cualquier otro, y nadie te obliga a dejarlo en el 0,5 que viene de fábrica. Lo que no vale es elegirlo mirando las filas que después vas a puntuar, porque entonces el número que publicas es el máximo de una búsqueda sobre el propio conjunto de evaluación y no una estimación de nada. El procedimiento correcto es:
- Parte las filas etiquetadas del conjunto retenido en dos mitades, por
game_idy nunca por posición, por la misma razón que el reparto principal (la sección siguiente). Aquí salen 3 793 filas / 2 455 partidas en la mitadtuney 3 660 filas / 2 368 partidas en la mitadscore. - En la mitad
tune, barre el umbral y quédate con el que maximiza F1. Aquí sale 0,128. - Publica el F1 medido en la mitad
score, que no ha visto ningún umbral. Aquí sale 0,179.
Y publica también lo que cuesta ese paso, porque es la parte que casi nadie enseña: el mismo umbral
0,128 da 0,184 en la mitad tune donde se eligió y 0,179 en la mitad score. Esos cinco
milésimos son el optimismo que introduce elegir un punto de operación, y son la razón entera de que
las dos mitades no sean las mismas filas.
La heurística de material y movilidad
La línea base cabe en una página: peón 1, caballo 3, alfil 3, torre 5, dama 9, rey 0. A eso se le
suma la movilidad —jugadas legales de las blancas menos las de las negras, a 0,05 peones cada una,
o sea que veinte jugadas de diferencia valen un peón— y se convierte a la misma escala acotada que
la etiqueta, tanh(puntuación / 4) en peones, que es idéntico a tanh(cp / 400) en centipeones.
Para juzgar una jugada, se juega en un tablero de python-chess y se deja que el rival conteste con
su captura o promoción más rentable, a un solo ply; si el balance de material del que movió cae un
punto o más, la heurística dice “error”.
Es mala a propósito, y eso es una decisión de diseño, no una concesión. Una línea base que ya entendiera los sacrificios no sería un suelo: sería un competidor, y el margen dejaría de significar “el modelo aprendió algo más que contar piezas”. Lo que sí es innegociable es medirla igual: mismas filas, misma definición de acierto, mismo umbral de 100 centipeones en la etiqueta. Un margen medido sobre dos conjuntos distintos no es un margen, es una comparación de anécdotas.
Y hay una asimetría que conviene decir antes de leer el margen, porque no juega a favor del modelo: la heurística recibe la posición anterior y la jugada —vuelve a jugarla en un tablero para saber qué cambió—, mientras que el encoder de casillas solo ve el FEN resultante. Detectar un error sin ver de dónde vienes es más difícil que detectarlo viéndolo, así que el margen está medido en contra del modelo: si aun así gana, ha aprendido algo que no es contar piezas. Ganó por 5,7 puntos.
Hay una segunda asimetría, y esta va a favor del modelo, así que también se dice: la heurística es
una regla de sí/no sin umbral que ajustar, de modo que no se le dio ninguna mitad tune ni se
barrió nada en su lado. La comparación enfrenta a un modelo en su mejor punto de operación contra
una regla en el único que tiene. Es una cortesía que recibe el modelo y no la línea base, está
escrita en el informe para que no sea injusta en silencio, y conviene tenerla en la cabeza al leer
los +9,0 puntos del esquema de jugadas.
Por cierto, mira las dos filas de la heurística en la tabla de puntos de operación de más arriba: precisión 4,7 %, exhaustividad 77,1 %, 2 359 posiciones marcadas de 3 660. La regla grita “error” en dos de cada tres jugadas de la partida. Encuentra casi todos los errores porque marca casi todo, y de ahí su exhaustividad envidiable y su precisión ridícula. El encoder gana el margen por el otro lado: marca 191 posiciones en vez de 2 359 y acierta en el 15,7 % de ellas contra el 4,7 % de la regla. Dos detectores con F1 de 8,9 % y 17,9 % y con perfiles opuestos, que es exactamente por qué el titular lleva la precisión y la exhaustividad al lado y no solo el F1.
Qué no puede ver
El ejemplo que el propio código lleva escrito en src/rukh/eval/heuristic.py es la partida del
siglo: Donald Byrne contra Robert Fischer, Nueva York, 1956, con Fischer de trece años. Después
de 17.Rf1 (17.Kf1 en notación inglesa), Fischer jugó 17…Ae6 (g4e6, 17…Be6), ofreciendo la
dama. Es una de las jugadas más famosas de la historia del ajedrez y la partida acaba siendo una
victoria de las negras.
La heurística mira esa posición, ve que las blancas pueden jugar 18.Axb6 y llevarse una dama por nada, calcula una pérdida de nueve puntos de material y llama error garrafal a 17…Ae6. No se equivoca en la aritmética: se equivoca en todo lo demás, porque un sacrificio posicional es literalmente invisible para algo que solo cuenta piezas.
Tiene otras dos cegueras que también están escritas en el módulo en vez de arregladas, y merece la pena conocerlas porque explican de dónde salen sus falsos positivos:
- No ve la recaptura. La respuesta del rival se busca a un solo ply y solo entre capturas y promociones, así que un cambio igualado cuya recaptura llega dos plies después se lee como una pérdida.
- Cobra dos veces una pieza colgada. Si una pieza ya estaba en el aire antes de mover, cualquier jugada tranquila posterior carga con esa pérdida, porque el “antes” es el material en el tablero y no lo mejor que el jugador podría haber conservado.
Arreglarlas convertiría la línea base en un pequeño motor, y entonces ya no sería una línea base.
Precisión, exhaustividad y F1 cuando una clase es rara
Con el desbalanceoDesbalanceo de clasesQue una clase sea mucho más frecuente que la otra. En detección de errores solo una fracción pequeña de las jugadas pierde 100 cp, así que la exactitud es inútil como métrica (decir siempre «no» ya la deja altísima) y hay que mirar precisión, exhaustividad y F1 sobre la clase rara. También cambia el entrenamiento: la pérdida promedia sobre las filas etiquetadas y la clase mayoritaria manda si no se compensa. ya desmontado, las tres cifras que sí se publican.
La precisión responde a “de lo que marqué, cuánto era de verdad”: verdaderos positivos entre todo lo marcado. Es la que le importa a la demo, porque una barra que grita “error” en jugadas correctas se apaga a los cinco minutos. Aquí, 15,7 %.
La exhaustividad responde a “de lo que había, cuánto marqué”: verdaderos positivos entre todos los errores reales. Es la que le importa a alguien que quiera usar esto para analizar sus partidas, porque un error que no se detecta no se aprende. Aquí, 20,8 %.
Las dos se mueven en contra. Bajar el umbral de decisión captura más errores reales y también más
falsas alarmas; subirlo hace lo contrario. Elegir el punto de esa curva es una decisión de producto
—en Rukh el valor de fábrica está en configs/eval/encoder.yaml como threshold: 0.5, y ya has
visto lo que ese valor de fábrica le hace a una clase del 3,7 %— y no tiene una respuesta
matemática. F1 es la media armónica de las dos, 2·P·R/(P+R), y se usa porque castiga el
desequilibrio: con precisión 1,0 y exhaustividad 0,01, la media aritmética daría un respetable 0,5 y
la armónica da 0,02, que es la cifra honesta. Aquí, 17,9 %.
Conviene mirar esos tres números sin adornos: de cada seis posiciones que el modelo marca como
error, cinco no lo son, y de cada cinco errores reales se le escapan cuatro. Como producto,
todavía no sirve para nada. Como medida, es el doble que una heurística que sí entiende de
material, sobre las mismas filas y con la posición anterior escondida, y eso es lo que el módulo
pretendía demostrar. El listón de GOAL.md es superar el F1 de la heurística en cinco puntos y
salen +9,0: criterio cumplido, con el nivel absoluto dicho en voz alta para que nadie confunda
“bate a la línea base” con “está listo”.
Pearson y Spearman, y por qué las dos
Para el valor no hay umbral que elegir: es una regresión, y lo que se mide es si el número predicho acompaña al real.
Pearson mide si los valores se alinean en una recta. Es sensible a la escala: si predices sistemáticamente la mitad de lo que hay, Pearson lo nota. Spearman es Pearson calculado sobre los rangos —las posiciones en el orden, con la media de los rangos para los empates—, así que solo mide si el orden coincide y le da igual la escala.
Las dos discrepan exactamente en un caso, y es un caso que aquí va a ocurrir: cuando el modelo
tiene el orden bien y la escala mal. Es justo lo que le hace un tanh acotado a una
puntuación en centipeones que no lo está: las posiciones extremas se aplastan contra ±1 y la
relación deja de ser lineal aunque el ranking sea perfecto. Publicar solo Pearson haría parecer peor
un modelo que ordena bien; publicar solo Spearman escondería un modelo que ordena bien pero cuya
barra de evaluación marca 0,3 donde debería marcar 0,8, que en la demo es un fallo visible. Por eso
van las dos, y por eso GOAL.md pide ≥ 0,8, leído sobre Spearman. En el código, Spearman está
escrito a mano —Pearson sobre rangos medios— porque scipy no es una dependencia del proyecto y no
merece serlo por catorce líneas.
Un detalle de medida antes de los números: las dos correlaciones se calculan contra
tanh(cp / 400), la escala acotada con la que se entrena la cabeza, y nunca contra el cp en
bruto. Un mate forzado vale ±9 999 centipeones, y media docena de filas así decidirían el Pearson
del conjunto entero. Correlacionar contra la escala en la que se entrena no es hacerlo fácil: es
correlacionar contra la etiqueta.
El criterio de valor no se cumple, y esta es la cifra
| Esquema | Spearman | Pearson | Listón de GOAL.md |
|---|---|---|---|
moves |
0,407 | 0,442 | ≥ 0,80 → no |
squares |
0,422 | 0,688 | ≥ 0,80 → no |
El mejor Spearman del hito es 0,42 y el listón es 0,80. No está cerca, no es un decimal, no hay
manera de contarlo como un aprobado raspado: el criterio de valor de GOAL.md no se cumple y se
publica sin cumplir, que es lo que el propio GOAL.md manda hacer cuando pasa esto.
El diagnóstico, con lo que se sabe hoy, tiene tres piezas y ninguna es “hace falta un modelo más grande”:
- Cuatro mil pasos de afinado. Es el presupuesto del módulo, no un punto de convergencia, y ya viste que el preentrenamiento seguía bajando cuando se cortó.
- Cobertura de etiquetas del 9,8 % —y, atención, la curva dice que esta pieza no es la que duele. De los cinco millones de posiciones muestreadas, solo 488 159 recibieron una evaluación de Stockfish al cruzarlas con el conjunto público de evaluaciones, y la cabeza de valor aprende de las 438 093 que sobreviven al reparto. Parecen pocas; el lab 3 las mide, y con el 25 % de ellas el error del valor es el mismo. O sea que el problema no es la cantidad de etiquetas sino lo que cada una aporta: etiquetas más informativas, no más etiquetas.
tanh(cp/400)comprime justo donde hay densidad. La inmensa mayoría de las posiciones de club están entre −100 y +100 centipeones, y ahí eltanhlas aplasta contra el cero: la señal que la cabeza tiene que aprender a distinguir es minúscula precisamente donde están casi todos los datos. Una escala menos comprimida repartiría mejor el rango útil.
Es el mismo diagnóstico que el curso ya se encontró en M2 con el tamaño del modelo: el cuello son los datos, no la capacidad. Y fíjate en la señal que lo confirma dentro de esta misma tabla: la entrada por casillas, que es un modelo más pequeño y sin preentrenar, correlaciona mejor en Pearson (0,688 frente a 0,442). Si el límite fuera la capacidad, el modelo con más información dentro ganaría; lo que decide es cuánta señal hay en la etiqueta y cómo de explícita está la respuesta en la entrada.
Fuga de datos: por qué el reparto va por partida
Al terminar esta sección sabrás reconocer la fuga concreta de este módulo y explicar por qué un reparto aleatorio de filas, que en otros problemas es correcto, aquí es una trampa.
Una fuga de datosFuga de datosCualquier camino por el que información del conjunto de validación llega al de entrenamiento. No falla: los números mejoran, y se descubre cuando el modelo sale al mundo. En Rukh se evita partiendo por mes (enero entrena, febrero valida), por PuzzleId con semilla en los puzles, y entrenando el BPE solo con datos de enero. es cualquier camino por el que información de validación llega al entrenamiento. Nunca se manifiesta como un error: los números mejoran, las curvas salen bonitas y el problema aparece cuando el modelo sale al mundo.
El reparto obvio de una tabla de un millón de posiciones sería aleatorio por filas: el 90 % a entrenamiento, el 10 % a validación. Aquí eso es una fuga, y la razón es esta.
Toma una partida cualquiera y sus posiciones consecutivas, ply 30 y ply 31. La segunda es la primera más una jugada: treinta y una de las treinta y dos piezas siguen donde estaban, la estructura de peones es la misma, el plan es el mismo, la evaluación de Stockfish casi la misma. Si el ply 30 cae en entrenamiento y el 31 en validación, el modelo está siendo evaluado sobre una posición que ya vio, con un cambio cosmético. Un modelo que memoriza partidas puntúa igual de bien que uno que entiende ajedrez, y la métrica de validación —cuyo único trabajo es distinguir esas dos cosas— deja de hacerlo. Peor: la fuga es invisible. No hay ninguna cifra en el informe que la delate; el modelo simplemente parece mejor de lo que es hasta que lo pones en la demo.
La solución es partir por game_id: una partida entera va a un lado o al otro, nunca a los dos. En
el código es una función pura del id y de la semilla —un CRC-32 de "semilla:partida" y un módulo—
y esa pureza no es decorativa: hash() de Python cambia entre procesos por el aleatorizador de
hashes, y un hash de dataframe puede cambiar de versión a versión. Un reparto que no es reproducible
no es un reparto, es una lotería que vuelves a jugar cada vez que ejecutas el script. El test que lo
acompaña comprueba que ningún game_id aparece en los dos lados.
Y la misma lógica se aplica a la etiqueta de error, que necesita la posición anterior de la misma
partida: si la posición de la que se calcula el loss_cp cayera al otro lado del reparto, estarías
construyendo la etiqueta de validación con una fila de entrenamiento. Por eso el cruce se hace antes
de partir, y por eso una posición cuyo predecesor no está en la tabla no recibe la etiqueta 0: recibe
null.
Labs
Al terminar esta sección tendrás el encoder preentrenado, las tres cabezas afinadas en los tres
modos, la curva por número de etiquetas, la comparación contra la heurística, el modelo exportado y
los datos de la isla de esta página. Todos los comandos se ejecutan desde la raíz del repo rukh.
Los directorios de las tiradas llevan una marca de tiempo (unique_run_name: true, para que una
segunda tirada no pise los checkpoints de la primera); en los comandos se escriben sin ella para que
se lean. Debajo de cada comando va la salida real de la tirada de referencia, sin recortar.
Lab 1 · El encoder bidireccional y el enmascarado
Primero, demuestra que el modelo es lo que dices que es. En M2 escribiste un test que exigía que
cambiar un token futuro no moviera los logits pasados; aquí el test tiene que exigir lo
contrario. Guarda este script como labs/m3/bidirectional.py en el repo rukh:
"""Show that the encoder is not causal, and that the masking recipe does what it claims."""
import torch
from rukh.models import EncoderConfig, PositionEncoderfrom rukh.models.encoder import MMM_IGNORE_INDEXfrom rukh.train.mmm import MaskingConfig, apply_masking, control_ids, mask_id, masking_generator
torch.manual_seed(0)cfg = EncoderConfig(n_layer=2, n_head=2, d_model=64, block=16)model = PositionEncoder(cfg).eval()print(f"preset parameters: {PositionEncoder(EncoderConfig()).num_params(False):,}")
# 1. Bidirectionality: change the LAST token and watch the FIRST hidden state move.idx = torch.randint(100, cfg.vocab_size, (1, 8))with torch.no_grad(): before = model(idx)[0, 0].clone() idx[0, -1] = (idx[0, -1] + 1) % cfg.vocab_size after = model(idx)[0, 0]delta = (before - after).abs().max().item()print(f"max |delta| on the FIRST token after changing the LAST one: {delta:.6f}")print("causal" if delta == 0.0 else "bidirectional: the future reaches the past")
# 2. Padding: the real tokens of a sequence must not depend on how much padding travels with it.short = torch.tensor([[7, 8, 9, 0, 0, 0]])tight = torch.tensor([[7, 8, 9]])with torch.no_grad(): padded = model(short, PositionEncoder.padding_mask(short))[0, :3] exact = model(tight, PositionEncoder.padding_mask(tight))[0]print(f"max |delta| between padded and tight: {(padded - exact).abs().max().item():.6f}")
# 3. The 80/10/10 recipe, counted over 10 000 tokens instead of trusted.masking = MaskingConfig()batch = torch.randint(max(control_ids("moves")) + 1, cfg.vocab_size, (100, 100))inputs, labels = apply_masking(batch, masking, cfg.vocab_size, "moves", masking_generator(0))selected = labels != MMM_IGNORE_INDEXtotal = int(selected.sum())masked = int((inputs[selected] == mask_id("moves")).sum())kept = int((inputs[selected] == batch[selected]).sum())print(f"selected {total / batch.numel():.3%} of the tokens (target 15 %)")print(f" <mask> {masked / total:.1%} kept {kept / total:.1%} random {1 - (masked + kept) / total:.1%}")uv run python labs/m3/bidirectional.pySalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
preset parameters: 15,052,800max |delta| on the FIRST token after changing the LAST one: 0.239878bidirectional: the future reaches the pastmax |delta| between padded and tight: 0.000001selected 14.910% of the tokens (target 15 %) <mask> 80.6% kept 9.9% random 9.5%Las tres comprobaciones salen como tienen que salir. La primera es la del módulo entero: cambiar el
último token mueve el estado oculto del primero en 0,24, un número que en el decoder de M2
sería exactamente 0,000000. La segunda dice que el relleno no contamina: la diferencia entre pasar
la secuencia rellena y pasarla justa es 1e-6, o sea ruido de bf16 y no información filtrada. La
tercera cuenta el sorteo en lugar de creérselo: 14,91 % de tokens seleccionados sobre un objetivo
del 15 %, repartidos 80,6 / 9,9 / 9,5.
El kept que imprime la tercera parte es una cota superior del 10 % real, porque una sustitución
aleatoria puede caer por casualidad en el mismo token; con un vocabulario de 1 968 jugadas la
diferencia es despreciable, pero saber por qué el número no es exacto es parte del lab.
Y ahora el preentrenamiento. Doce mil pasos, lotes de 96 secuencias de 200 tokens con acumulación de
gradiente 3 —288 secuencias efectivas—, bf16 y torch.compile, sobre el mismo flujo empaquetado
que come el decoder:
uv run rukh train encoder --config configs/train/encoder-mmm.yamlSalida real de la ejecución de referencia (RTX 5090, 2026-09-19). Son las últimas seis evaluaciones de validación de las doce mil de la tirada, que tardó dieciséis minutos:
step 9500 val/loss 1.0621 val/top1 0.7396step 10000 val/loss 1.0456 val/top1 0.7430step 10500 val/loss 1.0291 val/top1 0.7467step 11000 val/loss 1.0195 val/top1 0.7483step 11500 val/loss 1.0106 val/top1 0.7507step 12000 val/loss 1.0032 val/top1 0.7518
input: movesmasking: 15% at 80%/10%/10%steps: 12000checkpoint: checkpoints/encoder-mmm-20260919-093554/step-12000.pt75,2 % de acierto en las jugadas tapadas. Tres de cada cuatro huecos se rellenan bien, y merece la pena pararse a entender por qué esa cifra no es comparable con el 51,1 % de top-1 del decoder de M2 aunque las dos se llamen “acierto sobre jugadas”. El decoder adivina el futuro: ve la partida hasta el ply n y tiene que elegir el ply n+1 entre treinta jugadas legales, muchas de ellas razonables, en una situación donde no hay una respuesta correcta. El encoder rellena un hueco con la partida entera alrededor: ve lo que vino después, y lo que vino después restringe brutalmente lo que pudo haber pasado. Si en el ply n+2 hay una torre en d1, la jugada tapada era seguramente la que la puso ahí. Es la misma asimetría que hay entre escribir la siguiente palabra de una frase y rellenar un hueco en una frase que ya está escrita entera.
Que la curva siga bajando en el paso 12 000 —de 1,0621 a 1,0032 en los últimos 2 500 pasos, sin aplanarse— dice además que el preentrenamiento no ha terminado de exprimirse. No se alargó porque dieciséis minutos es el presupuesto del módulo, y eso se escribe aquí en vez de fingir que el número es un techo.
Fíjate en la métrica que el bucle registra aparte de la pérdida: masked_tokens_per_s. Es la que de
verdad manda, porque los otros tokens_per_s cuentan también los tokens que no puntúa nadie. Y
fíjate en un detalle del bucle que parece contabilidad y no lo es: la pérdida del paso se pondera
por número de posiciones tapadas, porque un micro-lote donde el sorteo tapó pocas jugadas no debe
pesar lo mismo que uno donde tapó muchas.
// Ejercicio 01¿Por qué la validación del MLM se reinicia la semilla en cada evaluación?
Mira evaluate_mmm en src/rukh/train/mmm.py: antes de recorrer los lotes de validación crea un
generador nuevo con masking.seed, en vez de seguir usando el del entrenamiento. Explica qué
pasaría si no lo hiciera, y por qué eso hace que dos tiradas distintas se puedan comparar.
// SoluciónVer la solución
Si la validación usara el generador del entrenamiento, cada evaluación taparía posiciones distintas, porque el generador ha avanzado. La pérdida de validación del paso 500 y la del paso 1 000 se estarían calculando sobre dos tareas diferentes: una podría haber tapado jugadas fáciles (recapturas forzadas, enroques) y la otra difíciles, y la curva mezclaría la mejora del modelo con la dificultad del sorteo. Con la semilla reiniciada, todas las evaluaciones de la tirada tapan exactamente las mismas posiciones, así que la curva mide una sola cosa.
El beneficio se extiende entre tiradas: como la semilla está en la configuración y no depende del
estado del proceso, la evaluación de otra tirada con la misma masking.seed usa el mismo
conjunto de huecos. Comparar dos preentrenamientos deja de tener el asterisco de “sobre tareas
distintas”. Es la misma idea que fijar el conjunto de validación: la tarea de evaluación es
parte del instrumento de medida, y un instrumento que cambia entre medidas no mide.
Lab 2 · Las tres cabezas con el tronco congelado
Ahora el probe. Cuatro mil pasos, lotes de 256 posiciones, tasa de aprendizaje 1e-3 —mucho más
alta que la del preentrenamiento, porque solo se están moviendo tres capas lineales— y el encoder
congelado y en modo eval:
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), sobre las 438 093 posiciones etiquetadas de la tabla supervisada, con el 10 % de validación repartido por partida:
mode: probe 100% of the labels (438093 rows) val/value_loss 0.0603 val/blunder_loss 0.1285 val/result_loss 0.8844 val/loss 0.6310 val/value_mae 0.1429 val/blunder_acc 0.9678 val/result_acc 0.4586La salida imprime, por cabeza, la pérdida de validación y una métrica legible: val/value_mae (el
error absoluto medio del valor, en la escala acotada, así que 0,1 son unos 40 centipeones en la zona
central), val/blunder_acc —que es exactitud, útil para ver que algo se mueve, pero no la cifra
que se publica, por lo que ya sabes del desbalanceo— y val/result_acc.
Antes de ejecutarlo conviene escribir en algún sitio qué esperas, porque la mitad del valor de un experimento está en haberse comprometido antes. Una expectativa razonable: el resultado de la partida debería ser la más difícil de las tres, porque su etiqueta es una propiedad de la partida y no de la posición; el valor debería ser la más fácil, porque el material está casi explícito en la entrada.
La primera parte se cumple: 45,9 % de acierto en el resultado sobre tres clases, apenas trece puntos por encima de tirar una moneda de tres caras, que para una tarea cuyo techo teórico es bajísimo está donde tiene que estar. La segunda también: 0,1429 de error absoluto medio del valor, unos 57 centipeones en la zona central de la escala.
Y el 96,78 % de val/blunder_acc es la cifra más peligrosa de todo el módulo. Parece que la
cabeza de errores es la que mejor va de las tres; es exactamente al revés. Los errores son el
3,72 % de las filas, así que un detector que conteste siempre “no hay error” —sin mirar el
tablero, sin saber qué es un alfil— saca 96,3 %. La cabeza está batiendo a esa constante por
cuatro décimas. Guarda esa cifra: en Cómo se mide y contra qué se desmonta del todo, y es la
lección más transferible del módulo.
// Ejercicio 02El probe no mueve el encoder: demuéstralo tú
Escribe un test que cargue un checkpoint del encoder, entrene tres pasos en modo probe y
compruebe que los pesos del tronco salen idénticos. ¿Basta con comparar dos tensores con ==? ¿Y
qué otra cosa, además de los pesos, podría cambiar el vector que ven las cabezas aunque los pesos
no se muevan?
// SoluciónVer la solución
Comparar con torch.equal sobre cada tensor del state_dict sirve, y es mejor que
allclose: aquí la exigencia es igualdad bit a bit, no parecido. Si el test pasara con
allclose pero no con equal, algo estaría moviendo los pesos un poquito, y “un poquito” en
tres pasos es “mucho” en cuatro mil.
Lo otro que puede cambiar el vector sin tocar un solo peso es el dropout. EncoderConfig
trae dropout = 0.1, y un módulo en modo train lo aplica aunque sus parámetros estén
congelados: la misma posición daría un vector distinto en cada pasada. Las cabezas verían ruido
que no pueden compensar, porque no es una propiedad de la posición, y el encoder no puede
absorberlo porque no está aprendiendo. Por eso set_training_mode pone el modelo en train y
acto seguido devuelve el encoder a eval cuando el modo es probe. También hay una tercera
fuente, más sutil, que no aplica aquí pero conviene tener en la cabeza: en una red con
BatchNorm, congelar los pesos no congela las estadísticas móviles, que se actualizan en modo
train y cambian la salida. Congelar es más que requires_grad = False.
Lab 3 · Las tres etapas y la curva por etiquetas
Repite el afinado subiendo la temperatura: primero las dos últimas capas y la normalización final, después el modelo entero.
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode last-nSalida real (RTX 5090, 2026-09-19), sobre las mismas 438 093 filas y los mismos 4 000 pasos:
mode: last-n 100% of the labels (438093 rows) val/value_loss 0.0396 val/blunder_loss 0.1270 val/result_loss 0.8723 val/loss 0.6028 val/value_mae 0.1180 val/blunder_acc 0.9678 val/result_acc 0.4775uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode fullSalida real de la ejecución de referencia (RTX 5090, 2026-09-19), sobre las mismas 438 093 filas:
mode: full 100% of the labels (438093 rows) val/value_loss 0.0578 val/blunder_loss 0.1310 val/result_loss 0.8991 val/loss 0.6384 val/value_mae 0.1354 val/blunder_acc 0.9678 val/result_acc 0.5142Compara las tres columnas, que es todo el punto del lab:
| Métrica | probe (tronco congelado) |
last-n (2 bloques + norma) |
full (todo el modelo) |
|---|---|---|---|
val/value_mae |
0,1429 | 0,1180 | 0,1354 |
val/blunder_acc |
0,9678 | 0,9678 | 0,9678 |
val/result_acc |
0,4586 | 0,4775 | 0,5142 |
val/blunder_loss |
0,1285 | 0,1270 | 0,1310 |
val/loss |
0,6310 | 0,6028 | 0,6384 |
Lee primero la fila del valor, porque dice lo contrario de lo que casi todo el mundo espera: afinar menos red salió mejor que afinarla entera. El error absoluto medio del valor baja de 0,1429 con el tronco congelado a 0,1180 descongelando solo los dos últimos bloques y la normalización final, y vuelve a subir a 0,1354 cuando se descongelan los quince millones de parámetros. El punto intermedio no está entre los dos extremos: los gana a los dos.
La lectura estándar de eso es la de siempre en transferencia, y conviene tenerla a mano porque te la
vas a encontrar fuera del ajedrez. Con 438 093 etiquetas y un tronco de 15 M de parámetros, el
afinado completo tiene grados de libertad de sobra para mover también las capas de abajo, que son
las que el preentrenamiento con masked move modeling dejó representando cosas genéricas de una
partida de ajedrez. Moverlas con la señal ruidosa de tres cabezas y 4 000 pasos las aleja de esa
representación sin construir otra mejor —lo que en la literatura se llama olvido catastrófico
cuando es grave y deriva de la representación cuando es leve—, mientras que la adaptación específica
de la tarea vive donde de verdad le toca vivir: en los últimos bloques, que son los que combinan
rasgos en algo parecido a “esta posición vale tanto”. last-n mueve exactamente esa parte y deja
quieta la otra.
Con una salvedad que no se puede saltar: esto es una tirada por modo, con una sola semilla. Las
tres barras del val/loss (0,6310 / 0,6028 / 0,6384) están en el mismo rango que el ruido entre
tiradas que vas a medir dentro de un momento en la curva por etiquetas, así que la ordenación
last-n > full > probe es sugerente, no establecida. Para establecerla harían falta varias
semillas por modo —tres o cinco— y publicar media y dispersión de cada una: si los intervalos se
solapan, lo honesto es decir que no se distinguen. Nueve entrenamientos no cupieron en el
presupuesto del hito; lo que sí cabe es no contar una tirada como si fuera un resultado.
El resultado de la partida sí ordena distinto, y ahí sigue ganando full con 51,4 % frente a
47,8 %: es la única de las tres tareas cuya etiqueta es una propiedad de la partida entera y no de
la posición, así que es la única que necesita que el tronco vaya a buscar información que el
preentrenamiento no dejó legible. Y fíjate en la fila que empeora al descongelar del todo: la
pérdida de la cabeza de errores sube de 0,1270 a 0,1310, con la exactitud clavada en el mismo
96,78 %. Es el aviso de siempre: cuando tres cabezas comparten tronco, una suma ponderada de
pérdidas puede mejorar una tarea a costa de otra, y el titular agregado no te dice cuál. Por eso el
bucle registra las tres por separado.
Y ahora la curva. --curve ejecuta la misma receta cuatro veces, con el 10, 25, 50 y 100 % de las
etiquetas de entrenamiento, sobre subconjuntos anidados, y nombra cada tirada con su porcentaje:
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode last-n --curveSe ejecuta en el modo que mejor salió, last-n, para que la curva mida el valor de las etiquetas y
no el de un modo de afinado de segunda. Salida real (RTX 5090, 2026-09-19); son cuatro
entrenamientos de 4 000 pasos encadenados, uno por porcentaje:
10% of the labels (43809 rows) val/value_mae 0.1217 val/blunder_acc 0.9559 val/result_acc 0.4794 val/loss 0.7185 25% of the labels (109523 rows) val/value_mae 0.1192 val/blunder_acc 0.9678 val/result_acc 0.4711 val/loss 0.6159 50% of the labels (219046 rows) val/value_mae 0.1210 val/blunder_acc 0.9678 val/result_acc 0.4767 val/loss 0.6105 100% of the labels (438093 rows) val/value_mae 0.1215 val/blunder_acc 0.9678 val/result_acc 0.4709 val/loss 0.6133Puesta en tabla, que es como se lee:
| Etiquetas | Filas | val/value_mae |
val/blunder_acc |
val/loss |
|---|---|---|---|---|
| 10 % | 43 809 | 0,1217 | 0,9559 | 0,7185 |
| 25 % | 109 523 | 0,1192 | 0,9678 | 0,6159 |
| 50 % | 219 046 | 0,1210 | 0,9678 | 0,6105 |
| 100 % | 438 093 | 0,1215 | 0,9678 | 0,6133 |
La curva está plana desde el 25 %. Pasar de 109 523 a 438 093 etiquetas —cuadruplicar el gasto en evaluaciones de Stockfish— mueve el error del valor de 0,1192 a 0,1215, que es peor, y deja la exactitud de errores exactamente igual. A partir de unas cien mil etiquetas, más etiquetas no compran nada medible en este montaje. Esa es la pregunta con la que se abrió el módulo —¿cuántas etiquetas hacen falta de verdad?— respondida con datos en vez de con intuición, y la respuesta es que con la cuarta parte del conjunto habría bastado.
Ahora la parte que hay que leer con la misma honestidad. La diferencia entre el mejor y el peor de
esos cuatro puntos es 0,1217 − 0,1192 = 0,0025. Y ahora mira la tirada last-n de más arriba:
misma receta, mismo modo, el mismo 100 % de las etiquetas, y 0,1180 frente a los 0,1215 del
punto del 100 % de la curva. Eso son 0,0035, todavía más que el ancho entero de la curva. Dos
tiradas con los mismos datos se separan más que dos tiradas con cuatro veces más datos. Eso no invalida la curva: la refuerza, porque significa que la única lectura
posible es “plana” y que cualquier ordenación dentro de esos cuatro puntos sería leer ruido. Y es,
en sí misma, la lección que más veces vas a necesitar: antes de explicar una diferencia pequeña,
mide cuánto se mueve tu montaje cuando no cambias nada. Una segunda tirada idéntica es el
experimento más barato que existe y casi nadie lo hace.
Con una excepción que sí supera ese ruido: la exactitud de errores en el 10 %, 0,9559 frente a 0,9678 en los otros tres. Son 1,2 puntos, y los otros tres puntos coinciden hasta la última cifra entre sí y con la tirada suelta, así que es la única diferencia de toda la tabla que no cabe dentro del ruido y la única que se puede afirmar. Tiene sentido: los errores son el 3,72 % de las filas, así que el 10 % del conjunto deja unas 1 600 posiciones con etiqueta positiva, y por debajo de ese orden de magnitud la clase rara empieza a escasear de verdad. El cuello no es el número de filas, es el número de filas de la clase que importa —y esa es otra regla general: en un problema desbalanceado, el tamaño efectivo del conjunto es el de la clase minoritaria.
// Ejercicio 03Lee tu propia curva antes de verla
Dibuja en un papel las tres formas que puede tener la curva de F1 contra número de etiquetas: (a) plana desde el 10 %, (b) subiendo y aplanándose hacia el 50 %, (c) subiendo todavía en el 100 %. Para cada una, escribe qué harías a continuación con un presupuesto limitado. Y añade una cuarta: ¿qué significaría que la curva baje entre el 50 % y el 100 %?
// SoluciónVer la solución
(a) Plana desde el 10 %: las etiquetas no son el cuello de botella. O la tarea es fácil y ya
está resuelta, o el modelo o la representación limitan. Lo siguiente es cambiar el modo de
afinado (probe → full), cambiar la representación de entrada, o revisar si la etiqueta tiene
tanto ruido que más cantidad no añade información. Etiquetar más es lo único que seguro no hay
que hacer.
(b) Subiendo y aplanándose hacia el 50 %: el punto dulce está ahí, y el 100 % solo compra los últimos decimales. Con presupuesto limitado, congelas el tamaño del conjunto y gastas en otra cosa: más modos de afinado, más semillas para tener barras de error, o una segunda representación.
(c) Subiendo en el 100 %: etiquetar más es la inversión más rentable que te queda, y ninguna idea arquitectónica va a competir con eso. Es también la señal de que cualquier comparación entre arquitecturas hecha con estas etiquetas está limitada por los datos y no por el modelo, así que sus conclusiones son provisionales.
Que baje entre el 50 % y el 100 % no puede deberse a “más datos hacen daño”: con subconjuntos
anidados, el conjunto grande contiene al pequeño. Las explicaciones reales son otras. Puede ser
que el número de pasos esté fijo (max_steps: 4000) y con cuatro veces más filas el modelo dé
muchas menos pasadas por cada una: estás comparando dos regímenes de entrenamiento distintos, no
dos cantidades de datos. Puede ser que las filas nuevas sean sistemáticamente distintas —el
orden es un CRC-32, así que no debería—, o simplemente ruido de una sola semilla, que es el
motivo por el que una curva de un único sorteo se lee con prudencia y una diferencia de dos
puntos no se celebra.
Lab 4 · Medir contra la heurística
Antes de nada, la ruta del checkpoint, que no es la que uno escribiría de memoria.
configs/train/encoder-heads.yaml trae run_name: encoder-heads y unique_run_name: true, así que
cada ejecución escribe en un directorio con marca de tiempo: checkpoints/encoder-heads-<fecha>/.
El modo (probe, last-n, full) no aparece en el nombre —lo elige --mode y queda dentro del
checkpoint, no en la carpeta—, así que un checkpoints/encoder-heads-full/ no existe. El último
directorio de una serie sale así, y en los comandos de abajo se escribe checkpoints/encoder-heads/
por brevedad: sustitúyelo por el que te salga aquí.
ls -td checkpoints/encoder-heads-*/ | head -1La suite del encoder se ejecuta sobre un checkpoint de las cabezas, no del preentrenamiento:
uv run rukh eval encoder --model checkpoints/encoder-heads-moves-20260919-095307/best.pt --stage encoderTitular real de artifacts/eval/encoder/report.md en la ejecución de referencia (RTX 5090,
2026-09-19), sobre 10 000 posiciones del conjunto retenido:
Blunder F1, encoder (tuned, p >= 0.128) 17.9 %Blunder F1, encoder (fixed, p >= 0.5) 0.0 %Blunder F1, material baseline 8.9 %Margin over the baseline +9.0 pointsMargin >= 5 points yesBlunder ROC AUC 0.738Blunder average precision 0.109Blunder base rate 3.7 %Value vs tanh(cp / 400), Spearman 0.407Value vs tanh(cp / 400), Pearson 0.442Value correlation >= 0.80 noResult accuracy 49.9 %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.128 3660 144 191 15.7 % 20.8 % 17.9 %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.0040 to 0.2568 (mean 0.0446)Los dos criterios de GOAL.md salen en direcciones opuestas y los dos se publican tal cual: el de
detección de errores se cumple con +9,0 puntos sobre la heurística; el de correlación de valor
no se cumple, con 0,407 de Spearman frente a un listón de 0,80. La sección Cómo se mide y contra qué explica de dónde sale cada número, por qué el 0,0 % de la fila del umbral fijo no es un
modelo roto y qué falló en el valor.
La salida trae, en este orden: cuántas posiciones se evaluaron y cuántas de ellas tenían etiqueta de
error; precisión, exhaustividad y F1 del encoder y de la heurística, en el umbral ajustado y en el
fijo; el margen en puntos de F1 y si llega al listón de cinco; ROC AUC y precisión media, que no
dependen de ningún umbral; Pearson y Spearman del valor contra tanh(cp/400); la exactitud del
resultado; y la curva por etiquetas si el checkpoint la lleva. Escribe además
artifacts/eval/<stage>/report.md y una fila en artifacts/web/results.json, que es la que llega a
la tabla única de esta web con pnpm sync:data. <stage> es lo que pases en --stage; sin esa
opción es el nombre del directorio del checkpoint, o sea encoder-heads-<marca de tiempo>, y
tanto la carpeta del informe como la fila de la tabla cambiarían de nombre en cada ejecución. Por
eso el comando de arriba lo fija en encoder: el informe queda en artifacts/eval/encoder/report.md
y la fila se actualiza en vez de duplicarse.
La heurística es la parte lenta —un tablero de python-chess por fila—, así que sus veredictos van
a una caché SQLite bajo una clave propia: no dependen de los pesos, así que evaluar un segundo
checkpoint sobre las mismas filas no vuelve a pagarlos. Con --no-cache se recalculan.
// Ejercicio 04Si el encoder no bate a la heurística por cinco puntos, ¿qué haces?
GOAL.md pide un margen de cinco puntos de F1. Supón que sale uno. Enumera, en orden de coste, qué
comprobarías antes de tocar el modelo, y di qué no vale hacer.
// SoluciónVer la solución
Lo primero, y es gratis, es mirar la descomposición en vez del titular. ¿El encoder tiene precisión alta y exhaustividad baja, o al revés? Si marca poquísimo, el umbral de 0,5 puede estar mal para una clase rara y mover ese único número puede valer varios puntos de F1 sin tocar un peso. Eso no es hacer trampa siempre que el umbral se elija en validación y se publique.
Lo segundo: ¿cuántas filas tenían etiqueta de error? Si son pocas, el intervalo de confianza del F1 es ancho y un punto de diferencia no significa nada; antes de rediseñar nada hay que saber si la diferencia es medible.
Lo tercero, ya con coste: la curva por etiquetas. Si sigue subiendo en el 100 %, el camino es
etiquetar más, no cambiar el modelo. Después, --mode full si venías de probe, y después
reabrir la representación de entrada, que es el experimento grande del módulo.
Lo que no vale: mover el listón. Tampoco vale cambiar el conjunto de evaluación, ni redefinir
“error” a 150 centipeones porque así sale mejor, ni comparar el encoder sobre unas filas y la
heurística sobre otras. Si después de todo el margen sigue siendo de un punto, la cifra se
publica tal cual y se escribe por qué, que es exactamente lo que GOAL.md manda hacer. Un
resultado negativo documentado vale más que un resultado positivo cocinado, y además es el que
enseña.
Lab 5 · Exportar, embeddings y los datos de la isla
El navegador necesita el encoder en ONNX, y con dos salidas nada más: la demo pinta el valor y la alerta de error, y no tiene ningún uso para los logits del MLM ni para el resultado.
uv run rukh export --ckpt checkpoints/encoder-heads/best.pt --out artifacts/onnx/encoder --kind encoder --fp16 --int8 --check-paritySalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
exporter: dynamo (opset 18)kind: encoder -> value, blundershapes: batch dynamic=True, sequence dynamic=True (verified), block=200fp32: artifacts/onnx/encoder/model.onnx (60735659 bytes)fp16: artifacts/onnx/encoder/model-fp16.onnx (30648768 bytes, 0.50 of fp32)int8: artifacts/onnx/encoder/model-int8.onnx (18254207 bytes, 0.30 of fp32)parity fp32: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 1.073e-06)parity fp16: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 0.0009496)parity int8: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 0.0241)60,7 MB en fp32, 30,6 en fp16, 18,3 en int8, y lo importante: el 100 % de las decisiones de error coinciden con PyTorch en las tres precisiones. El valor se mueve 0,024 como mucho en int8, que en la escala acotada son unos diez centipeones y en la barra de la demo no se ve.
Compáralo con lo que te salió en M2: allí el int8 del decoder cambiaba el 4,6 % de sus jugadas.
Es la misma técnica de cuantización sobre dos modelos del mismo proyecto, con resultados opuestos, y
la diferencia no es suerte. El decoder termina en un argmax sobre 2 030 logits: dos jugadas
razonables están a menudo a milésimas, y un error de redondeo que no cambia la evaluación de nada sí
cambia cuál gana. El encoder termina en dos escalares —un tanh y un sigmoide— y la decisión de
error es un umbral sobre uno de ellos: para que cambie, el ruido tiene que cruzar la frontera, y
0,024 solo la cruza si la probabilidad ya estaba pegada al umbral. La sensibilidad a la
cuantización es una propiedad de la cabeza, no del cuerpo, y esa frase vale para cualquier modelo
que vayas a cuantizar: pregúntate siempre cuántos candidatos compiten en la última operación.
Tres diferencias con la exportación del decoder que hiciste en M2. La primera: el eje de secuencia
depende del esquema, y la salida lo dice. El checkpoint exportado aquí es el de jugadas, cuya
secuencia crece con la partida, y por eso anuncia sequence dynamic=True (verified); el de
casillas siempre tiene 69 tokens, así que ese eje se fija y --seq-len no pinta nada. No es un
detalle cosmético: un eje que se declara dinámico y no lo es se convierte en un error en el
navegador la primera vez que llega una secuencia de otro tamaño. La segunda: la paridad no se mide sobre la jugada
elegida sino sobre las dos salidas —la coincidencia de la decisión de error y la diferencia
máxima absoluta del valor—, que es lo que de verdad ve el usuario. La tercera: los metadatos
rukh_* que viajan dentro del fichero llevan además rukh_kind=encoder y rukh_heads, de forma
que un .onnx suelto sigue sabiendo decir qué es y qué devuelve.
La salida secundaria del encoder son los embeddings de posición, que no usa la demo pero sí la fase 2 del curso, cuando montes recuperación de posiciones parecidas:
uv run rukh encoder embed --positions data/evals/positions-eval.parquet --out artifacts/embeddings/positions.npy --ckpt checkpoints/encoder-heads/best.ptSalida real, con el checkpoint del esquema de casillas:
wrote 488159 embeddings of 384 dimensions to artifacts\embeddings\positions.npypositions: 488159dim: 384 (mean pooling, squares scheme)array: artifacts/embeddings/positions.npysidecar: artifacts/embeddings/positions.parquet (row, fen4)Esos 488 159 vectores de 384 dimensiones son exactamente el índice sobre el que buscará la recuperación de posiciones parecidas de A1, en la fase 2: cuando allí preguntes “posiciones como esta”, lo que se compara es un vector de esta matriz contra todos los demás.
Fíjate en que escribe dos ficheros: la matriz .npy y un .parquet de acompañamiento con row
y fen4 en el mismo orden. Una matriz de flotantes sin saber qué fila es qué posición no sirve para
nada, y un .npy no tiene sitio donde decirlo. Es una regla general de los artefactos numéricos:
el índice viaja con los datos o los datos no existen.
Y queda el script que alimenta la isla de esta página. Guarda esto como
labs/m3/value_bar_export.py en el repo rukh: recorre una partida, evalúa cada posición con
las cabezas del encoder y con Stockfish, y escribe artifacts/web/value-bar.json con el esquema
rukh-value-bar/1 documentado en rukh-lab/src/data/README.md.
"""Export one game's encoder and Stockfish evaluations to artifacts/web/value-bar.json."""
import jsonimport mathfrom datetime import UTC, datetimefrom pathlib import Path
import chessimport chess.engineimport torch
from rukh.engine import find_stockfishfrom rukh.models.squares import fen_to_tokensfrom rukh.train import load_any
CKPT = Path("checkpoints/encoder-heads/best.pt") # el directorio real lleva marca de tiempoOUT = Path("artifacts/web/value-bar.json")VALUE_SCALE = 400.0 # the tanh(cp / 400) of rukh.data.labels: both curves must share itTHRESHOLD = 0.5 # the blunder threshold of configs/eval/encoder.yamlDEPTH = 12 # Stockfish depth; deeper is slower and barely moves the curve at club level# Byrne-Fischer, New York 1956: the Game of the Century, where 17...Be6 offers the queen.MOVES = ( "g1f3 g8f6 c2c4 g7g6 b1c3 f8g7 d2d4 e8g8 c1f4 d7d5 d1b3 d5c4 b3c4 c7c6 e2e4 b8d7 " "a1d1 d7b6 c4c5 c8g4 f4g5 b6a4 c5a3 a4c3 b2c3 f6e4 g5e7 d8b6 f1c4 e4c3 e7c5 f8e8 " "e1f1 g4e6").split()
model, _payload, kind = load_any(CKPT)if kind != "encoder": raise SystemExit(f"{CKPT} is a {kind} checkpoint; the value bar needs the encoder heads")model.eval()
engine_path = find_stockfish()if engine_path is None: raise SystemExit("Stockfish not found: the second curve is the whole point of this figure")
board = chess.Board()san: list[str] = []fens: list[str] = []for uci in MOVES: # fail loudly if the line is not legal move = chess.Move.from_uci(uci) san.append(board.san(move)) board.push(move) fens.append(board.fen())
idx = torch.tensor([fen_to_tokens(fen) for fen in fens], dtype=torch.long)with torch.no_grad(): outputs = model(idx)values = outputs["value"].float().tolist()blunders = torch.sigmoid(outputs["blunder"].float()).tolist()
stockfish: list[float | None] = []with chess.engine.SimpleEngine.popen_uci(str(engine_path)) as engine: for fen in fens: info = engine.analyse(chess.Board(fen), chess.engine.Limit(depth=DEPTH)) score = info["score"].white() cp = score.score(mate_score=10000) stockfish.append(None if cp is None else math.tanh(cp / VALUE_SCALE))
OUT.parent.mkdir(parents=True, exist_ok=True)OUT.write_text( json.dumps( { "schema": "rukh-value-bar/1", "game": {"moves": MOVES, "san": san}, "series": [ { "ply": ply + 1, "encoder": round(values[ply], 4), "stockfish": None if stockfish[ply] is None else round(stockfish[ply], 4), "blunder": blunders[ply] > THRESHOLD, } for ply in range(len(MOVES)) ], "meta": { "model": "rukh-encoder", "checkpoint": str(CKPT), "white": "Byrne, D.", "black": "Fischer, R.", "event": "New York, 1956", "result": "0-1", "value_scale": VALUE_SCALE, "threshold": THRESHOLD, "generated": datetime.now(UTC).isoformat(timespec="seconds"), }, } ), encoding="utf-8",)print(f"wrote {OUT} ({len(MOVES)} plies, depth {DEPTH})")uv run python labs/m3/value_bar_export.pySalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
wrote artifacts/web/value-bar.json (34 plies, depth 12)El fichero llega a esta web con pnpm sync:data, igual que los dos de M2. La figura que hay más
abajo es exactamente ese JSON, generado con el checkpoint de casillas
(checkpoints/encoder-heads-20260919-100532/best.pt): el script tokeniza un FEN con
fen_to_tokens, así que el esquema de jugadas no le sirve tal cual.
Y hay una cosa que mirar en el fichero antes de mirar el gráfico: ningún ply sale marcado como
error. No es un fallo del script ni del modelo. El THRESHOLD = 0.5 que tiene escrito es el de
configs/eval/encoder.yaml, y ya sabes por la evaluación que las probabilidades de esta cabeza no
pasan de 0,19: en 0,5 no se dispara nunca. La figura enseña, sin querer, la misma lección que la
tabla de puntos de operación, y por eso se publica así en vez de bajarle el umbral a mano hasta que
salgan verticales bonitas. Cambiar THRESHOLD al 0,04459 que eligió la mitad tune es el lab que
te llevas a casa.
// Ejercicio 05¿Por qué la partida del script termina en 17…Ae6 y no sigue?
El script se para justo en la jugada del sacrificio. Explica qué se vería si continuara hasta el final de la partida, y por qué para esta figura concreta cortar ahí enseña más. Después piensa en el problema técnico: ¿qué le pasaría al eje vertical si la partida llegara al mate?
// SoluciónVer la solución
Si continuara, las dos curvas se separarían cada vez más en la dirección de las negras hasta pegarse al suelo, porque la partida se decide. La figura pasaría a estar dominada por un tramo final en el que las dos fuentes están de acuerdo y no hay nada que mirar, y el punto interesante —el ply donde la heurística, y quizá también el encoder, llaman error a una jugada genial— quedaría aplastado en un rincón del eje horizontal. Cortar en el sacrificio deja la figura centrada en la pregunta del módulo.
Lo técnico: la escala es tanh(cp/400) y un mate se convierte con mate_score=10000, así que
vale exactamente ±1 y se pega al borde del eje. Como el eje está fijado a [-1, 1] en vez
de ajustarse a los datos, el mate no deforma nada: se dibuja donde le toca. Un eje ajustado
automáticamente sí sería un problema, porque un solo mate al final comprimiría todo el resto de
la partida contra la línea del cero y una partida tranquila parecería un electrocardiograma. Es
la razón por la que la isla no ajusta su eje: las dos series están acotadas por construcción, y
ajustar una escala ya acotada solo sirve para exagerar.
Ver las dos evaluaciones: ValueBar
Al terminar esta sección sabrás mirar dos curvas de evaluación juntas y distinguir dónde el modelo coincide con el motor, dónde se separa por una razón entendible y dónde simplemente se equivoca.
La isla de abajo recorre una partida ply a ply. La línea continua es el valor del encoder; la de
trazos, el cp de Stockfish convertido a la misma escala acotada. Las dos miran desde el punto de
vista de las blancas: por encima del cero, ventaja blanca; por debajo, ventaja negra. El deslizador
elige una media jugada, la barra de arriba muestra su valor, y el texto de debajo dice de quién es
la ventaja con palabras, porque el signo de una evaluación no puede depender de distinguir dos
colores. Las verticales punteadas son los plies que la cabeza de error marca, y están además
enumerados en texto justo debajo del deslizador.
Cuatro cosas concretas que mirar, en este orden:
- Dónde se separan las dos líneas. Mientras van juntas, el encoder está haciendo el trabajo de un motor con una sola pasada hacia delante, que ya es un resultado. Las separaciones son lo interesante, y casi siempre ocurren en el mismo tipo de posición: cuando la ventaja es táctica y depende de una secuencia forzada. El encoder no busca; evalúa de un vistazo. Todo lo que exija calcular tres jugadas por delante es, por construcción, su punto ciego.
- Si el encoder es sistemáticamente más plano. Es lo esperable: un
tanhentrenado con error cuadrático tiende a la media, así que las ventajas grandes se quedan cortas. Si lo ves, mira Pearson y Spearman de la evaluación: es exactamente el caso en el que Spearman es alto y Pearson más bajo, y verlo en una partida concreta es la mejor manera de entender esas dos cifras. - Qué plies marca como error: ninguno. No hay una sola vertical punteada en toda la partida, y
ya sabes por qué: el script escribe la marca con el umbral de fábrica de 0,5 y las
probabilidades de esta cabeza no llegan a 0,20. La figura acaba siendo la mejor ilustración
posible de la sección de métricas, porque aquí no hay tabla que te consuele: el punto de
operación equivocado convierte a un detector con 0,696 de ROC AUC en un detector que no detecta
nada. Si quieres ver las verticales, cambia
THRESHOLDa 0,04459 —el que eligió la mitadtune— y vuelve a ejecutar el script. - El sacrificio, y el escalón que sí está. La partida termina en 17…Ae6, la jugada que la
heurística de material llama error garrafal. Mira la curva de trazos en el ply 22: Stockfish
pasa de −0,015 a −0,52 y se queda ahí el resto de la partida, porque ahí es donde la partida
se decide a favor de las negras. Y ahora mira la línea continua: el encoder se mueve entre 0,00
y 0,16 de principio a fin, siempre del lado de las blancas, y no registra el escalón. Ese
dibujo es 0,42 de Spearman con los ojos, y es mucho más elocuente que el número: el modelo no
está mintiendo, está aplastado contra el cero, que es exactamente lo que predice el diagnóstico
de
tanh(cp/400)sobre pocas etiquetas.
tanh(cp/400). Línea continua, el encoder; línea de trazos, Stockfish. Las verticales punteadas son los plies que la cabeza blunder marca como error, y están enumerados en texto debajo del deslizador. encoder (cabeza de valor) Stockfish (cp convertido a la misma escala) error marcado por el encoder
1. Nf3 (g1f3, ply 1): posición igualada, +0,01 según el encoder y +0,05 según Stockfish. El encoder no marca esta jugada como error.
- Ply
- 1
- Encoder
- +0,01
- Stockfish
- +0,05
- Diferencia
- −0,04
El encoder no marca ninguna jugada de esta partida como error.
Qué has aprendido, cómo se mide
Has escrito un encoder bidireccional que es, literalmente, el modelo de M2 con un booleano cambiado,
y sabes explicar por qué ese booleano lo cambia todo: qué tarea habilita, qué tarea imposibilita y
por qué la máscara es una consecuencia del objetivo y no al revés. Lo has preentrenado con masked
move modeling sabiendo defender cada número de la receta —el 15 %, las tres ramas del 80/10/10, los
tokens de control intocables y el -100 que no es un 0—, has reducido sus salidas a un vector con
un pooling que ignora el relleno por un motivo que puedes explicar en una frase, y has montado
encima tres cabezas lineales precisamente porque son demasiado tontas para hacer trampa. Ese
preentrenamiento acertó el 75,2 % de las jugadas tapadas en dieciséis minutos, y la comparación
entre las dos entradas —la pregunta abierta con la que empezó el módulo— tiene ya una respuesta que
no es la que nadie habría apostado entera: ver el tablero es mejor para cuánto vale esto, ver la
línea es mejor para qué se acaba de tirar.
Y te llevas el resultado incómodo, que es el que más enseña: de los dos criterios de GOAL.md, el
de detección de errores se cumple con +9,0 puntos sobre la heurística y el de correlación de
valor no se cumple, con 0,42 de Spearman frente a un listón de 0,80. Los dos se publican igual.
Te llevas también dos resultados que contradicen el instinto y que son el motivo de existir del módulo. Uno: afinar menos red salió mejor que afinarla entera —0,1180 de error de valor con los dos últimos bloques frente a 0,1354 con los quince millones de parámetros—, porque descongelarlo todo con pocas etiquetas aleja las capas de abajo de la representación que el preentrenamiento había construido. Dos: la curva por etiquetas es plana desde el 25 %, así que las tres cuartas partes del conjunto no compraron nada, y la hipótesis de “faltan etiquetas” que parecía obvia queda descartada por medida y no por opinión. Y la coda que amarra las dos: la dispersión entre esos puntos es del tamaño del ruido entre dos tiradas idénticas, así que las dos conclusiones se enuncian como lo que son —una ordenación sugerente y una meseta clara— y no como lo que gustaría.
Y has aprendido a medir. Contra una línea base deliberadamente mala, sobre las mismas filas, con métricas que no se dejan engañar por una clase rara y con un reparto por partida que cierra la fuga que este módulo tenía servida en bandeja. Si de todo el módulo solo te quedas con una cosa, que sea esta: con una tasa base del 3,7 %, la exactitud del 96,78 % no dice nada y el F1 de 0,0000 en el umbral de 0,5 tampoco; el mismo modelo tiene 0,738 de ROC AUC, y el número publicable sale de elegir el umbral en unas filas y puntuar en otras. No es una particularidad del ajedrez: es lo que hay que hacer con cualquier detector de algo raro.
Cómo se mide el módulo 3. Todo lo que sigue es un número o un test:
- F1 de detección de errores, del encoder y de la heurística de material sobre las mismas filas
de validación, con su precisión y su exhaustividad al lado, en el umbral ajustado y en el fijo, y
con ROC AUC y precisión media, que no dependen de ningún umbral. El umbral se elige en una mitad
tuney el F1 se mide en la mitadscore, partidas porgame_id. Objetivo deGOAL.md: al menos cinco puntos de margen. Medido: 0,179 contra 0,089, +9,0 puntos. Se cumple. - Correlación del valor con
tanh(cp/400), Pearson y Spearman, esta última como titular. Objetivo: ≥ 0,80. Medido: 0,407 con la entrada por jugadas y 0,422 con la de casillas. No se cumple, y el diagnóstico —pocos pasos de afinado, 9,8 % de cobertura de etiquetas y una escala comprimida— queda documentado en vez de disimulado. - Exactitud del resultado sobre las tres clases, con la advertencia de que su techo es bajo por construcción. Medido: 51,4 % en validación con el modelo entero descongelado, 49,9 % en la evaluación sobre las 10 000 posiciones retenidas.
- Curva por número de etiquetas al 10, 25, 50 y 100 %, sobre subconjuntos anidados, en modo
last-n. Medida: plana desde el 25 % —error del valor 0,1217 / 0,1192 / 0,1210 / 0,1215—, así que más allá de unas cien mil etiquetas más evaluaciones de Stockfish no compran nada medible. La única diferencia que supera el ruido entre tiradas es la exactitud de errores en el 10 % (0,9559 frente a 0,9678), que es lo que pasa cuando la clase rara se queda en unas 1 600 filas. - Modo de afinado, los tres a 4 000 pasos sobre las mismas etiquetas. Medido:
last-ngana con 0,1180 de error de valor, frente a 0,1354 defully 0,1429 deprobe;fullsolo gana en la exactitud del resultado (51,4 %). Es una tirada por modo, así que la ordenación es sugerente y no establecida: para cerrarla harían falta varias semillas por modo. - Paridad ONNX de las dos salidas que usa la demo: coincidencia de la decisión de error y diferencia máxima del valor, para fp32, fp16 e int8. Medido: 100 % en las tres, con 60,7 / 30,6 / 18,3 MB y un desvío máximo del valor de 0,024 en int8. El contraste con el decoder de M2, cuyo int8 cambiaba el 4,6 % de las jugadas, es una de las cosas que conviene recordar del módulo.
- Tests unitarios que se quedan para siempre: que el modelo no es causal, que el relleno no
influye en los tokens reales, que
pool("mean")lo ignora, que un FEN va y vuelve casilla a casilla, que las proporciones 80/10/10 se cumplen sobre diez mil tokens, que los tokens de control nunca se tapan, que el modoprobedeja el encoder idéntico bit a bit, que ningúngame_idcruza el reparto y que el F1 calculado a mano sobre un caso pequeño coincide con el del código. - Reproducibilidad: el hash del vocabulario de casillas fijado en un test, la semilla del enmascarado en la configuración, el reparto como función pura del id y de la semilla, y cada tirada en MLflowMLflowRegistro de experimentos: cada entrenamiento guarda su configuración, sus métricas por paso y sus artefactos en una base SQLite local (`rukh mlflow ui` la abre en el navegador). Las model cards del curso se generan desde ahí para que ningún número se escriba a mano. con sus curvas.
Lo siguiente es M4, donde vuelves al decoder de M2 y le enseñas a jugar como un 1500 o como un 2200 cambiando dos tokens del principio, con LoRA para no tener que mover treinta y nueve millones de pesos cada vez. El encoder que acabas de construir no se queda ahí parado: su barra sigue en la demo, sus embeddings son la entrada de la recuperación de posiciones de la fase 2, y su probe linealProbe linealCongelar un modelo y entrenar encima solo una capa lineal para una tarea nueva. Si la capa lineal acierta, la información ya estaba en la representación y el preentrenamiento la puso ahí; si no acierta, no se puede concluir que no esté, solo que no está de forma linealmente legible. En Rukh es el modo `probe` de `rukh train heads`, y un test comprueba que los pesos del encoder salen idénticos bit a bit. es la misma técnica con la que en M2 leíste que se puede extraer el tablero de las activaciones de un modelo de ajedrez —solo que ahora la has montado tú sobre tu propio modelo en vez de citarla de un artículo.
La cheatsheet del módulo, diez preguntas con su respuesta corta, está justo debajo.
// cheatsheet M3
Ocho 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,0040 a 0,2568, 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,738 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,128) y publicar el F1 medido en la mitad `score` (aquí 0,179), 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 hito se queda en 0,407 con la entrada por jugadas y 0,422 con la de casillas: criterio no cumplido**, publicado sin cumplir y con el diagnóstico al lado.
- 08¿Qué es una línea base y por qué la de M3 es deliberadamente tonta?
- El rival contra el que se mide el modelo para saber si su número significa algo. La de M3 cuenta material (peón 1, caballo y alfil 3, torre 5, dama 9), le suma movilidad y llama error a toda jugada que pierda un punto neto tras la captura más rentable del rival a un ply. Es tonta a propósito: una línea base que ya entendiera los sacrificios no sería un suelo, sería un competidor, y el margen dejaría de decir «el modelo aprendió algo más que contar piezas». Lo esencial es medirla exactamente igual que al modelo: mismas filas, mismas etiquetas, misma definición de acierto. Medido: la heurística saca 8,9 % de F1 y el encoder 17,9 %, **+9,0 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 12 capas, d=512 y 38 971 392) 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.