rukh · lab

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

  • 360 min
  • nivel base
  • vigente
  • actualizado el19 de septiembre de 2026

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, y src/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 —value y blunder— 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.
modelchorcat/rukh-encoder

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ás
layers.Block(d_model, n_head, ff, dropout, causal=True)
# encoder.py: cada posición mira toda la secuencia
layers.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 712
embedding de tokens 2030 · 384 = 779 520
posiciones 200 · 384 = 76 800
LayerNorm final 2 · 384 = 768
cabeza MMM atada al embedding = 0
----------
total = 15 052 800

La ú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 tanh
class BlunderHead(nn.Module): # un logit, no una probabilidad
class ResultHead(nn.Module): # tres clases: blancas, tablas, negras

Si 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)
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:

  1. Parte las filas etiquetadas del conjunto retenido en dos mitades, por game_id y 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 mitad tune y 3 660 filas / 2 368 partidas en la mitad score.
  2. En la mitad tune, barre el umbral y quédate con el que maximiza F1. Aquí sale 0,128.
  3. 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”:

  1. 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ó.
  2. 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.
  3. 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í el tanh las 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:

labs/m3/bidirectional.py
"""Show that the encoder is not causal, and that the masking recipe does what it claims."""
import torch
from rukh.models import EncoderConfig, PositionEncoder
from rukh.models.encoder import MMM_IGNORE_INDEX
from rukh.train.mmm import MaskingConfig, apply_masking, control_ids, mask_id, masking_generator
torch.manual_seed(0)
cfg = EncoderConfig(n_layer=2, n_head=2, d_model=64, block=16)
model = PositionEncoder(cfg).eval()
print(f"preset parameters: {PositionEncoder(EncoderConfig()).num_params(False):,}")
# 1. Bidirectionality: change the LAST token and watch the FIRST hidden state move.
idx = torch.randint(100, cfg.vocab_size, (1, 8))
with torch.no_grad():
before = model(idx)[0, 0].clone()
idx[0, -1] = (idx[0, -1] + 1) % cfg.vocab_size
after = model(idx)[0, 0]
delta = (before - after).abs().max().item()
print(f"max |delta| on the FIRST token after changing the LAST one: {delta:.6f}")
print("causal" if delta == 0.0 else "bidirectional: the future reaches the past")
# 2. Padding: the real tokens of a sequence must not depend on how much padding travels with it.
short = torch.tensor([[7, 8, 9, 0, 0, 0]])
tight = torch.tensor([[7, 8, 9]])
with torch.no_grad():
padded = model(short, PositionEncoder.padding_mask(short))[0, :3]
exact = model(tight, PositionEncoder.padding_mask(tight))[0]
print(f"max |delta| between padded and tight: {(padded - exact).abs().max().item():.6f}")
# 3. The 80/10/10 recipe, counted over 10 000 tokens instead of trusted.
masking = MaskingConfig()
batch = torch.randint(max(control_ids("moves")) + 1, cfg.vocab_size, (100, 100))
inputs, labels = apply_masking(batch, masking, cfg.vocab_size, "moves", masking_generator(0))
selected = labels != MMM_IGNORE_INDEX
total = int(selected.sum())
masked = int((inputs[selected] == mask_id("moves")).sum())
kept = int((inputs[selected] == batch[selected]).sum())
print(f"selected {total / batch.numel():.3%} of the tokens (target 15 %)")
print(f" <mask> {masked / total:.1%} kept {kept / total:.1%} random {1 - (masked + kept) / total:.1%}")
Terminal
uv run python labs/m3/bidirectional.py

Salida real de la ejecución de referencia (RTX 5090, 2026-09-19):

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

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:

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

Salida real de la ejecución de referencia (RTX 5090, 2026-09-19). 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.7396
step 10000 val/loss 1.0456 val/top1 0.7430
step 10500 val/loss 1.0291 val/top1 0.7467
step 11000 val/loss 1.0195 val/top1 0.7483
step 11500 val/loss 1.0106 val/top1 0.7507
step 12000 val/loss 1.0032 val/top1 0.7518
input: moves
masking: 15% at 80%/10%/10%
steps: 12000
checkpoint: checkpoints/encoder-mmm-20260919-093554/step-12000.pt

75,2 % de acierto en las jugadas tapadas. 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:

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

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

La 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.

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

Salida real (RTX 5090, 2026-09-19), sobre las mismas 438 093 filas y los mismos 4 000 pasos:

mode: last-n
100% of the labels (438093 rows)
val/value_loss 0.0396
val/blunder_loss 0.1270
val/result_loss 0.8723
val/loss 0.6028
val/value_mae 0.1180
val/blunder_acc 0.9678
val/result_acc 0.4775
Terminal
uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode full

Salida real de la ejecución de referencia (RTX 5090, 2026-09-19), sobre las mismas 438 093 filas:

mode: full
100% of the labels (438093 rows)
val/value_loss 0.0578
val/blunder_loss 0.1310
val/result_loss 0.8991
val/loss 0.6384
val/value_mae 0.1354
val/blunder_acc 0.9678
val/result_acc 0.5142

Compara las tres columnas, que es todo el punto del lab:

Métrica probe (tronco congelado) last-n (2 bloques + norma) full (todo el modelo)
val/value_mae 0,1429 0,1180 0,1354
val/blunder_acc 0,9678 0,9678 0,9678
val/result_acc 0,4586 0,4775 0,5142
val/blunder_loss 0,1285 0,1270 0,1310
val/loss 0,6310 0,6028 0,6384

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:

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

Se 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.6133

Puesta 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 (probefull), 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í.

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

La suite del encoder se ejecuta sobre un checkpoint de las cabezas, no del preentrenamiento:

Terminal
uv run rukh eval encoder --model checkpoints/encoder-heads-moves-20260919-095307/best.pt --stage encoder

Titular real de artifacts/eval/encoder/report.md en la ejecución de referencia (RTX 5090, 2026-09-19), sobre 10 000 posiciones del conjunto 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 points
Margin >= 5 points yes
Blunder ROC AUC 0.738
Blunder average precision 0.109
Blunder base rate 3.7 %
Value vs tanh(cp / 400), Spearman 0.407
Value vs tanh(cp / 400), Pearson 0.442
Value correlation >= 0.80 no
Result 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 F1
encoder 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.

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

Salida real de la ejecución de referencia (RTX 5090, 2026-09-19):

exporter: dynamo (opset 18)
kind: encoder -> value, blunder
shapes: batch dynamic=True, sequence dynamic=True (verified), block=200
fp32: artifacts/onnx/encoder/model.onnx (60735659 bytes)
fp16: artifacts/onnx/encoder/model-fp16.onnx (30648768 bytes, 0.50 of fp32)
int8: artifacts/onnx/encoder/model-int8.onnx (18254207 bytes, 0.30 of fp32)
parity fp32: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 1.073e-06)
parity fp16: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 0.0009496)
parity int8: 1.0000 of the blunder decisions on 1000 positions (max |delta value| 0.0241)

60,7 MB en fp32, 30,6 en fp16, 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:

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

Salida real, con el checkpoint del esquema de casillas:

wrote 488159 embeddings of 384 dimensions to artifacts\embeddings\positions.npy
positions: 488159
dim: 384 (mean pooling, squares scheme)
array: artifacts/embeddings/positions.npy
sidecar: artifacts/embeddings/positions.parquet (row, fen4)

Esos 488 159 vectores de 384 dimensiones son 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.

labs/m3/value_bar_export.py
"""Export one game's encoder and Stockfish evaluations to artifacts/web/value-bar.json."""
import json
import math
from datetime import UTC, datetime
from pathlib import Path
import chess
import chess.engine
import torch
from rukh.engine import find_stockfish
from rukh.models.squares import fen_to_tokens
from rukh.train import load_any
CKPT = Path("checkpoints/encoder-heads/best.pt") # el directorio real lleva marca de tiempo
OUT = Path("artifacts/web/value-bar.json")
VALUE_SCALE = 400.0 # the tanh(cp / 400) of rukh.data.labels: both curves must share it
THRESHOLD = 0.5 # the blunder threshold of configs/eval/encoder.yaml
DEPTH = 12 # Stockfish depth; deeper is slower and barely moves the curve at club level
# Byrne-Fischer, New York 1956: the Game of the Century, where 17...Be6 offers the queen.
MOVES = (
"g1f3 g8f6 c2c4 g7g6 b1c3 f8g7 d2d4 e8g8 c1f4 d7d5 d1b3 d5c4 b3c4 c7c6 e2e4 b8d7 "
"a1d1 d7b6 c4c5 c8g4 f4g5 b6a4 c5a3 a4c3 b2c3 f6e4 g5e7 d8b6 f1c4 e4c3 e7c5 f8e8 "
"e1f1 g4e6"
).split()
model, _payload, kind = load_any(CKPT)
if kind != "encoder":
raise SystemExit(f"{CKPT} is a {kind} checkpoint; the value bar needs the encoder heads")
model.eval()
engine_path = find_stockfish()
if engine_path is None:
raise SystemExit("Stockfish not found: the second curve is the whole point of this figure")
board = chess.Board()
san: list[str] = []
fens: list[str] = []
for uci in MOVES: # fail loudly if the line is not legal
move = chess.Move.from_uci(uci)
san.append(board.san(move))
board.push(move)
fens.append(board.fen())
idx = torch.tensor([fen_to_tokens(fen) for fen in fens], dtype=torch.long)
with torch.no_grad():
outputs = model(idx)
values = outputs["value"].float().tolist()
blunders = torch.sigmoid(outputs["blunder"].float()).tolist()
stockfish: list[float | None] = []
with chess.engine.SimpleEngine.popen_uci(str(engine_path)) as engine:
for fen in fens:
info = engine.analyse(chess.Board(fen), chess.engine.Limit(depth=DEPTH))
score = info["score"].white()
cp = score.score(mate_score=10000)
stockfish.append(None if cp is None else math.tanh(cp / VALUE_SCALE))
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps(
{
"schema": "rukh-value-bar/1",
"game": {"moves": MOVES, "san": san},
"series": [
{
"ply": ply + 1,
"encoder": round(values[ply], 4),
"stockfish": None if stockfish[ply] is None else round(stockfish[ply], 4),
"blunder": blunders[ply] > THRESHOLD,
}
for ply in range(len(MOVES))
],
"meta": {
"model": "rukh-encoder",
"checkpoint": str(CKPT),
"white": "Byrne, D.",
"black": "Fischer, R.",
"event": "New York, 1956",
"result": "0-1",
"value_scale": VALUE_SCALE,
"threshold": THRESHOLD,
"generated": datetime.now(UTC).isoformat(timespec="seconds"),
},
}
),
encoding="utf-8",
)
print(f"wrote {OUT} ({len(MOVES)} plies, depth {DEPTH})")
Terminal
uv run python labs/m3/value_bar_export.py

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

  1. 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.
  2. Si el encoder es sistemáticamente más plano. Es lo esperable: un tanh entrenado 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.
  3. 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 THRESHOLD a 0,04459 —el que eligió la mitad tune— y vuelve a ejecutar el script.
  4. 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.

← negrasigualadablancas →

Valor de la posición según el encoder y según Stockfish, ply a ply34 medias jugadas en la escala acotada tanh(cp/400), de −1 (ganan las negras) a +1 (ganan las blancas). Los valores exactos del ply seleccionado están en la lista que sigue al gráfico, y los plies marcados como error están enumerados debajo.+10−1ply 1ply 34
Eje vertical: valor de la posición desde el punto de vista de las blancas, en la escala acotada tanh(cp/400). Línea continua, el encoder; línea de trazos, Stockfish. Las verticales punteadas son los plies que la cabeza blunder marca como error, y están enumerados en texto debajo del deslizador.

encoder (cabeza de valor) Stockfish (cp convertido a la misma escala) error marcado por el encoder

1. Nf3 (g1f3, ply 1): posición igualada, +0,01 según el encoder y +0,05 según Stockfish. El encoder no marca esta jugada como error.

Ply
1
Encoder
+0,01
Stockfish
+0,05
Diferencia
−0,04

El encoder no marca ninguna jugada de esta partida como error.

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

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

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 tune y el F1 se mide en la mitad score, partidas por game_id. Objetivo de GOAL.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-n gana con 0,1180 de error de valor, frente a 0,1354 de full y 0,1429 de probe; full solo 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 modo probe deja el encoder idéntico bit a bit, que ningún game_id cruza 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.
Todas las cheatsheets, imprimibles →