rukh · lab

// M2 · lección 01

El decoder: un GPT que juega al ajedrez

El módulo central del curso: embeddings, atención con máscara causal, multi-cabeza, bloques pre-norm y tied embeddings escritos a mano, la receta de entrenamiento que hace que 39 millones de parámetros converjan en una sola GPU, el harness que mide si el modelo entendió el tablero y la exportación a ONNX que lo pone a jugar en el navegador.

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

Qué vas a construir

Al terminar esta sección sabrás exactamente qué objeto vas a escribir, cuánto ocupa, cuánto tarda en entrenarse y dónde acaba.

Vas a construir un GPT de 38 971 392 parámetros —treinta y nueve millones— que juega al ajedrez. Escrito a mano, sin transformers, sin nn.TransformerDecoder: doce bloques, ocho cabezas, 512 dimensiones, un vocabulario de 2 030 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`. y una ventana de contextoVentana de contextoNúmero máximo de tokens que el modelo puede ver a la vez; en Rukh, 200 (el bloque del dataloader). Lo que no cabe se trunca. Como la atención cuesta el cuadrado de la longitud, una ventana cuatro veces mayor es dieciséis veces más cara, y por eso importa que el 95 % de las partidas quepan en 200 tokens UCI. de 200 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.. Se entrena desde cero sobre los lotes que dejaste preparados en M1, en tu GPU, en menos de una hora. Y no acaba en un fichero .pt olvidado en una carpeta: acaba exportado a 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., cuantizado, publicado en Hugging Face y jugando contra ti en una pestaña del navegador, sin servidor.

Este es el módulo central del curso. Todo lo anterior existía para llegar aquí y todo lo posterior modifica esto. El encoder de M3 es este mismo bloque sin la máscara causalMáscara causalRestricción que impide a cada posición mirar hacia delante: antes del softmax, los pesos de las claves futuras se ponen a menos infinito. Sin ella el modelo vería la jugada que tiene que predecir y la pérdida bajaría sin que aprendiese nada. En Rukh la aplica `F.scaled_dot_product_attention(..., is_causal=True)`.. El fine-tuning de M4 son estos mismos pesos con un gradiente más pequeño. El DPO de M5 es esta misma red con otra pérdida. Si entiendes de verdad lo que hay dentro de MoveDecoder, el resto del curso es vocabulario nuevo sobre una idea que ya tienes; si lo sobrevuelas, cada módulo siguiente será un truco que funciona por razones que no sabes.

Al terminar tendrás:

  • src/rukh/models/decoder.py: el modelo completo en unas doscientas líneas, con tests que comprueban formas, causalidad, determinismo y número de parámetros. Es código que puedes leer entero en una sentada, y esa es la razón de escribirlo a mano en lugar de importarlo.
  • Dos modelos entrenados: tiny (6 capas, 256 dimensiones, 5 309 952 parámetros, 189 segundos medidos) para iterar sin miedo, y small (el de 39 millones, 42 minutos) como modelo del curso.
  • Un harness de evaluación que produce una fila de la tabla única: legalidad sin máscara, top-1 y top-3, puzles por tramo, Elo estimadoElo estimadoFuerza del modelo obtenida ajustando la fórmula logística del Elo a los resultados de partidas contra Stockfish limitado a varios niveles. No es un Elo de la FIDE ni de Lichess: es el número que mejor explica esos resultados, y solo significa algo acompañado de su intervalo de confianza y de la lista de rivales. contra Stockfish con intervalo de confianza, Δcp y diversidad.
  • El modelo en ONNX, en fp16 y en int8, con una prueba de paridad que mide si el fichero que descarga el navegador elige la misma jugada que PyTorch. El listón era el 99,9 % de las posiciones y no se alcanza: 99,80 % en fp16 y 95,40 % en int8.
  • Dos visualizaciones en esta misma página: el mapa 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í. de una partida y la repetición del entrenamiento checkpointCheckpointFotografía de un entrenamiento guardada en disco: pesos, estado del optimizador, paso alcanzado, configuración y procedencia (hash del vocabulario, del manifiesto de datos y SHA de git). Sirve para reanudar, para evaluar y para publicar; en Rukh se escribe uno cada 1 000 pasos más el mejor por pérdida de validación. a checkpoint.

Las cifras medidas de esta lección —curvas, legalidad, top-1, puzles, Elo, tamaños de fichero y paridad— son las del entrenamiento real de small en la máquina de referencia (RTX 5090, 19 de septiembre de 2026), y las dos islas del final dibujan esa misma tirada: TrainingReplay recorre los 20 checkpoints del run small-20260919-062911 y AttentionMap dibuja las 12 capas × 8 cabezas exportadas de su best.pt. Las salidas de consola están pegadas tal cual, con una sola excepción declarada donde ocurre: la del entrenamiento de tiny, que no se capturó y cuyos números se leen de MLflow. Las cifras de parámetros, dimensiones y la aritmética de la atención no son medidas: son aritmética, y puedes comprobarlas con una calculadora.

Teoría justa

Al terminar esta sección podrás dibujar el modelo entero de memoria, explicar por qué cada pieza está donde está y predecir qué se rompe si la quitas. Nada de esto es teoría por la teoría: cada apartado termina en una decisión que está escrita en DecoderConfig o en configs/train/small.yaml.

Embeddings: por qué 2 030 × 512

El modelo no sabe qué es e2e4. Recibe el entero 47, o el 312, o el que le haya tocado a esa jugada en la enumeración de M1. Un entero no sirve para nada: no puedes sumarlo, no puedes decir que el 47 se parece al 312, y si el modelo aprendiera algo de la magnitud del número aprendería una propiedad del orden alfabético de las casillas, que es ruido puro.

La solución es una tabla: una fila de números reales por cada id del vocabulario. Eso es 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.. En rukh-small la tabla es de 2 030 filas (el vocabulario completo de M1: 1 968 jugadas, 8 tokens especiales y 54 tramos de Elo) por 512 columnas, que es la anchura del modelo:

self.tokens = nn.Embedding(self.cfg.vocab_size, self.cfg.d_model) # 2030 x 512

Son 1 039 360 parámetros, el 2,7 % del modelo. Se inicializan con ruido gaussiano de desviación 0,02 —es decir, al principio e2e4 y a1h8 son dos puntos al azar sin relación— y se aprenden con el resto de la red por descenso de gradiente. Al final del entrenamiento, los vectores de jugadas que aparecen en contextos parecidos habrán acabado cerca: g1f3 y b1c3 (los dos desarrollan un caballo en la apertura) tendrán un producto escalar alto, y el vector de e2e4 apuntará hacia una zona del espacio que el modelo asocia con “apertura abierta”. Nadie le dice eso; sale de la única presión que hay, que es predecir bien la siguiente jugada.

¿Por qué 512 y no 128 o 2 048? Porque d_model es el presupuesto de información que fluye por el modelo, y todo escala con él: las matrices de atención son d × 3d y d × d, las del MLP d × 4d y 4d × d, así que el número de parámetros por bloque crece con . Con 512 y 12 capas salen 38 971 392 parámetros, y eso, medido en esta RTX 5090, son 422 000 tokens por segundo en el bucle de entrenamiento: los 20 000 pasos de small, a 51 200 tokens cada uno, son 1 024 millones de tokens, o sea 42 minutos de reloj. Con 768 y 16 capas (el preset medium) salen 115 120 128 parámetros, casi el triple de cómputo por token, así que por regla de tres serían un par de horas —cifra estimada, no medida—. La elección no es teórica: es el punto donde el modelo es lo bastante grande para aprender ajedrez y lo bastante pequeño para que puedas repetir el experimento un martes por la tarde.

Posiciones: tabla aprendida o RoPE

Hay un problema con lo que acabamos de montar. Si el modelo solo suma vectores de tokens, la secuencia e2e4 e7e5 g1f3 y la secuencia g1f3 e7e5 e2e4 le llegan idénticas: la atención, como verás en un momento, es una suma ponderada, y las sumas no tienen orden. Un modelo de lenguajeModelo de lenguajeRed neuronal entrenada para predecir el siguiente token dada la secuencia anterior. Aplicado a partidas de ajedrez, predice la siguiente jugada dadas las jugadas previas; de esa única tarea emergen las reglas y el juego. sin información de posición es un modelo de bolsa de palabras muy caro.

En ajedrez eso sería fatal por una razón más fuerte que en texto. Una partida es su orden: las mismas tres jugadas en otro orden dan otra posición, o ni siquiera son legales. El modelo tiene que saber que e2e4 fue la primera y g1f3 la tercera, porque la posición del tablero es una función de la secuencia ordenada y de nada más.

Opción 1: posiciones aprendidas. Una segunda tabla, de block × d_model = 200 × 512 = 102 400 parámetros, que se suma al embedding del token:

steps = torch.arange(seq, device=idx.device)
x = self.tokens(idx) + self.positions(steps)

La fila 0 es “lo que significa estar en la primera posición”, la fila 7 es lo mismo para la octava. Son 102 400 parámetros, el 0,26 % del modelo, y es lo que hace GPT-2. Ventaja: simplísimo, y el modelo aprende lo que quiera de cada posición (por ejemplo, que las diez primeras son apertura). Desventaja: no hay fila 201. El modelo no puede procesar una secuencia más larga que la tabla, y las posiciones altas se entrenan mucho menos que las bajas, porque muchas partidas terminan antes.

Opción 2: RoPE. En vez de sumar un vector de posición al token, RoPERoPECodificación de posición rotatoria (rotary position embedding): en vez de sumar un vector de posición, rota por pares las dimensiones de las consultas y las claves con un ángulo proporcional a la posición. El producto escalar entre dos posiciones depende entonces solo de su distancia, y no hace falta una tabla de posiciones. En Rukh es la alternativa a las posiciones aprendidas (`pos: rope`). rota los vectores de consulta y de clave dentro de la atención, con un ángulo proporcional a la posición. La idea es geométrica y elegante: si tomas dos dimensiones del vector como un plano y giras ese plano un ángulo m·θ para la posición m, entonces el producto escalar entre la posición m y la posición n depende solo de la diferencia m − n. La atención pasa a ver distancias relativas (“la jugada de hace dos”) en vez de índices absolutos (“la jugada número 7”), gratis y sin parámetros nuevos.

En el código, head_dim es 64, así que hay 32 pares de dimensiones, cada uno con su propia frecuencia:

inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim))

Con base = 10 000, el primer par gira 1 radián por posición —da la vuelta cada seis jugadas, así que codifica distancias muy cortas con mucha resolución— y el último gira 0,000133 radianes por posición: en las 200 posiciones enteras del contexto apenas recorre 0,027 radianes, así que lo que codifica es “principio o final de la partida”. Entre medias hay 30 frecuencias más, como las agujas de un reloj con 32 manecillas: leyendo todas a la vez se reconstruye la posición sin ambigüedad. La implementación de Rukh usa la variante por mitades, que es la de GPT-NeoX y la de Llama:

half = x.shape[-1] // 2
rotated = torch.cat([-x[..., half:], x[..., :half]], dim=-1)
return x * cos_t + rotated * sin_t

Esas dos líneas son la fórmula de la rotación de toda la vida, x·cos − y·sin y x·sin + y·cos, escritas de golpe para las 32 parejas.

Rukh entrena con posiciones aprendidas por defecto (pos: "learned") y deja RoPE detrás de una opción de configuración, porque con contexto fijo de 200 tokens el argumento de la extrapolación no aplica: ninguna partida va a ser más larga que la tabla. Es una decisión tomada por el argumento, no por medida: el día que alguien entrene las dos y RoPE gane algo, la decisión cambia y se anota. Esa es la forma sana de elegir entre dos técnicas: no por cuál se publicó después.

Atención, con números

Esta es la pieza. Todo lo demás del Transformer es plomería.

Cada posición de la secuencia produce tres vectores a partir de su representación actual, con tres matrices aprendidas (en el código, una sola matriz qkv que las calcula de golpe y se parte en tres):

  • Q, la consulta (query): qué estoy buscando.
  • K, la clave (key): qué ofrezco yo a quien me busque.
  • V, el valor (value): qué información entrego si me eligen.

La metáfora útil es un archivo. Cada jugada anterior deja una ficha con una etiqueta (K) y un contenido (V). La jugada actual escribe una búsqueda (Q). El parecido entre la búsqueda y cada etiqueta decide cuánto pesa cada contenido en el resultado.

Hagámoslo con números, en un modelo de juguete de dos dimensiones por cabeza. Tres jugadas:

posición 1: e2e4 posición 2: e7e5 posición 3: g1f3

Supón que después de sus proyecciones los vectores son:

k1 = (1, 0) v1 = (2, 0)
k2 = (1, 1) v2 = (0, 2)
k3 = (0, 1) v3 = (1, 1)
q3 = (1, 1) <- la consulta de la tercera jugada

Paso 1, producto escalar. El parecido entre la consulta y cada clave:

q3 · k1 = 1*1 + 1*0 = 1
q3 · k2 = 1*1 + 1*1 = 2
q3 · k3 = 1*0 + 1*1 = 1

Paso 2, escala 1/√d. Se divide por la raíz de la dimensión de la cabeza, aquí √2 = 1,4142:

1 / 1,4142 = 0,7071
2 / 1,4142 = 1,4142
1 / 1,4142 = 0,7071

¿Por qué? Si Q y K tienen componentes independientes de varianza 1, el producto escalar de d dimensiones tiene varianza d: con d = 64, los valores se mueven en un rango ocho veces mayor que con d = 1. Y el softmax es exponencial: una diferencia de 8 entre dos puntuaciones se convierte en una probabilidad de 0,9997 frente a 0,0003. Sin la escala, un modelo ancho empezaría con atenciones prácticamente binarias, y donde el softmax satura el gradiente es cero. Dividir por √d mantiene las puntuaciones en un rango donde el softmax todavía tiene pendiente. Es una línea de código que decide si el modelo aprende o se queda clavado.

Paso 3, softmax. Exponenciar y normalizar:

exp(0,7071) = 2,0281
exp(1,4142) = 4,1133
exp(0,7071) = 2,0281
suma = 8,1695
pesos = 0,2483 0,5035 0,2483 (suman 1)

Compara con lo que habría salido sin la escala, partiendo de 1, 2 y 1: 0,2120 · 0,5761 · 0,2120. Con dos dimensiones la diferencia es pequeña; con 64 es la diferencia entre un reparto y un interruptor.

Paso 4, mezcla. La salida es la media de los valores ponderada por esos pesos:

0,2483 * (2, 0) = (0,4966 , 0 )
0,5035 * (0, 2) = (0 , 1,0070)
0,2483 * (1, 1) = (0,2483 , 0,2483)
-------------------
salida de q3 = (0,7449 , 1,2553)

Eso es todo. softmax(QKᵀ/√d)·V, tres líneas de álgebra que puedes hacer a mano, repetidas para cada posición, para cada cabeza y para cada capa. En PyTorch es una llamada:

out = F.scaled_dot_product_attention(q, k, v, dropout_p=..., is_causal=True)

La máscara causal, que aquí no es opcional

En el ejemplo anterior, la consulta de la posición 3 miró a las claves 1, 2 y 3. ¿Y la consulta de la posición 1? Si mirase a las claves 2 y 3, estaría mirando el futuro.

Eso, en un modelo entrenado para predecir la siguiente jugada, no es una imprecisión: es hacer trampa, y el modelo la encuentra inmediatamente. El entrenamiento es teacher forcing: se le da la partida entera y se le pide que en cada posición prediga el token siguiente. Si la posición t puede leer el token t+1, la tarea deja de ser “predice” y pasa a ser “copia lo que tienes al lado”. La pérdida se desploma hasta casi cero en unos cientos de pasos, las curvas parecen un éxito, y el modelo generando de verdad —donde el futuro no existe todavía— juega como un generador de números aleatorios. Es el error más caro que se puede cometer en este módulo, porque no se manifiesta como un fallo sino como un resultado buenísimo.

La máscara causalMáscara causalRestricción que impide a cada posición mirar hacia delante: antes del softmax, los pesos de las claves futuras se ponen a menos infinito. Sin ella el modelo vería la jugada que tiene que predecir y la pérdida bajaría sin que aprendiese nada. En Rukh la aplica `F.scaled_dot_product_attention(..., is_causal=True)`. lo impide. Antes del softmax, toda puntuación de una clave posterior a la consulta se pone a menos infinito; exp(-inf) = 0, así que esos pesos salen exactamente cero:

clave 1 clave 2 clave 3
consulta 1 0,71 -inf -inf -> pesos 1 0 0
consulta 2 0,35 1,06 -inf -> pesos 0,33 0,67 0
consulta 3 0,71 1,41 0,71 -> pesos 0,25 0,50 0,25

Una matriz triangular inferior. La primera posición solo se ve a sí misma (y por eso el <bos> de M1 no es decorativo: es el único contexto que tiene la primera jugada). En el código es un argumento, is_causal=True, y en el lab 2 la vas a dibujar y, sobre todo, a verificar empíricamente: cambiar un token futuro no debe mover ni un decimal de los logits pasados. Un test que se escribe una vez y protege el proyecto entero.

Multi-cabeza: ocho de 64 en vez de una de 512

Una cabeza de atenciónCabeza de atenciónUna atención completa calculada en un subespacio de la representación. `rukh-small` parte sus 512 dimensiones en 8 cabezas de 64: cada una aprende su propio criterio de parecido y sus propios pesos, y sus salidas se concatenan y se vuelven a proyectar. Mismo coste que una cabeza de 512, ocho patrones distintos por capa. con d = 512 calcula un único reparto de pesos por posición. Es un único criterio de parecido: todo lo que el modelo quiera mirar en esa capa tiene que caber en esa comparación.

Multi-cabeza parte las 512 dimensiones en 8 trozos de 64 y ejecuta la misma operación ocho veces en paralelo, cada una en su subespacio, con sus propios pesos dentro de la misma matriz qkv. Después concatena las ocho salidas (8 × 64 = 512) y las pasa por una proyección de salida que las mezcla.

Lo notable es lo que cuesta: nada. La matriz qkv es de 512 × 1536 tanto si hay una cabeza como si hay ocho; lo único que cambia es cómo se reinterpretan sus columnas. Mismo número de parámetros, prácticamente los mismos FLOPs, y a cambio ocho patrones de atención distintos por capa en vez de uno. Con 12 capas, el modelo tiene 96 cabezas y por tanto 96 formas simultáneas de relacionar jugadas.

¿Qué aprende cada una? En los Transformers de texto se han encontrado cabezas muy especializadas: una que mira siempre al token anterior, otra que busca la aparición previa del mismo token, otra que empareja comillas de apertura y cierre. En ajedrez cabe esperar cosas análogas: una cabeza “jugada anterior” (la diagonal encendida que verás en el mapa), una cabeza que mira a la última vez que se movió una pieza de la casilla de destino, una que se fija en el token de Elo del principio. No tenemos garantía de ninguna, y por eso la isla de esta lección deja elegir capa y cabeza en vez de enseñarte la que confirma la historia.

Lo que sí se sabe es la contrapartida: 64 dimensiones por cabeza es poco espacio, y con demasiadas cabezas cada una se queda sin resolución. La regla que siguen casi todos los modelos —y Rukh— es mantener head_dim entre 64 y 128. 8 × 64 en small, 4 × 64 en tiny, 12 × 64 en medium.

El bloque pre-norm y por qué doce capas se dejan entrenar

Un bloque de Rukh son cuatro líneas:

def forward(self, x, cos=None, sin=None):
x = x + self.attn(self.ln1(x), cos, sin)
return x + self.mlp(self.ln2(x))

Dos subcapas —atención y MLP— cada una precedida de una normalización y sumada a su entrada. Hay dos ideas juntas y conviene separarlas.

La conexión residualConexión residualSuma de la entrada de una subcapa a su salida (`x + f(x)`). La derivada de esa suma es la identidad más la de `f`, así que el gradiente llega íntegro a las capas de abajo aunque `f` aporte poco; el flujo de vectores que atraviesa el modelo de principio a fin se llama por eso corriente residual. es el x +. Sin ella, la salida de la capa 12 es una composición de doce funciones, y el gradiente que llega a la capa 1 es el producto de doce jacobianas: si cada una encoge el gradiente a la mitad, llega dividido por 4 096 y la primera capa no aprende. Con la suma, la derivada de cada bloque es la identidad más la derivada de la subcapa: el gradiente llega íntegro a todas las capas por el camino recto, y lo que cada bloque aporta es una corrección. Por eso una pila de 12 (o de 96) se entrena, y por eso al vector que recorre el modelo de arriba abajo se le llama corriente residual: es una especie de memoria compartida que cada bloque lee, modifica un poco y devuelve.

El pre-normPre-normOrden de un bloque Transformer en el que la normalización va antes de la subcapa y su salida se suma a la entrada: `x = x + attn(ln1(x))`. Deja un camino sin obstáculos entre la pérdida y las primeras capas, y es lo que permite apilar doce bloques y entrenarlos sin trucos. La variante contraria (post-norm) es la del artículo original y necesita mucho más cuidado. es dónde va el LayerNorm. El artículo original ponía la normalización después de la suma (x = ln(x + attn(x)), post-norm), y así es como se entrenaron los primeros Transformers: con warmup largo, inicialización cuidadosa y bastante mala suerte. El problema es que la normalización está en el camino residual, así que ya no hay identidad: cada capa reescala lo que pasa por ella. Pre-norm mueve el LayerNorm dentro de la rama, antes de la subcapa, y deja la autopista libre desde la pérdida hasta el embedding. Es un cambio de dos caracteres en el código que convierte “entrenable con cuidado” en “entrenable”.

Queda un detalle que parece cosmético y no lo es. La corriente residual recibe 24 aportaciones (12 bloques × 2 subcapas), y si cada una tiene la varianza de una inicialización normal, la varianza total crece con el número de capas y el modelo arranca con activaciones enormes. Por eso las proyecciones de salida se inicializan más pequeñas:

scale = 0.02 / math.sqrt(2 * self.cfg.n_layer) # 0.02 / sqrt(24) = 0.00408

Es el truco de GPT-2 y está en MoveDecoder.__init__. Divide la desviación por √24: las escrituras en la corriente residual empiezan pequeñas y el modelo va subiendo el volumen de las que le sirven.

El MLP, por cierto, es la parte aburrida y dos tercios de los parámetros de un bloque: 512 → 2048 → 512 con GELU en medio. La atención mezcla posiciones; el MLP procesa cada posición por separado. Se suele describir como la memoria del modelo: donde vive “después de esta configuración, esta jugada”.

Tied embeddings: la misma tabla para leer y para escribir

Al final del modelo hay una capa que convierte el vector de 512 dimensiones de la última posición en 2 030 logitsLogitsPuntuación bruta que el modelo asigna a cada token del vocabulario antes de convertirla en probabilidades (con softmax). En Rukh hay un logit por jugada UCI posible; la demo pone a menos infinito los de las jugadas ilegales antes de muestrear, y la temperatura y el top-k actúan sobre ellos., uno por jugada posible. Esa capa es una matriz de 512 × 2 030… que tiene exactamente la misma forma, traspuesta, que la tabla de embeddings de la entrada. Rukh no crea una nueva: usa la misma.

self.lm_head = nn.Linear(self.cfg.d_model, self.cfg.vocab_size, bias=False)
if self.cfg.tie_embeddings:
self.lm_head.weight = self.tokens.weight

Qué ahorra: 1 039 360 parámetros, un 2,7 % del total. En un modelo grande sería irrelevante; en uno de 39 millones con un vocabulario pequeño es un descuento honesto, y sobre todo son parámetros que reciben el doble de gradiente, porque se entrenan por los dos extremos.

Qué supone, que es lo interesante. El logit de la jugada j pasa a ser el producto escalar entre el estado final del modelo y el vector con el que se lee j. Es decir: predecir una jugada es apuntar, en el espacio de embeddings, hacia el sitio donde vive esa jugada. Se impone que “lo que significa e2e4 a la entrada” y “lo que hay que pensar para decir e2e4” sean lo mismo. Es una hipótesis fuerte, casi siempre buena, y ocasionalmente limitante: si un token quisiera significar cosas muy distintas leído y escrito, atarlos le quita esa libertad.

La receta: AdamW, warmup, coseno, clipping, bf16

Un modelo bien construido no entrena solo. La receta de configs/train/small.yaml es casi la misma que usaría cualquiera para un GPT pequeño, y cada número tiene un motivo.

AdamWAdamWOptimizador Adam con el decaimiento de pesos desacoplado del gradiente: mantiene medias móviles del gradiente (β₁) y de su cuadrado (β₂) para dar a cada parámetro su propio paso, y resta aparte una fracción del peso. En Rukh: β = 0,9/0,95, weight decay 0,1 aplicado solo a las matrices. con β = 0,9 / 0,95. Adam guarda dos medias móviles por parámetro: la del gradiente (β₁ = 0,9, una especie de inercia) y la de su cuadrado (β₂), y divide la primera por la raíz de la segunda, de manera que cada parámetro recibe un paso normalizado por lo ruidoso que es su gradiente. El valor por defecto de β₂ en PyTorch es 0,999, que promedia sobre unos mil pasos; en modelos de lenguaje se baja a 0,95 (unos veinte pasos) porque la escala de los gradientes cambia deprisa al principio y una media demasiado larga reacciona tarde. Es el valor de GPT-3 y el de nanoGPT.

Weight decay 0,1, solo en las matrices. El decaimiento de pesos resta en cada paso una fracción del propio peso: empuja todo hacia cero salvo que el gradiente lo sostenga. En las matrices —la atención, el MLP, los embeddings— eso es regularización clásica. En los parámetros de LayerNorm y en los sesgos, no:

decay = [p for p in model.parameters() if p.requires_grad and p.dim() >= 2]
no_decay = [p for p in model.parameters() if p.requires_grad and p.dim() < 2]

La regla es literalmente “tensores de dos dimensiones o más”. Un LayerNorm tiene una ganancia por canal inicializada a 1, y su trabajo es reescalar la señal; empujarla hacia cero es apagar el canal, no regularizarlo. Un sesgo tiene una dimensión y desplaza, no escala: no aporta capacidad que haga falta contener. Son unos pocos miles de parámetros de los 39 millones, pero meterlos en el grupo equivocado degrada el entrenamiento de forma silenciosa, que es la peor.

WarmupWarmupArranque en el que la tasa de aprendizaje sube linealmente desde cero durante los primeros pasos (1 000 en `small`) antes de empezar a decaer. Evita que Adam dé pasos enormes mientras sus medias móviles todavía se estiman con cuatro gradientes, que es cuando un modelo recién inicializado se rompe. de 1 000 pasos y después coseno. La tasa de aprendizaje empieza en cero, sube en línea recta hasta 6e-4 en el paso 1 000 y desde ahí baja siguiendo medio coseno hasta el 10 % de ese valor en el paso 20 000, donde se queda:

if step < cfg.warmup:
return cfg.lr * step / cfg.warmup
progress = min(1.0, (step - cfg.warmup) / (cfg.max_steps - cfg.warmup))
return floor + 0.5 * (1.0 + math.cos(math.pi * progress)) * (cfg.lr - floor)

Qué pasa sin warmup. En el paso 1, Adam tiene una estimación del segundo momento hecha con un único gradiente; el cociente que calcula es casi arbitrario, y como el paso de Adam está normalizado, un parámetro puede moverse tanto como la tasa de aprendizaje de golpe. Sobre una red recién inicializada, con 24 escrituras en la corriente residual, eso basta para que las activaciones exploten, el softmax de la atención sature y el modelo caiga en un mínimo tonto: predecir siempre las jugadas más frecuentes del corpus. La curva no revienta con un NaN espectacular; se queda plana en una pérdida mediocre y no baja nunca. El warmup cuesta el 5 % de los pasos y elimina ese riesgo entero.

La bajada en coseno tiene una lógica parecida por el otro lado: al final del entrenamiento el modelo está cerca de un mínimo y pasos grandes solo lo sacan de él. El suelo del 10 % evita que los últimos miles de pasos no hagan nada.

Clipping a 1,0. Antes de cada optimizer.step() se calcula la norma global del gradiente y, si pasa de 1,0, se reescala el vector entero para que valga 1,0. Es un seguro contra un lote raro: sin él, una única secuencia patológica puede mover los pesos lo suficiente como para tirar horas de entrenamiento. El grad_norm se registra en MLflow en cada paso, y mirarlo es la mejor forma de saber si el entrenamiento está sano: debe bajar y estabilizarse, no dar picos.

bf16bfloat16Formato de coma flotante de 16 bits con los mismos 8 bits de exponente que fp32 y solo 7 de mantisa: pierde precisión pero conserva el rango, así que un gradiente pequeño no se va a cero. Por eso entrena sin escalado de pérdida, al contrario que fp16, cuyo exponente de 5 bits desborda por abajo., y por qué no fp16. Los dos son formatos de 16 bits y los dos doblan el rendimiento en una GPU moderna. La diferencia está en cómo reparten los bits. fp16 usa 5 para el exponente y 10 para la mantisa: mucha precisión, poco rango, con el número normal más pequeño alrededor de 6·10⁻⁵. Los gradientes de una red profunda viven justo ahí abajo, así que en fp16 se van a cero: por eso entrenar en fp16 obliga a un GradScaler que multiplica la pérdida por un factor grande, lo baja si aparecen infinitos, y de vez en cuando te regala un NaN a las tres de la mañana. bf16 usa 8 bits de exponente —el mismo rango que fp32— y 7 de mantisa: menos precisión por número, pero nada se desborda por abajo. No hace falta escalar nada. En la RTX 5090 (sm_120) bf16 es nativo, así que la elección es gratis:

precision: bf16
compile: true

Los pesos maestros siguen en fp32 y el optimizador acumula en fp32; bf16 es solo la precisión de las operaciones dentro del autocast. Y torch.compile está activado con una salvaguarda: en Windows sin MSVC la compilación puede fallar, así que el bucle la fuerza con un lote de prueba y, si revienta, avisa y entrena en modo eager en vez de morirse.

Muestrear una jugada: temperatura, top-k y la máscara de legalidad

El modelo da 2 030 logits. Convertirlos en una jugada es una decisión de diseño con tres perillas.

TemperaturaTemperaturaDivisor que se aplica a los logits antes del softmax. Por debajo de 1 concentra la probabilidad en las jugadas que el modelo prefiere (con temperatura 0 es directamente el argmax) y por encima de 1 la reparte. En Rukh el valor por defecto es 0,6: juego variado pero no temerario.. Se dividen los logits por T antes del softmax. Con T = 1 la distribución es la que el modelo aprendió; con T pequeña las diferencias se amplifican y la mejor jugada se lleva casi toda la probabilidad (en el límite, T → 0, es el argmax); con T grande todo se aplana hacia el azar. Rukh usa 0,6 por defecto: suficiente para que dos partidas no sean idénticas, poco para que el modelo se tire a la piscina.

Top-kTop-kTruncamiento del muestreo: solo se conservan los k tokens de mayor probabilidad y el resto pasa a probabilidad cero antes de sortear. Con k = 20 el modelo nunca juega una ocurrencia de la cola de la distribución, que es de donde salen casi todas las jugadas absurdas.. Se conservan solo los k logits mayores y el resto pasa a menos infinito. Con k = 20 de 2 030 jugadas, la cola —donde hay mil jugadas con probabilidad 0,0001 cada una, que sumadas son un 10 % de probabilidad de hacer una tontería— desaparece. Temperatura y top-k no son alternativas sino complementarias: la temperatura reescala, el top-k recorta. Sin top-k, una temperatura alta muestrea basura; sin temperatura, el top-k solo limita cuánta basura.

La máscara de legalidad. Antes de nada, se calcula con python-chess la lista de jugadas legales en la posición y se ponen a menos infinito todos los demás logits:

keep = torch.zeros_like(out, dtype=torch.bool)
keep[torch.tensor(legal, dtype=torch.long, device=out.device)] = True
out = out.masked_fill(~keep, float("-inf"))

Con la máscara puesta, el modelo no puede proponer una jugada ilegal. Eso hace la demo jugable y es lo que se usa en las partidas contra Stockfish. Y aquí está el argumento honesto de todo el módulo:

Cómo se mide

Al terminar esta sección sabrás leer cada columna de la tabla única, explicar de dónde sale el intervalo de confianza del Elo y decir con cuántas partidas una mejora es real.

Un modelo sin números es una anécdota. rukh eval --model <ckpt> --suite full produce una fila completa, con caché SQLite para que repetir la evaluación no vuelva a jugar las mismas partidas. Seis medidas, cada una respondiendo a una pregunta distinta.

Legalidad sin máscara, en sus dos versiones. Diez mil prefijos de partidas de validación; en cada uno se le pide una jugada sin máscara y se comprueba si el token propuesto es legal en esa posición. Salen dos columnas: legality_argmax (el token más probable; es el titular y el listón de GOAL) y legality_sampled (temperatura 0,6 y top-k 20, como juega la demo), que es siempre menor porque muestrear mete cola. Es lo primero que sube durante el entrenamiento y lo que más impresiona: el modelo no tiene tablero, no tiene reglas, no sabe qué es un alfil, y aun así acaba proponiendo jugadas legales casi siempre. Ojo con el sesgo de la medida: las posiciones de apertura son fáciles (casi cualquier desarrollo razonable es legal) y los finales son difíciles (pocas jugadas legales, muchas parecidas). Una legalidad global del 95 % puede esconder un 99 % en apertura y un 70 % en finales.

Top-1 y top-3 de siguiente jugada. Porcentaje de posiciones en que la jugada más probable del modelo (o alguna de sus tres primeras) coincide con la que jugó el humano. Es la métrica más directa de “imita bien”, y también la más engañosa como medida de fuerza: el techo no es el 100 %, porque en muchas posiciones hay tres jugadas razonables y el humano eligió una. La expectativa con la que se entra al lab 4 es un top-1 del 35-45 % contra jugadores de club; es una horquilla de andar por casa, sin fuente detrás, así que trátala como hipótesis y compárala con la cifra que salga de rukh eval, que es la que vale. Se desglosa por tramo de Elo, y ese desglose es lo que hace posible el Elo-conditioning de M4: si el modelo predice mejor a los 1800 que a los 2400, es que ha aprendido a imitar el nivel medio de sus datos.

Puzles por tramo. Dos mil puzles de Lichess por banda de dificultad (1000-1500, 1500-2000, 2000+), con un criterio duro: un puzle cuenta como resuelto solo si el modelo acierta toda la secuencia, no la primera jugada. Mide algo distinto de la imitación: encontrar la jugada única que gana. Un modelo entrenado solo para imitar suele ir mal en puzles, y ver cuánto sube eso con DPO y GRPO en M5 es uno de los resultados bonitos del curso.

Elo estimado con intervalo de confianza. El modelo juega contra Stockfish en ocho escalones —cuatro por Skill Level (por debajo de 1320, que es el suelo de UCI_Elo) y cuatro por UCI_Elo en 1320, 1500, 1800 y 2000— con ambos colores y con máscara de legalidad. Después se ajusta el número que mejor explica esos resultados. La fórmula es la logística de Elo de toda la vida:

P(puntuación) = 1 / (1 + 10 ** ((elo_rival − elo_modelo) / 400))

Se lee así: 400 puntos de diferencia son una probabilidad de ganar de 10 a 1; 0 puntos, 50 %. El ajuste es encontrar el elo_modelo que hace más verosímiles los resultados observados, y se resuelve por Newton en un par de líneas, sin SciPy. Con c = ln(10)/400 = 0,005756, el gradiente de la log-verosimilitud es c · Σ(s − p) y la curvatura −c² · Σ p(1−p), así que cada paso es Σ(s − p) / (c · Σ p(1−p)). Las tablas entran como puntuación 0,5.

Y ahora la parte que casi nadie cuenta. La misma curvatura que resuelve el ajuste da la varianza del resultado: 1 / (c² · Σ p(1−p)). Haz el número. Si juegas 100 partidas contra un rival donde rindes al 50 %, Σ p(1−p) = 25, la varianza es 1 / (0,005756² · 25) ≈ 1 207 y la desviación típica 35 puntos de Elo: el intervalo del 95 % mide unos ±68. Con los ocho escalones de la suite completa, 800 partidas, la desviación baja a unos 12 puntos y el intervalo a ±24. En la práctica el harness no usa esa fórmula sino un bootstrap percentil: remuestrea con reemplazo las partidas jugadas mil veces, reajusta el Elo en cada remuestreo y se queda con los percentiles 2,5 y 97,5. Da un intervalo parecido sin suponer que la verosimilitud es gaussiana, y las tablas —que reducen la varianza— quedan contadas tal como ocurrieron.

La conclusión práctica: con 100 partidas por escalón no puedes presumir de 10 puntos de Elo. Ni de 30. La mejora que Elo-conditioning o DPO produzcan en M4 y M5 tiene que superar el ancho del intervalo para que signifique algo, y si no lo supera, lo honesto es escribir “no medimos una diferencia” en vez de “mejoró un poco”. Es exactamente el mismo error que cometen los benchmarks de LLM que celebran medio punto en una evaluación de 200 ejemplos.

Δcp medio. Pérdida media en centipeones respecto a la mejor jugada según Stockfish a profundidad 12, sobre 500 posiciones. Es la métrica que mejor correlaciona con “juega bien”, porque no penaliza elegir una jugada distinta de la del humano si es igual de buena.

Diversidad. Entropía de las aperturas jugadas en 200 partidas propias. Es la métrica de guardia: cuando en M5 empieces a optimizar recompensas, lo primero que hace un modelo alineado es colapsar en una sola línea que funciona. Una diversidad que se hunde mientras el Elo sube es la firma del reward hacking.

Δcp y diversidad se calculan en un hito posterior, así que en las primeras filas de la tabla aparecen como null. Un hueco explícito es información; un cero inventado, no.

Labs

Al terminar esta sección tendrás el modelo escrito, entrenado, evaluado, exportado y jugando, y los cuatro scripts de labs/m2/ que hacen falta para verlo por dentro. Todos los comandos se ejecutan desde la raíz del repo rukh. Donde va una salida real hay un hueco marcado hasta que el entrenamiento termine.

Lab 1 · MoveDecoder a mano, y contar sus parámetros

Escribe src/rukh/models/decoder.py siguiendo el plan: CausalSelfAttention con una qkv fusionada, Mlp con GELU, Block pre-norm, MoveDecoder con embeddings, posiciones, la pila de bloques, el LayerNorm final y el lm_head atado. Los tests de formas y causalidad son la parte que no se salta.

Y después cuenta. Guarda este script como labs/m2/params.py en el repo rukh: recorre el modelo bloque a bloque, suma, y compara con la fórmula escrita a mano. Si las dos cifras no coinciden, es que tienes una idea equivocada de tu propio modelo.

labs/m2/params.py
"""Parameter count of a MoveDecoder, block by block, against the closed-form formula."""
from rukh.models import MoveDecoder, preset
def formula(cfg) -> int:
"""What the architecture says it should be, computed by hand."""
d, ff, v = cfg.d_model, cfg.ff, cfg.vocab_size
per_block = (
2 * d # ln1
+ (d * 3 * d + 3 * d) # qkv
+ (d * d + d) # attn out projection
+ 2 * d # ln2
+ (d * ff + ff) # mlp in
+ (ff * d + d) # mlp out
)
total = v * d + per_block * cfg.n_layer + 2 * d # tokens + blocks + final ln
if cfg.pos == "learned":
total += cfg.block * d
if not cfg.tie_embeddings:
total += v * d
return total
for name in ("tiny", "small", "medium"):
cfg = preset(name)
model = MoveDecoder(cfg)
counted = sum(p.numel() for p in model.parameters())
groups = {
"tokens": model.tokens.weight.numel(),
"positions": model.positions.weight.numel() if model.positions is not None else 0,
"blocks": sum(p.numel() for p in model.blocks.parameters()),
"ln_f": sum(p.numel() for p in model.ln_f.parameters()),
"lm_head (tied)": 0 if cfg.tie_embeddings else model.lm_head.weight.numel(),
}
print(f"== {name}: {cfg.n_layer} layers, d={cfg.d_model}, {cfg.n_head} heads ==")
for key, value in groups.items():
print(f" {key:<16} {value:>12,}")
print(f" {'total':<16} {counted:>12,} formula {formula(cfg):>12,}")
print(f" {'non-embedding':<16} {model.num_params():>12,}")
assert counted == formula(cfg), f"{name}: the formula does not match the model"
Terminal
uv run python labs/m2/params.py

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

== tiny: 6 layers, d=256, 4 heads ==
tokens 519,680
positions 51,200
blocks 4,738,560
ln_f 512
lm_head (tied) 0
total 5,309,952 formula 5,309,952
non-embedding 5,258,752
== small: 12 layers, d=512, 8 heads ==
tokens 1,039,360
positions 102,400
blocks 37,828,608
ln_f 1,024
lm_head (tied) 0
total 38,971,392 formula 38,971,392
non-embedding 38,868,992
== medium: 16 layers, d=768, 12 heads ==
tokens 1,559,040
positions 153,600
blocks 113,405,952
ln_f 1,536
lm_head (tied) 0
total 115,120,128 formula 115,120,128
non-embedding 114,966,528

Lo que debe salir para small, y que puedes verificar con una calculadora antes de ejecutarlo: embeddings 2 030 × 512 = 1 039 360; posiciones 200 × 512 = 102 400; por bloque 1 024 + 787 968 + 262 656 + 1 024 + 1 050 624 + 1 049 088 = 3 152 384, que por 12 capas son 37 828 608; el LayerNorm final, 1 024. Total 38 971 392, y 38 868 992 sin contar la tabla de posiciones. Fíjate en el reparto: el 97 % de los parámetros está en los bloques, y dentro de un bloque, dos tercios están en el MLP. La atención, que es la idea, es la minoría de los pesos.

// Ejercicio 01¿Dónde meterías el próximo millón de parámetros?

Tienes un millón de parámetros extra de presupuesto para small. Calcula, con la fórmula del script, cuánto cuesta cada una de estas tres opciones y cuál cabe: (a) una capa más, (b) subir d_ff de 2 048 a 2 304, (c) subir d_model de 512 a 528 (y ajustar n_head para que siga dividiendo). ¿Cuál elegirías y por qué?

// SoluciónVer la solución

(a) Una capa más son 3 152 384 parámetros: no cabe, se pasa tres veces. (b) Subir d_ff en 256 añade d·256 + 256 + 256·d + d = 262 912 por bloque, 3 154 944 en total: tampoco cabe. (c) Subir d_model a 528 recalcula todo: el término de cada bloque crece un 6,3 %, y sale en torno a 41,4 millones, más de dos millones por encima. Con un millón de margen no cabe ninguna de las tres a lo largo de las doce capas; lo único que cabría es aplicar (b) a la mitad de los bloques, que es una arquitectura rara.

La lección es la que cuenta: en un Transformer el presupuesto no es continuo. Los parámetros van en cuantos de una capa entera o de un incremento de anchura multiplicado por n_layer, y por eso los modelos publicados vienen en tallas y no en cualquier tamaño. Y si hubiera que elegir, el consenso empírico (las leyes de escala de Kaplan y las correcciones de Chinchilla) es que profundidad y anchura deben crecer juntas: un modelo muy profundo y estrecho o muy ancho y plano rinde peor que el cuadrado a igualdad de parámetros. Nuestros tres presets —6×256, 12×512, 16×768— siguen esa diagonal.

Lab 2 · Dibujar la máscara causal y demostrar que funciona

Un test que pasa no te enseña nada si no ves lo que comprueba. Guarda este script como labs/m2/causal_mask.py: dibuja la máscara en la terminal y después hace el experimento que importa, que es cambiar un token del futuro y comprobar que los logits del pasado no se mueven ni un bit.

labs/m2/causal_mask.py
"""Draw the causal mask and prove causality empirically on a real MoveDecoder."""
import torch
from rukh.models import DecoderConfig, MoveDecoder
T = 8
mask = torch.ones(T, T, dtype=torch.bool).tril()
print("Causal mask (row = query, column = key):")
print(" " + " ".join(f"{j:>2}" for j in range(T)))
for i in range(T):
cells = " ".join(" #" if mask[i, j] else " ." for j in range(T))
print(f" q{i:<3} {cells}")
print(f"\nVisible pairs: {int(mask.sum())} of {T * T} ({100 * mask.float().mean():.1f} %)\n")
torch.manual_seed(0)
model = MoveDecoder(DecoderConfig(n_layer=2, n_head=2, d_model=32, vocab_size=64, block=T)).eval()
idx = torch.randint(1, 64, (1, T))
with torch.no_grad():
base, _ = model(idx)
for t in range(T - 1):
changed = idx.clone()
# Replace every token after t with a different one.
changed[:, t + 1 :] = (changed[:, t + 1 :] + 7) % 63 + 1
other, _ = model(changed)
past = (base[:, : t + 1] - other[:, : t + 1]).abs().max().item()
future = (base[:, t + 1 :] - other[:, t + 1 :]).abs().max().item()
print(f" cut after position {t}: max |delta| past {past:.3e} future {future:.3e}")
assert past == 0.0, "the past moved: the mask is not causal"
Terminal
uv run python labs/m2/causal_mask.py

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

Causal mask (row = query, column = key):
0 1 2 3 4 5 6 7
q0 # . . . . . . .
q1 # # . . . . . .
q2 # # # . . . . .
q3 # # # # . . . .
q4 # # # # # . . .
q5 # # # # # # . .
q6 # # # # # # # .
q7 # # # # # # # #
Visible pairs: 36 of 64 (56.2 %)
cut after position 0: max |delta| past 0.000e+00 future 5.934e-01
cut after position 1: max |delta| past 0.000e+00 future 5.969e-01
cut after position 2: max |delta| past 0.000e+00 future 5.884e-01
cut after position 3: max |delta| past 0.000e+00 future 5.925e-01
cut after position 4: max |delta| past 0.000e+00 future 5.863e-01
cut after position 5: max |delta| past 0.000e+00 future 4.818e-01
cut after position 6: max |delta| past 0.000e+00 future 3.524e-01

Lo que tiene que salir: la diferencia en el pasado es exactamente 0,0 —no 1e-7, cero— y la del futuro es grande. Cero exacto porque no es una cuestión de precisión numérica sino de grafo: los tokens futuros ni siquiera entran en el cálculo de las posiciones pasadas. Si alguna vez ves 1e-7 ahí, tienes una fuga sutil, típicamente una normalización aplicada a lo largo del eje de tiempo en vez del de canales.

// Ejercicio 02Rompe la causalidad a propósito

Cambia is_causal=True por is_causal=False en CausalSelfAttention y vuelve a ejecutar el script. Después entrena tiny durante 200 pasos con y sin máscara y compara las dos curvas de pérdida. ¿Cuál baja más rápido? ¿Cuál modelo es mejor? ¿Qué medirías para no dejarte engañar?

Aviso práctico: esto no es una opción de configuración, es una edición de src/rukh/models/decoder.py. Deshazla en cuanto termines (git checkout -- src/rukh/models/), porque un modelo entrenado con ese cambio puesto pasa los tests de forma y falla el de causalidad, y un checkpoint contaminado no se distingue de uno bueno mirando los pesos.

// SoluciónVer la solución

Sin máscara, el assert del script salta en la primera iteración: los logits pasados cambian al tocar el futuro. Entrenando, la curva sin máscara baja muchísimo más rápido y llega a pérdidas que la causal no alcanza jamás, porque la tarea es distinta: con acceso al futuro, predecir el token t+1 se resuelve leyendo el token t+1. En el límite, la pérdida tiende a cero y el top-1 al 100 %.

El modelo es basura y no hay forma de verlo en la curva de entrenamiento. Lo que lo delata es generar: en inferencia el futuro no existe, el modelo recibe una secuencia que no se parece a ninguna que haya visto y propone jugadas al azar. Las dos métricas que lo cazan en un minuto son la legalidad sin máscara (se hunde) y una partida de rukh play (jugadas sin sentido desde el ply 2). Es el caso general de una lección que vale para cualquier proyecto de ML: una pérdida sospechosamente buena es casi siempre una fuga, y la única defensa fiable es una métrica calculada como se va a usar el modelo, no como se entrena.

Lab 3 · Entrenar tiny en tres minutos

Ahora sí. tiny son 6 capas, 256 dimensiones, 4 cabezas y 5 309 952 parámetros; la configuración pide 6 000 pasos con lotes de 128 × 2 acumulaciones (256 secuencias efectivas de 200 tokens, unos 51 200 tokens por paso) y tasa de aprendizaje 1e-3 con 500 pasos de warmup. Es el modelo con el que se itera: si algo está mal, lo sabes en tres minutos y no en cuarenta.

Terminal
uv run rukh train --config configs/train/tiny.yaml

La consola de esta tirada no se capturó. El run sí quedó registrado: estas son las 24 evaluaciones de validación de tiny-20260919-061533 tal como están en mlruns/mlflow.db, de principio a fin del entrenamiento (189 segundos en total, poco más de tres minutos):

step 250 val/loss 4.5869 val/top1 0.1903
step 500 val/loss 3.5244 val/top1 0.2544
step 750 val/loss 3.0194 val/top1 0.2953
step 1000 val/loss 2.7751 val/top1 0.3175
step 1250 val/loss 2.6150 val/top1 0.3326
step 1500 val/loss 2.5098 val/top1 0.3443
step 1750 val/loss 2.4252 val/top1 0.3537
step 2000 val/loss 2.3687 val/top1 0.3611
step 2250 val/loss 2.3210 val/top1 0.3665
step 2500 val/loss 2.2764 val/top1 0.3723
step 2750 val/loss 2.2394 val/top1 0.3776
step 3000 val/loss 2.2115 val/top1 0.3816
step 3250 val/loss 2.1809 val/top1 0.3858
step 3500 val/loss 2.1552 val/top1 0.3902
step 3750 val/loss 2.1312 val/top1 0.3934
step 4000 val/loss 2.1111 val/top1 0.3970
step 4250 val/loss 2.0932 val/top1 0.3993
step 4500 val/loss 2.0770 val/top1 0.4022
step 4750 val/loss 2.0622 val/top1 0.4048
step 5000 val/loss 2.0500 val/top1 0.4066
step 5250 val/loss 2.0398 val/top1 0.4080
step 5500 val/loss 2.0316 val/top1 0.4101
step 5750 val/loss 2.0251 val/top1 0.4106
step 6000 val/loss 2.0200 val/top1 0.4121

Mientras corre, abre MLflow (uv run rukh mlflow ui) y mira cuatro series. train/loss debe bajar deprisa los primeros cientos de pasos y después despacio. val/loss debe acompañarla; cuando se separen —la de entrenamiento sigue bajando y la de validación se queda plana o sube— has llegado al sobreajuste y el best.pt ya está guardado del paso anterior. lr debe dibujar la rampa de warmup y el coseno. Y grad_norm debe estabilizarse: picos recurrentes significan que el clipping está trabajando demasiado y que la tasa de aprendizaje es alta para este modelo.

Cada 1 000 pasos se escribe un checkpoint, y esos checkpoints son exactamente los que alimentan la repetición del entrenamiento que hay más abajo en esta página.

Después, la evaluación rápida —una décima parte del trabajo de la completa, pensada para iterar:

Terminal
uv run rukh eval --model checkpoints/tiny/best.pt --suite quick

El informe que escribió esa ejecución, artifacts/eval/tiny/report.md, real y sin recortar:

# Evaluation of `tiny`
- Suite: `quick`
- Checkpoint: `checkpoints/tiny-20260919-061533/best.pt`
- Weights SHA-256: `4591cf8cca0b91b38fc3c4969c7bbf18aa5d3b4b79b36558727b981c24df281a`
- Parameters: 5,309,952
- Device: `cuda`
- Date: 2026-09-19
- MLflow run: b1836b7e8934483693df84ff2f090a3d
## Headline
| Metric | Value |
| ---------------------------------------------------- | --------------------- |
| Legality without the mask, argmax | 94.5 % |
| Legality without the mask, sampled (T=0.6, top-k 20) | 93.8 % |
| Top-1 next move | 40.3 % |
| Top-3 next move | 67.1 % |
| Puzzles solved | n/a |
| Estimated Elo | 64 (95 % CI -200-292) |
| Mean centipawn loss | n/a |
| Opening diversity | n/a |
## Legality
Two rates, because they answer different questions. **argmax** is the share of validation positions whose single most likely token is a legal move, with no temperature, no top-k and no mask: it is a property of the weights and it is the definition behind the ≥ 99 % bar of `GOAL.md`. **sampled** draws the token exactly as the demo does(T=0.6, top-k 20), so it is what a player would meet with the mask switched off, and it is always the lower of the two.
| Definition | Positions | Legal | Rate |
| ---------- | --------: | ----: | -----: |
| argmax | 1000 | 945 | 94.5 % |
| sampled | 1000 | 938 | 93.8 % |
## Next-move accuracy by Elo band
| Band | Positions | Top-1 | Top-3 |
| --------- | --------: | -----: | -----: |
| 1800-2000 | 543 | 38.9 % | 66.1 % |
| 2000-2200 | 328 | 45.4 % | 71.0 % |
| 2200-2400 | 97 | 33.0 % | 59.8 % |
| 2400-2600 | 28 | 32.1 % | 64.3 % |
| 2600+ | 4 | 50.0 % | 75.0 % |
## Games against Stockfish
| Rung | Opponent Elo | Games | W | D | L | Score | Cut | Adjudicated |
| -------- | -----------: | ----: | --: | --: | --: | ----: | --: | ----------: |
| skill-0 | 800 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| skill-1 | 950 | 20 | 0 | 1 | 19 | 0.025 | 0 | 0 |
| skill-2 | 1100 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| skill-3 | 1250 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-1320 | 1320 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-1500 | 1500 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-1800 | 1800 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
| uci-2000 | 2000 | 20 | 0 | 0 | 20 | 0.000 | 0 | 0 |
0 of 160 games hit the context limit; 0 of those were adjudicated on the final position (shallow engine analysis, or the material count when no engine was available) rather than scored as draws.
## Notes
- legality_argmax is the share of validation positions where the single most likely token is a legal move (no temperature, no top-k, no mask): this is the >= 99 % bar of GOAL.md. legality_sampled draws the token the way the demo does (temperature 0.6, top-k 20) and is always the lower of the two.
- puzzles not found at E:\work\ai\chess-lm\rukh\data\puzzles\puzzles.parquet: puzzle suite skipped
- the Elo interval covers sampling noise only: the four `skill-*` rungs are nominal `Skill Level` anchors rather than measured ratings, and Stockfish plays at 0.1 s per move, far below any setting `UCI_Elo` is calibrated for

Léelo entero, porque tiny es el contraejemplo del módulo. En tres minutos de GPU aprende bastante: la pérdida de validación baja de 4,59 a 2,02, el top-1 de siguiente jugada llega al 40,3 % y el 94,5 % de sus argmax son jugadas legales. Es muchísimo para un modelo que no ha visto un tablero en su vida. Y es del todo insuficiente: el listón de GOAL.md es ≥ 99 % de legalidad, y 94,5 % significa que una de cada dieciocho jugadas propuestas es imposible en la posición. Contra Stockfish el resultado es demoledor: cero victorias en 160 partidas, unas tablas sueltas contra el escalón más bajo, y un Elo estimado de 64 con un intervalo del 95 % que va de −200 a 292, es decir, un intervalo tan ancho que lo único que dice es “no gana nunca”. Con suite: quick no se evalúan puzles (el fichero no estaba aún en disco: mira la última línea de las notas), así que esa fila sale n/a.

tiny existe para eso: para que cuando small dé 99,4 % de legalidad y 1007 de Elo sepas de dónde viene y cuánto costó cada punto. Y para descubrir un bug de tubería en tres minutos.

// Ejercicio 03Dos entrenamientos de 300 pasos que explican la receta

Con --max-steps 300, lanza tiny tres veces: (a) tal cual; (b) con warmup: 0 en una copia de la configuración; (c) con lr: 1.0e-2. Compara las tres curvas de train/loss y de grad_norm en MLflow. Después responde: ¿cuál de las dos variantes rotas se parece más a la sana en los primeros diez pasos, y por qué es eso peligroso?

// SoluciónVer la solución

(a) baja suave desde ln(2030) ≈ 7,6. (b) sin warmup pega un salto en los primeros pasos: o la pérdida sube por encima de 8 antes de volver, o se queda estancada alrededor de 6, que es aproximadamente la entropía de la distribución de jugadas más frecuentes del corpus; el modelo ha caído en “predecir siempre lo común”. (c) con lr diez veces mayor, grad_norm se pega al techo del clipping en casi todos los pasos, señal de que el paso real es el que decide el clipping y no el optimizador; la pérdida oscila o diverge.

Lo peligroso es la comparación en los primeros diez pasos: las tres bajan. Cualquier configuración razonable baja de 7,6 a 5 en unos pocos cientos de pasos, simplemente aprendiendo la frecuencia marginal de cada jugada. Mirar la curva al principio no distingue un entrenamiento sano de uno roto; hay que esperar a la zona donde la pendiente se suaviza, o mirar métricas que no sean la pérdida (legalidad, top-1). Es la razón de que eval_every sea 250 en tiny: la señal útil llega de la validación, no del bucle.

Lab 4 · small entero, y la evaluación completa

small es el modelo del curso: 12 capas, 512 dimensiones, 8 cabezas, 38 971 392 parámetros, 20 000 pasos de 64 × 4 secuencias (51 200 tokens por paso, 1 024 millones de tokens en total), tasa 6e-4 con 1 000 de warmup.

Cuánto tardó, ya medido y no estimado: el bucle sostuvo 422 000 tokens/s de mediana en esta 5090 —423 309 en el último paso registrado—, en modo eager (torch.compile no arranca en este Windows porque no hay Triton, y el bucle cae a eager sin romperse). Mil veinticuatro millones de tokens a esa velocidad son 2 498 segundos de principio a fin: 42 minutos. El spec del proyecto estimaba entre cuatro y ocho horas; era una estimación conservadora escrita antes de medir nada, y la dejamos dicha aquí para que se vea la diferencia entre estimar y medir: el error fue de un factor de entre seis y once. Los ficheros de configuración que vienen en el repo todavía llevan la estimación vieja en un comentario.

Terminal
uv run rukh train --config configs/train/small.yaml

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

2026-09-19 08:56:21,493 step 13000 val/loss 1.5787 val/top1 0.4981
2026-09-19 08:57:23,733 step 13500 val/loss 1.5695 val/top1 0.5006
2026-09-19 08:58:25,635 step 14000 val/loss 1.5641 val/top1 0.5019
2026-09-19 08:59:27,866 step 14500 val/loss 1.5586 val/top1 0.5034
2026-09-19 09:00:29,794 step 15000 val/loss 1.5528 val/top1 0.5045
2026-09-19 09:01:32,021 step 15500 val/loss 1.5475 val/top1 0.5060
2026-09-19 09:02:33,968 step 16000 val/loss 1.5424 val/top1 0.5069
2026-09-19 09:03:36,099 step 16500 val/loss 1.5378 val/top1 0.5077
2026-09-19 09:04:37,972 step 17000 val/loss 1.5332 val/top1 0.5087
2026-09-19 09:05:40,223 step 17500 val/loss 1.5308 val/top1 0.5092
2026-09-19 09:06:42,081 step 18000 val/loss 1.5275 val/top1 0.5101
2026-09-19 09:07:44,246 step 18500 val/loss 1.5253 val/top1 0.5110
2026-09-19 09:08:46,056 step 19000 val/loss 1.5226 val/top1 0.5113
2026-09-19 09:09:48,207 step 19500 val/loss 1.5201 val/top1 0.5116
2026-09-19 09:10:50,062 step 20000 val/loss 1.5197 val/top1 0.5120
preset: small
steps: 20000
checkpoint: E:\work\ai\chess-lm\rukh\checkpoints\small-20260919-062911\step-20000.pt

Si el proceso se corta —se reinicia la máquina, se cae el driver—, no se pierde nada: cada checkpoint lleva el estado del optimizador y el de los generadores aleatorios, así que --resume checkpoints/small/step-12000.pt continúa exactamente donde estaba. Esa es la diferencia entre guardar los pesos y guardar un checkpoint.

Cuando termina, la suite completa: mil posiciones de legalidad —con sus dos definiciones—, mil de exactitud, dos mil puzles por cada una de las tres bandas y veinte partidas contra cada uno de los ocho escalones de Stockfish, 160 en total. Aquí sí hay horas, y no de entrenamiento sino de partidas; la caché SQLite hace que repetirla sea casi gratis.

Terminal
uv run rukh eval --model checkpoints/small/best.pt --suite full --stage small-greedy

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

2026-09-19 10:00:22,143 evaluating checkpoints\small-20260919-062911\best.pt on cuda
stage: small-greedy (38,971,392 parameters)
suite: full on cuda
legality: 0.9940 argmax, unmasked
0.9940 sampled, unmasked
accuracy: top1 0.5110 top3 0.7940
puzzles: 0.0107 solved
elo: 1007 (95 % CI 920-1101) over 160 games
report: E:/work/ai/chess-lm/rukh/artifacts/eval/small-greedy/report.md
results: E:/work/ai/chess-lm/rukh/artifacts/eval/small-greedy/results.json
table: E:/work/ai/chess-lm/rukh/artifacts/web/results.json

Dos cosas de esa salida antes de mirar el informe.

stage: small-greedy no es otro modelo: son los mismos pesos evaluados con el muestreo casi determinista (temperatura 0,05, top-k 1). La misma suite sobre el mismo best.pt con el muestreo de la demo (temperatura 0,6, top-k 20) es la fila small de la tabla única, y da 785 de Elo (IC 95 % 680-896) en vez de 1007. Los dos números están publicados porque miden cosas distintas: uno es la fuerza del modelo, el otro es la fuerza con la que juega la demo. La temperatura vale unos 220 puntos de Elo, más que cualquier decisión de arquitectura de este módulo.

Y la línea puzzles: 0.0107 es un error, no un resultado. Esa tirada montaba el prompt del puzle mal —le daba al modelo la posición suelta en vez del prefijo real de la partida de la que sale el puzle, que es lo que el tokenizador UCI necesita para saber dónde están las piezas— y el modelo resolvía el 1,07 %. Con el prompt arreglado y la suite repetida, la cifra real es el 22,1 %, que es la que está en el informe de abajo y en la tabla única. La métrica no estaba midiendo al modelo: estaba midiendo un bug, y “1 %” era lo bastante creíble para un modelo pequeño como para no levantar sospechas. Guárdate esa forma de fallar: una métrica baja y plausible es más peligrosa que una métrica absurda.

El informe completo que escribió la ejecución buena, artifacts/eval/small-greedy/report.md:

# Evaluation of `small-greedy`
- Suite: `full`
- Checkpoint: `checkpoints/small-20260919-062911/best.pt`
- Weights SHA-256: `7130d64ac1c409332dfd9b753281b38a7b0b434103eb1908533942e30c243f4e`
- Parameters: 38,971,392
- Device: `cuda`
- Date: 2026-09-19
- MLflow run: not tracked
## Headline
| Metric | Value |
| ---------------------------------------------------- | ----------------------- |
| Legality without the mask, argmax | 99.4 % |
| Legality without the mask, sampled (T=0.05, top-k 1) | 99.4 % |
| Top-1 next move | 51.1 % |
| Top-3 next move | 79.4 % |
| Puzzles solved | 22.1 % |
| Estimated Elo | 1007 (95 % CI 920-1101) |
| Mean centipawn loss | n/a |
| Opening diversity | n/a |
## Legality
Two rates, because they answer different questions. **argmax** is the share of validation positions whose single most likely token is a legal move, with no temperature, no top-k and no mask: it is a property of the weights and it is the definition behind the ≥ 99 % bar of `GOAL.md`. **sampled** draws the token exactly as the demo does(T=0.05, top-k 1), so it is what a player would meet with the mask switched off, and it is always the lower of the two.
| Definition | Positions | Legal | Rate |
| ---------- | --------: | ----: | -----: |
| argmax | 1000 | 994 | 99.4 % |
| sampled | 1000 | 994 | 99.4 % |
## Next-move accuracy by Elo band
| Band | Positions | Top-1 | Top-3 |
| --------- | --------: | -----: | ------: |
| 1800-2000 | 543 | 49.9 % | 78.5 % |
| 2000-2200 | 328 | 52.7 % | 79.9 % |
| 2200-2400 | 97 | 52.6 % | 82.5 % |
| 2400-2600 | 28 | 50.0 % | 78.6 % |
| 2600+ | 4 | 50.0 % | 100.0 % |
## Puzzles by difficulty band
Prompt: game-prefix.
| Band | Attempted | Solved | Rate |
| --------- | --------: | -----: | -----: |
| 1000-1500 | 2000 | 694 | 34.7 % |
| 1500-2000 | 2000 | 423 | 21.1 % |
| 2000+ | 2000 | 207 | 10.3 % |
## Games against Stockfish
| Rung | Opponent Elo | Games | W | D | L | Score | Cut | Adjudicated |
| -------- | -----------: | ----: | --: | --: | --: | ----: | --: | ----------: |
| skill-0 | 800 | 20 | 10 | 1 | 9 | 0.525 | 2 | 2 |
| skill-1 | 950 | 20 | 8 | 2 | 10 | 0.450 | 0 | 0 |
| skill-2 | 1100 | 20 | 2 | 0 | 18 | 0.100 | 0 | 0 |
| skill-3 | 1250 | 20 | 1 | 0 | 19 | 0.050 | 0 | 0 |
| uci-1320 | 1320 | 20 | 10 | 1 | 9 | 0.525 | 1 | 1 |
| uci-1500 | 1500 | 20 | 4 | 1 | 15 | 0.225 | 0 | 0 |
| uci-1800 | 1800 | 20 | 4 | 0 | 16 | 0.200 | 1 | 1 |
| uci-2000 | 2000 | 20 | 0 | 2 | 18 | 0.050 | 0 | 0 |
4 of 160 games hit the context limit; 4 of those were adjudicated on the final position (shallow engine analysis, or the material count when no engine was available) rather than scored as draws.
## Notes
- legality_argmax is the share of validation positions where the single most likely token is a legal move (no temperature, no top-k, no mask): this is the >= 99 % bar of GOAL.md. legality_sampled draws the token the way the demo does (temperature 0.05, top-k 1) and is always the lower of the two.
- the Elo interval covers sampling noise only: the four `skill-*` rungs are nominal `Skill Level` anchors rather than measured ratings, and Stockfish plays at 0.1 s per move, far below any setting `UCI_Elo` is calibrated for
- 4 of 160 games hit the context limit and were adjudicated (4 of them) instead of being scored as draws

Qué dice, sin adornos:

  • Legalidad 99,4 % por argmax. El listón de GOAL.md es ≥ 99 % y se cumple. 994 de 1 000 posiciones de validación tienen como token más probable una jugada legal, sin máscara, sin temperatura y sin top-k. Es la métrica de comprensión del módulo y es la única de las tres que pasa limpia.
  • Top-1 51,1 % y top-3 79,4 %. El modelo acierta la jugada humana una de cada dos veces, y la tiene entre sus tres primeras cuatro de cada cinco. Por tramo de Elo apenas se mueve (49,9 % en 1800-2000, 52,7 % en 2000-2200), lo cual tiene sentido: imita la media del corpus, y el corpus es sobre todo 1800-2000.
  • Puzles 22,1 %, con la pendiente que se espera: 34,7 % en 1000-1500, 21,1 % en 1500-2000 y 10,3 % en 2000+. Resolver un puzle exige acertar toda la secuencia, así que es una métrica dura, y esa caída por dificultad es la señal de que mide algo real.
  • Elo 1007, IC 95 % 920-1101. El objetivo de GOAL.md para small es ≥ 1200, y no se alcanza: faltan casi doscientos puntos y el extremo superior del intervalo se queda en 1101. Está escrito así a propósito. GOAL.md dice que si no se llega se documenta la cifra real y no se mueve el objetivo, y esta lección es donde se documenta.

Mirar la tabla de partidas explica de dónde sale ese 1007 y por qué hay que leerlo con cuidado. El modelo puntúa 0,525 contra skill-0 y contra uci-1320, se hunde a 0,100 contra skill-2 y a 0,050 contra skill-3, y vuelve a marcar 0,225 contra uci-1500. Esa no monotonía es la prueba de que los escalones no son una escala calibrada: los cuatro skill-* son valores nominales de Skill Level, no ratings medidos, y Stockfish juega a 0,1 s por jugada, muy por debajo de cualquier régimen en el que UCI_Elo signifique algo. El intervalo del 95 % solo cubre el ruido de muestreo de 160 partidas; el sesgo del banco de pruebas no está dentro de él.

Y una partida, para verlo con tus ojos, que es una forma de evaluación que ninguna tabla sustituye:

Terminal
uv run rukh play --ckpt checkpoints/small/best.pt --games 1

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

opponent: random
mask: on
game 1: 1/2-1/2 117 plies 0 illegal (stalemate)
illegal: 0 proposals

Ciento diecisiete plies contra un oponente que juega al azar, y tablas por ahogado. No es la partida lucida que uno querría enseñar, y por eso está aquí. Con la máscara puesta el contador de ilegales es 0, como tiene que ser: el muestreo enmascarado no puede producir una jugada ilegal, así que ese cero no dice nada del modelo —dice que la máscara funciona, y si alguna vez ves ahí un número distinto de cero, lo que está roto es la máscara—. El ahogado, en cambio, sí es del modelo: llegó a un final ganado contra un rival aleatorio y no supo dar mate.

La misma partida sin máscara enseña el otro lado: cada vez que el modelo propone algo ilegal se cuenta y se rescata con un muestreo enmascarado, así que la partida siempre termina y el contador illegal es la señal cruda.

Terminal
uv run rukh play --ckpt checkpoints/small/best.pt --games 1 --no-mask

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

opponent: random
mask: off
game 1: 1-0 79 plies 1 illegal (checkmate)
illegal: 1 proposals

Mate en 79 plies y una sola propuesta ilegal en toda la partida. Ese 1 es lo que conecta esta partida con la tabla de arriba, y merece la pena hacer la cuenta: es la única vez en el módulo en que una métrica agregada y un caso concreto se pueden contrastar a mano. Una legalidad por argmax del 99,4 % son 6 fallos por cada 1 000 posiciones, es decir uno cada 167 jugadas propuestas. En una partida de 79 plies el modelo mueve la mitad de las veces —el otro lado es el oponente aleatorio—, así que propone unas 40 jugadas y el número esperado de ilegales es 0,24: sale 1. Con una sola partida eso es ruido puro y no confirma nada, pero el orden de magnitud coincide, y esa es la comprobación barata que conviene hacerse siempre que tengas una métrica agregada y un caso suelto delante. Si aquí hubieran salido quince ilegales, el 99,4 % de la tabla estaría midiendo otra cosa.

// Ejercicio 04¿Cuántas partidas necesitas para creerte una mejora de 40 Elo?

Usando la fórmula de la varianza de la sección anterior —var = 1 / (c² · Σ p(1−p)) con c = 0,005756— calcula cuántas partidas con resultados cercanos al 50 % hacen falta para que la desviación típica del Elo baje a 20 puntos. Después: si comparas dos modelos, ¿basta con que sus intervalos del 95 % no se solapen?

// SoluciónVer la solución

Con p = 0,5, Σ p(1−p) = 0,25·n, así que var = 1 / (0,005756² · 0,25 n) = 1 / (8,28e-6 · n). Para una desviación de 20 hace falta var = 400, es decir n = 1 / (8,28e-6 · 400) ≈ 302 partidas. Para 10 puntos harían falta unas 1 208: la precisión va con la raíz, así que dividir el error por dos cuesta cuatro veces más partidas. Con 800 partidas la desviación ronda los 12 puntos y el intervalo del 95 % mide unos 48 de ancho: una mejora de 40 puntos es detectable, una de 15 no.

Y no, los intervalos que no se solapan no son el criterio correcto: es una prueba demasiado conservadora (dos intervalos pueden solaparse y la diferencia ser significativa). Lo correcto es estimar el intervalo de la diferencia. Y, mucho mejor que cualquiera de las dos cosas, enfrentar los dos modelos directamente entre sí: comparar A y B contra un tercero acumula el error de dos estimaciones, mientras que el emparejamiento directo mide lo que te importa. Es la misma razón por la que las listas de motores se construyen con torneos y no con puntuaciones independientes.

Lab 5 · Exportar a ONNX y sacar los mapas de atención

El navegador no ejecuta PyTorch. El puente es 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.: un formato que describe el grafo y los pesos, que onnxruntime-web sabe ejecutar con WebGPU o con WASM.

Con un detalle de diseño que vale la pena entender. El modelo devuelve logits de forma (B, T, 2030), pero la demo solo necesita la distribución de la siguiente jugada. Exportar la salida completa serían 200 veces más números por inferencia, así que el exportador envuelve el modelo y devuelve solo el último paso:

class LastStepLogits(nn.Module):
def forward(self, idx):
logits, _ = self.model(idx)
return logits[:, -1, :]

Después se generan las dos variantes con cuantizaciónCuantizaciónGuardar y calcular los pesos con menos bits de los que se entrenaron: fp16 (la mitad de tamaño, sin pérdida apreciable) o int8 dinámico (una cuarta parte, con algo de error). En Rukh se cuantizan solo las matrices (`MatMul` y `Gemm`) y cada fichero pasa una prueba de paridad contra PyTorch antes de subirse. —fp16 para WebGPU (unos 80 MB) e int8 dinámico para el respaldo en WASM (unos 40 MB), convirtiendo solo MatMul y Gemm— y se comprueba la paridad: en mil posiciones de partidas legales aleatorias, el fichero exportado debe elegir la misma jugada que PyTorch en al menos el 99,9 % de los casos, y se registra además la mayor diferencia absoluta en los logits.

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

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

exporter: dynamo (opset 18)
kind: decoder -> logits
shapes: batch dynamic=True, sequence dynamic=True (verified), block=200
fp32: artifacts/onnx/small/model.onnx (156743532 bytes)
fp16: artifacts/onnx/small/model-fp16.onnx (78804476 bytes, 0.50 of fp32, onnxconverter_common.float16)
int8: artifacts/onnx/small/model-int8.onnx (43459142 bytes, 0.28 of fp32, onnxruntime.quantization.quantize_dynamic)
parity fp32: 1.0000 on 1000 validation positions (max |delta logits| 2.551e-05)
parity fp16: 0.9980 on 1000 validation positions (max |delta logits| 0.01404)
parity int8: 0.9540 on 1000 validation positions (max |delta logits| 1.75)

Tres cosas que la exportación hace por debajo y conviene saber. Sale por el exportador dynamo de torch.onnx.export (el antiguo tracer de TorchScript sigue ahí como respaldo, y su grafo produce un fp16 que onnxruntime rechaza, así que cuál corrió se anota en el fichero). El eje dinámico de secuencia no se cree: se comprueba ejecutando el fichero a dos longitudes distintas y lo que se publica es lo que funcionó, no lo que se pidió; hace falta, porque la demo alarga la secuencia una jugada por turno. Y el contexto del modelo viaja con el fichero como metadatos rukh_* (rukh_block, rukh_vocab_size, rukh_exporter…), de modo que un .onnx suelto sigue sabiendo decir de cuántos tokens es su ventana.

El criterio es la jugada, no los logits, y el matiz importa. Pero aquí toca la segunda mala noticia del módulo: el listón del 99,9 % no lo pasa ninguna de las dos cuantizaciones.

Fichero Tamaño Paridad de jugada Max |Δ logits| ¿Pasa el ≥ 99,9 %?
fp32 156,7 MB 100 % 2,551e-05
fp16 78,8 MB 99,80 % 0,01404 no
int8 43,5 MB 95,40 % 1,75 no

fp16 se queda a una décima de milésima: elige otra jugada en 2 de cada 1 000 posiciones, y sus logits se mueven una centésima. Eso es prácticamente el mismo modelo, y como se sirve por WebGPU es el que juega en la demo cuando el navegador puede.

int8 es otra cosa. Cambia la jugada en 46 de cada 1 000 posiciones y mueve los logits hasta 1,75, que es un margen del tamaño de la diferencia habitual entre la primera y la segunda jugada. Un 95,40 % de paridad no es “casi el mismo modelo”: es un modelo medible y distinto, un 4,6 % de sus decisiones cambiadas, y no se ha medido cuánto Elo cuesta esa diferencia. Y es precisamente el fichero que descarga un móvil, porque es el respaldo en WASM cuando no hay WebGPU: los 43,5 MB frente a los 78,8 del fp16 son la razón de que exista. Así que quien juegue desde un teléfono antiguo está jugando contra una variante del modelo que la tabla única no mide.

Lo honesto es dejarlo escrito así y no redondear: el listón de GOAL.md era ≥ 99,9 % de paridad de jugada para las tres variantes, fp32 lo cumple, fp16 se queda a 99,80 % e int8 a 95,40 %. Medir el Elo del int8 con su propia tirada de partidas es trabajo para un hito posterior.

Queda el segundo script del lab: sacar los pesos de atención para la isla de esta página. Y aquí aparece el precio de usar la llamada rápida. F.scaled_dot_product_attention no devuelve la matriz de pesos, así que hay que recalcularla: se engancha un hook a la proyección qkv de cada bloque, se parten sus salidas en Q, K y V, se aplica RoPE si el modelo lo usa y se calcula el softmax a mano con la máscara triangular. Guarda esto como labs/m2/attention_export.py:

labs/m2/attention_export.py
"""Export the attention weights of one short game to artifacts/web/attention.json."""
import json
from datetime import UTC, datetime
from pathlib import Path
import chess
import torch
from torch.nn import functional as F
from rukh.models.decoder import apply_rope
from rukh.tokenize.uci_vocab import UciTokenizer
from rukh.train import load_model
CKPT = Path("checkpoints/small/best.pt")
OUT = Path("artifacts/web/attention.json")
# Legal's mate: short, famous and every move is easy to follow in the heat map.
MOVES = "e2e4 e7e5 g1f3 b8c6 f1c4 d7d6 b1c3 c8g4 f3e5 g4d1 c4f7 e8e7 c3d5".split()
model, _ = load_model(CKPT)
tok = UciTokenizer()
board = chess.Board()
for uci in MOVES: # fail loudly if the line is not legal
board.push(chess.Move.from_uci(uci))
ids = [tok.bos_id, tok.vocab["<w1800>"], tok.vocab["<b1800>"]]
ids += [tok.vocab[uci] for uci in MOVES]
labels = ["<bos>", "<w1800>", "<b1800>", *MOVES]
idx = torch.tensor([ids], dtype=torch.long)
captured: list[torch.Tensor] = []
def hook(module, args, output): # noqa: ARG001 - torch hook signature
captured.append(output.detach())
handles = [block.attn.qkv.register_forward_hook(hook) for block in model.blocks]
with torch.no_grad():
model(idx)
for handle in handles:
handle.remove()
cfg = model.cfg
weights = []
mask = torch.ones(len(ids), len(ids), dtype=torch.bool).tril()
for _layer, qkv in enumerate(captured):
q, k, _ = qkv.split(cfg.d_model, dim=2)
shape = (1, len(ids), cfg.n_head, cfg.head_dim)
q = q.view(shape).transpose(1, 2)
k = k.view(shape).transpose(1, 2)
if cfg.pos == "rope":
q = apply_rope(q, model.rope_cos, model.rope_sin)
k = apply_rope(k, model.rope_cos, model.rope_sin)
scores = (q @ k.transpose(-2, -1)) / (cfg.head_dim**0.5)
probs = F.softmax(scores.masked_fill(~mask, float("-inf")), dim=-1)[0]
weights.append([[[round(v, 4) for v in row] for row in head] for head in probs.tolist()])
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps(
{
"schema": "rukh-attention/1",
"game": {"moves": labels},
"layers": cfg.n_layer,
"heads": cfg.n_head,
"weights": weights,
"meta": {
"model": "rukh-small",
"checkpoint": str(CKPT),
"generated": datetime.now(UTC).isoformat(timespec="seconds"),
},
}
),
encoding="utf-8",
)
print(f"wrote {OUT} ({cfg.n_layer} layers x {cfg.n_head} heads x {len(ids)}^2)")
Terminal
uv run python labs/m2/attention_export.py

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

wrote artifacts\web\attention.json (12 layers x 8 heads x 16^2)

Una línea y un fichero de 211 KB. El 16^2 es el tamaño de la partida de ejemplo: trece jugadas más los tres tokens de control, 16 × 16 celdas por cabeza, 96 matrices en total. Es el fichero que pnpm sync:data copia a src/data/attention.json y que dibuja la segunda isla de esta página.

Falta un cuarto script, corto, que lee las métricas de MLflow del run y escribe la repetición del entrenamiento: labs/m2/replay_export.py produce artifacts/web/training-replay.json con un elemento por checkpoint —no por paso registrado en MLflow: el deslizador de la isla recorre los step-*.pt que dejó la tirada, que es lo único sobre lo que se puede medir algo más que la pérdida—. De cada entrada solo step está garantizado: train_loss, val_loss y val_top1 se copian si MLflow registró esa métrica en ese paso exacto (con las configuraciones del repo siempre lo hace), legality exige --with-legality y elo exige --with-elo, que juega partidas de verdad y cuesta horas. Lo que falte sale como un guion en la isla. El esquema exacto de los dos ficheros está documentado en rukh-lab/src/data/README.md; los dos llegan a esta web con pnpm sync:data.

Terminal
# Lo barato: solo las series que MLflow ya registró durante el entrenamiento.
uv run python labs/m2/replay_export.py
# Lo que se exportó de verdad: además, la legalidad sin máscara de cada checkpoint.
uv run python labs/m2/replay_export.py --with-legality --positions 400

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

step 1000 legality 0.880
step 2000 legality 0.940
step 3000 legality 0.950
step 4000 legality 0.978
step 5000 legality 0.970
step 6000 legality 0.970
step 7000 legality 0.985
step 8000 legality 0.990
step 9000 legality 0.988
step 10000 legality 0.993
step 11000 legality 0.995
step 12000 legality 0.993
step 13000 legality 0.990
step 14000 legality 0.993
step 15000 legality 0.998
step 16000 legality 0.993
step 17000 legality 0.995
step 18000 legality 0.993
step 19000 legality 0.993
step 20000 legality 0.993
20 checkpoints -> E:\work\ai\chess-lm\rukh\artifacts\web\training-replay.json

Veinte checkpoints, uno cada mil pasos, y una sola de las dos banderas caras. --with-legality cuesta una pasada hacia delante por checkpoint sobre las posiciones que pida --positions —400 aquí, unos segundos por checkpoint— y es lo que llena la fila de legalidad de la isla en los veinte pasos. --with-elo no se usó: juega partidas contra Stockfish en cada checkpoint y convierte un script de un par de minutos en una noche de cómputo, así que el Elo sí sale como un guion en los veinte. Y ojo con la resolución de lo que acabas de imprimir: con 400 posiciones, una posición vale un cuarto de punto y el ruido de muestreo ronda el ±1 punto, así que 0.993 y 0.998 son 397 y 399 aciertos de 400 y no dos niveles distintos de competencia.

// Ejercicio 05¿Por qué int8 cambia los logits y no la jugada?

Mira la salida de la exportación: la diferencia máxima de logits del fichero int8 es más de cien veces la del fp16 (1,75 frente a 0,01404), y sin embargo su paridad de jugada sigue siendo del 95,40 %, no del 5 %. Explica por qué el desastre en los logits se traduce en un desperfecto moderado en las jugadas, y di en qué tipo de posición esperarías que int8 falle.

// SoluciónVer la solución

La cuantización dinámica a int8 representa cada matriz con 256 niveles y una escala por tensor: el error relativo por peso es del orden del 0,4 %, y ese error se acumula a lo largo de doce capas. Los logits resultantes se desplazan de forma perceptible. Pero elegir una jugada no depende del valor de los logits sino de su orden, y en la mayoría de posiciones la diferencia entre el primero y el segundo es mucho mayor que el ruido introducido. fp16, con 10 bits de mantisa y el mismo rango relativo en toda la escala, introduce un error unas cien veces menor.

Int8 falla justo donde el margen es pequeño: posiciones en las que dos o tres jugadas están casi empatadas —aperturas muy conocidas con varias continuaciones estándar, finales simples donde varias jugadas mantienen la evaluación— y también en las posiciones donde el modelo está poco seguro en general. Ahí las dos jugadas suelen ser igual de razonables, así que el efecto sobre la fuerza es probablemente menor de lo que sugiere un 4,6 % de decisiones cambiadas. Pero fíjate en el “probablemente”: nadie lo ha medido, y esa discrepancia no se reparte uniformemente. Por eso la paridad se mide sobre posiciones de partidas reales y no sobre ruido, y por eso el 95,40 % se publica como un incumplimiento del listón y no como una nota al pie.

Ver el entrenamiento: TrainingReplay

Al terminar esta sección sabrás leer una curva de entrenamiento como quien lee un electrocardiograma: dónde está sano, dónde se está sobreajustando y en qué momento el modelo dejó de decir tonterías.

La isla de abajo recorre los checkpoints de la tirada real: un paso del deslizador, un step-*.pt. El deslizador elige uno; el gráfico dibuja la pérdida de entrenamiento y la de validación contra el eje izquierdo y el top-1 de siguiente jugada contra el derecho, con una línea vertical en el checkpoint seleccionado, y debajo están sus números exactos, incluidas la legalidad sin máscara por argmax —medida en los veinte checkpoints— y el Elo estimado, que en esta tirada no se midió en ninguno. Los campos que no se midieron salen como un guion, y su línea deja un hueco en vez de bajar a cero.

Tres cosas concretas que mirar, en este orden:

  1. Dónde se separan las dos pérdidas. Mientras van juntas, el modelo generaliza: lo que aprende del entrenamiento le sirve para partidas que no ha visto. El punto donde la de validación se aplana mientras la otra sigue bajando es el principio del sobreajuste, y es el punto que decide cuál es el best.pt. Con 6 millones de partidas y 39 millones de parámetros esa separación debería llegar tarde o no llegar; si llega pronto, sobran parámetros o faltan datos.
  2. Cuánto compra la segunda mitad del entrenamiento. Lleva el deslizador al paso 10 000 y compara con el 20 000. La diferencia entre esas dos cifras de top-1 es, literalmente, lo que valieron veinte minutos de GPU, y es el número con el que se decide si la tirada siguiente dura más pasos o cambia otra cosa.
  3. Cuándo despega la legalidad. Mira solo esa fila mientras llevas el deslizador del paso 1 000 al 8 000: 88,0 % → 99,0 %. Y luego sigue hasta el final y comprueba que ya no pasa nada. Es la lectura que da título al módulo y la que se desarrolla debajo de las curvas.

Lo que dibuja la isla, leído de los números y no de la forma de la curva. Los 20 checkpoints van de 2,6531 de pérdida de entrenamiento y 2,6279 de validación en el paso 1 000 a 1,4594 y 1,5197 en el 20 000, con el top-1 de validación subiendo de 0,3341 a 0,5120. Casi todo el descenso ocurre pronto: del paso 1 000 al 5 000 la validación cae 0,85 (2,6279 → 1,7822) y en los 15 000 pasos restantes solo 0,26 más. El top-1 hace exactamente lo mismo: +12,2 puntos en los primeros 5 000 pasos y +5,6 en los 15 000 siguientes; la segunda mitad entera del entrenamiento, del paso 10 000 al 20 000, compró 2,4 puntos de top-1.

El hueco entre las dos pérdidas responde a la pregunta del sobreajuste, y la respuesta no es la que uno espera. En los pasos 1 000 y 2 000 la validación va por debajo de la de entrenamiento (−0,025 y −0,015): no es magia, es que la pérdida de entrenamiento se promedia sobre una ventana de pasos en los que el modelo todavía era peor, mientras que la de validación se mide con los pesos de ese instante. Las dos curvas se cruzan en el paso 3 000 y desde ahí el hueco crece de forma monótona: +0,021 en el 10 000, +0,046 en el 15 000 y +0,060 en el 20 000. Sesenta milésimas sobre una pérdida de 1,52 es un 4 %. Hay sobreajuste, sí, y se ve; pero la curva de validación no se ha aplanado: en los últimos mil pasos todavía baja 0,003 y el top-1 todavía sube. Con 240 millones de tokens de entrenamiento —poco más de cuatro épocas de enero— 39 millones de parámetros no son demasiados parámetros. Por eso el best.pt de esta tirada es el último checkpoint y no uno intermedio rescatado antes del desastre, y por eso la conclusión honesta del módulo es que a small no lo limita el sobreajuste sino el presupuesto: entrenar más pasos era la mejora barata que este módulo no llegó a probar, y esos +2,4 puntos de la segunda mitad son la cifra que habría que batir.

Y ahora la fila que hace de este módulo el de ver emerger el juego. El modelo no tiene tablero, no tiene reglas y no sabe qué es un alfil: solo ve secuencias de cuatro caracteres. En el paso 1 000 propone una jugada imposible en el 12 % de las posiciones —legalidad 88,0 %, 352 aciertos de 400—; en el paso 8 000 ya respeta las reglas en el 99,0 %, 396 de 400. Once puntos en siete mil pasos. Y ahí se para: del paso 8 000 al 20 000 la legalidad pasa de 99,0 % a 99,25 %, tres décimas de punto en los 12 000 pasos restantes, mientras la pérdida de validación todavía baja de 1,6727 a 1,5197 y el top-1 sube de 47,78 % a 51,20 %. La legalidad satura mucho antes que la pérdida.

Antes de sacarle punta a la curva, el ruido. Cada punto se midió sobre 400 posiciones de validación, así que un acierto o un fallo valen un cuarto de punto y el error de muestreo ronda ±1 punto. Por eso a partir del paso 10 000 la serie no sube en línea recta sino que vibra entre 99,0 % y 99,75 %: el 99,75 % del paso 15 000 y el 99,25 % del 20 000 son 399 y 397 posiciones legales de 400, una diferencia de dos posiciones. Quien concluya de ahí que el checkpoint 15 000 sabe más reglas que el final está leyendo ruido como si fuera señal. Lo único que esa parte de la curva dice es “ya está”; lo que significa algo es el tramo de subida, del paso 1 000 al 8 000.

Y de ahí sale el argumento más importante de la lección: legalidad y fuerza son cosas distintas. En el paso 8 000 el modelo ya cumple las reglas casi perfectamente y juega bastante peor que en el 20 000: misma legalidad dentro del ruido, pero una pérdida de validación un 10 % más alta y 3,4 puntos menos de top-1, medidos sobre las mismas partidas de validación. Saber que un alfil va en diagonal no es saber adónde conviene moverlo. Por eso el listón del ≥ 99 % de GOAL.md es un requisito de entrada y no una medida de calidad, y por eso la fila de la tabla única no se queda en la legalidad y trae además top-1, puzles, Δcp y Elo.

Lo que sigue abierto es la otra pregunta, la cara: el Elo por checkpoint. La isla tiene sitio para él y en esta tirada sale como un guion en los veinte, porque --with-elo juega partidas contra Stockfish en cada uno y cuesta una noche de cómputo. Sin ese dato se puede decir cuánta imitación compró cada tramo de pasos, pero no cuánta fuerza; y con la legalidad ya saturada desde el paso 8 000, la fuerza es justo lo que falta por ver. Queda anotada como tal, no resuelta de memoria.

Pérdida y exactitud top-1 a lo largo del entrenamiento20 checkpoints, del paso 1000 al 20000. Los valores exactos del checkpoint seleccionado están en la lista que sigue al gráfico.2,751,3652,6 %32,0 %paso 1000paso 20000
Eje izquierdo: pérdida (entrenamiento y validación). Eje derecho: top-1 de siguiente jugada en validación. La línea vertical marca el checkpoint seleccionado.

pérdida de entrenamiento pérdida de validación top-1 de validación

Paso
20000
Pérdida (train)
1,459
Pérdida (val)
1,520
Top-1 (val)
51,2 %
Legalidad sin máscara
99,3 %
Elo estimado

small-20260919-062911 · preset small · 20 checkpoints · generado 2026-09-19T08:49:05+00:00

Ver la atención: AttentionMap

Al terminar esta sección sabrás mirar una matriz de atención sin contarte una película: qué patrones son reales, cuáles son artefactos y qué no se puede concluir de un mapa bonito.

Abajo está la matriz de una cabeza de una capa sobre una partida corta (el mate de Légal, trece jugadas más los tokens de control). Cada fila es una jugada que mira; cada columna, una jugada mirada. La mitad superior derecha está siempre vacía: eso es la máscara causal, que ahora puedes ver en lugar de creértela. El color es relativo al máximo de la cabeza seleccionada, así que comparar intensidades entre cabezas distintas no significa nada; comparar dentro de una, sí. El valor exacto de cada celda está en su título y en el resumen de arriba, que te dice las tres jugadas a las que más mira la fila que elijas con el selector.

Qué buscar mientras cambias de capa y de cabeza:

  • La diagonal encendida. La casilla inmediatamente a la izquierda de la diagonal es “la jugada anterior”. Suele haber alguna cabeza dedicada a eso, y tiene todo el sentido: la jugada anterior es el vecino más informativo de una secuencia. En este modelo la hay —una sola, y no en la capa en la que la buscarías—; el apartado que sigue a la lista dice cuál y con qué números.
  • Columnas verticales. Una columna entera encendida es una jugada a la que mira todo el mundo, normalmente porque acumula contexto. El <bos> del principio suele comportarse así en varias cabezas y, en los Transformers de texto, se conoce como sumidero de atención: la cabeza no tiene nada que mirar y vuelca su peso en el primer token, que es su forma de decir “no aporto nada aquí”. Si ves una columna así en el <bos>, no es un hallazgo: es el estado de reposo.
  • Bloques y saltos. Lo interesante son las celdas lejos de la diagonal: la jugada 12 mirando a la 5, que es cuando se movió la pieza que ahora se captura. Algunas cabezas de capas intermedias hacen eso, y es lo más cerca que se está de ver el modelo “razonando” sobre el tablero.
  • Diferencias entre capas. Lo que se cuenta habitualmente es que las primeras capas tienen patrones locales y regulares, las del medio los más estructurados y las últimas los más difusos, porque ya trabajan sobre representaciones muy mezcladas. Es una expectativa razonable y conviene comprobarla antes de repetirla: en este modelo la mitad de esa frase no se cumple.

Lo que hay de verdad en este attention.json. Lo que sigue no es lo que se ve “a ojo” en el mapa: es el resultado de leer las 96 matrices de 16 × 16 con un script de veinte líneas, porque mirar colores es precisamente la forma de contarse una película. Salen tres cosas: una limpia, una que contradice el punto de arriba y una que hay que dejar sin concluir.

Lo limpio: hay una cabeza de jugada anterior, y es inequívoca. Es la capa 9, cabeza 4 (L9H4), con una media de 0,80 de peso sobre la celda inmediatamente a la izquierda de la diagonal, frente a un máximo de 0,65 en las otras noventa y cinco. Y no es una media de valores tibios: en 11 de sus 15 filas el máximo cae justo ahí, con 0,988 (b1c3 mirando a d7d6), 0,994 y luego 1,000, 1,000, 1,000, 1,000 en las filas de f3e5, g4d1, c4f7 y e8e7. Esa cabeza no hace otra cosa. Detrás van L8H7 (0,65 de media), L7H7 (0,60) y L4H3 (0,58); las 92 restantes se quedan por debajo de 0,45. Las filas en las que L9H4 se sale del guion son las tres primeras jugadas negras (e7e5, b8c6, d7d6), donde pone el peso en sí misma (0,982 y 0,919 sobre la diagonal). No sé por qué, y prefiero dejarlo escrito así a inventarme una explicación.

Lo que contradice el punto de arriba: la capa 0 no tiene patrones locales; no tiene ninguno. Sus ocho cabezas son casi indistinguibles entre sí y casi uniformes. Midiendo cada fila contra la distribución uniforme sobre su propio pasado, la divergencia KL de las ocho cabezas de la capa 0 va de 0,23 a 0,34, cuando la media del modelo entero es 0,72 y L9H4 llega a 1,90; en la última fila, la celda más alta de L0H0 es el <bos> con 0,198 cuando lo uniforme serían 0,062. La primera capa reparte, y ya está. La otra mitad de la expectativa sí se cumple: las capas 10 y 11 vuelven a ser difusas —la atención a la jugada anterior cae a 0,07-0,17— y vuelcan la masa en los tokens de control; en la capa 11, siete de las ocho cabezas tienen el máximo de su última fila en el <bos>, entre 0,345 y 0,513, y la octava en <b1800> con 0,570. Con un matiz que el punto del sumidero no anticipaba: aquí el sumidero no es solo el <bos>. Promediando el modelo entero sobre las filas de jugada, el <bos> se lleva 0,229 de la atención y <b1800> —el último token de control, el que va justo antes de la primera jugada— 0,226, prácticamente lo mismo. Y hay cabezas dedicadas a él en exclusiva: L3H7 pone entre 0,746 y 0,986 sobre <b1800> en ocho filas seguidas.

Y lo que hay que dejar sin concluir, que era lo que más ganas había de encontrar. Sí aparecen celdas lejos de la diagonal con pesos altísimos, y son tentadoras: L5H5 pone 0,999 sobre d7d6 justo en la fila en la que toca escribir f3e5, que es el caballo capturando en e5 después de que ese d7d6 dejara de defender el peón; L4H0 pone 0,987 sobre f1c4 en la fila de g4d1. Es exactamente la película que el aviso de aquí abajo pide no contarse, y los números explican por qué: esas mismas cabezas apuntan al mismo token en varias filas seguidas. L5H5 tiene su máximo en d7d6 en tres filas distintas, y L1H3 lo tiene en e7e5 en ocho de las suyas. Eso se parece mucho más a “esta cabeza se ha enganchado a una columna” que a “esta cabeza busca la pieza que se captura”, y con una partida de 16 tokens no hay manera de distinguir las dos cosas. Para distinguirlas harían falta un centenar de partidas y una estadística sobre ellas; lo de abajo es un instrumento de depuración, no un experimento.

La jugada 1 (<bos>) reparte su atención sobre todo en: 1 <bos> (100,0 %)

Capa 0, cabeza 0. Cada fila es una jugada que mira; cada columna, una jugada mirada. El color es relativo al máximo de esta cabeza (100,0 %) y el valor exacto está en el título de cada celda.
Jugada que miraJugada 1, <bos>Jugada 2, <w1800>Jugada 3, <b1800>Jugada 4, e2e4Jugada 5, e7e5Jugada 6, g1f3Jugada 7, b8c6Jugada 8, f1c4Jugada 9, d7d6Jugada 10, b1c3Jugada 11, c8g4Jugada 12, f3e5Jugada 13, g4d1Jugada 14, c4f7Jugada 15, e8e7Jugada 16, c3d5
1 <bos>100,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
2 <w1800>50,2 %49,8 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
3 <b1800>38,8 %31,8 %29,4 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
4 e2e433,8 %30,7 %23,1 %12,4 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
5 e7e531,9 %28,1 %24,1 %10,4 %5,5 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
6 g1f331,2 %28,5 %17,7 %8,2 %5,7 %8,6 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
7 b8c628,3 %27,8 %17,8 %7,6 %4,7 %7,7 %6,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
8 f1c427,3 %27,1 %15,8 %7,2 %4,4 %7,3 %4,4 %6,5 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
9 d7d625,4 %24,3 %14,1 %6,6 %4,0 %7,0 %5,0 %5,7 %8,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
10 b1c326,2 %21,5 %18,4 %6,4 %3,9 %6,6 %4,3 %4,4 %4,4 %3,8 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
11 c8g425,2 %23,2 %14,5 %6,0 %3,8 %6,1 %4,2 %4,5 %4,4 %3,7 %4,3 %0,0 %0,0 %0,0 %0,0 %0,0 %
12 f3e522,2 %21,1 %12,8 %5,9 %5,1 %7,4 %4,3 %5,2 %5,3 %4,0 %3,8 %2,9 %0,0 %0,0 %0,0 %0,0 %
13 g4d129,2 %23,1 %12,4 %5,4 %3,8 %5,0 %3,1 %3,3 %2,8 %2,6 %4,1 %2,8 %2,5 %0,0 %0,0 %0,0 %
14 c4f728,7 %21,3 %15,6 %5,5 %2,9 %4,5 %2,5 %3,7 %3,1 %2,9 %2,4 %2,1 %2,5 %2,2 %0,0 %0,0 %
15 e8e722,3 %19,5 %14,8 %5,8 %3,8 %4,9 %2,9 %3,8 %4,3 %2,9 %2,4 %2,4 %2,4 %2,5 %5,4 %0,0 %
16 c3d519,8 %17,2 %14,1 %5,8 %3,6 %5,7 %4,0 %3,8 %3,8 %3,3 %3,7 %2,9 %3,3 %2,7 %3,8 %2,7 %

0100,0 %Diagonal encendida: cada jugada mira a la anterior. Columna encendida: una jugada a la que mira todo el mundo.

rukh-small · checkpoint checkpoints\small-20260919-062911\best.pt · generado 2026-09-19T08:27:28+00:00

Por qué el modelo “aprende el tablero” sin verlo

Al terminar esta sección sabrás qué se ha demostrado exactamente sobre los modelos de lenguaje de ajedrez y sus representaciones internas, y —más importante para no hacer el ridículo— qué no.

El hecho que sostiene este módulo entero es raro cuando te paras a pensarlo. El modelo nunca ha visto un tablero. No tiene casillas, ni piezas, ni reglas; tiene 2 030 símbolos y seis millones de secuencias. Y acaba proponiendo jugadas legales el 99,4 % de las veces, lo que exige saber dónde está cada pieza después de cuarenta movimientos, incluida la torre que no se mueve desde la jugada 3. Esa información no está en el token actual: está repartida por toda la historia y hay que reconstruirla.

La lectura obligatoria de este módulo es el trabajo de Adam Karvonen, Chess-GPT’s Internal World Model (2024). Es corto, es claro y es la referencia que usa el curso para calibrar expectativas: un nanoGPT de 50 millones de parámetros, entrenado sobre 16 millones de partidas de Lichess en PGN a nivel de carácter, alcanzó alrededor de 1300 de Elo con un 99,8 % de jugadas legales en un día de GPU. Léelo en adamkarvonen.github.io; el código y los modelos están publicados con licencia MIT.

Conviene poner esa cifra al lado de la nuestra sin maquillarla: 1300 de Elo y 99,8 % de legalidad, frente a los 1007 y 99,4 % que mide small. Karvonen usó 16 millones de partidas y un día de GPU; nosotros, 5,9 millones y 42 minutos, con un modelo un 22 % más pequeño. El curso no está igualando ese resultado, y la lección no finge que sí.

Qué se demostró exactamente. El método son sondas lineales (linear probes). Se congela el modelo, se toman las activaciones de una capa intermedia en una posición concreta de la secuencia, y se entrena una regresión lineal —nada más: una matriz, sin capas ocultas— para predecir el estado del tablero, casilla a casilla, a partir de esas activaciones. La sonda acierta qué pieza hay en cada una de las 64 casillas con una exactitud altísima. Es decir: el estado del tablero está presente en las activaciones del modelo de forma linealmente legible, aunque nadie se lo enseñara y aunque su entrada sean caracteres de texto. Se entrenaron también sondas para la fuerza estimada de los jugadores, con resultados análogos.

Y hay una segunda parte, que es la que convierte una correlación en algo más fuerte. Karvonen interviene: toma la dirección que la sonda asocia a “hay una pieza en esta casilla” y modifica las activaciones del modelo a lo largo de esa dirección, como si borrase una pieza del tablero interno. Las jugadas que el modelo propone después cambian de forma coherente con el tablero editado. Eso descarta la explicación aburrida de que la sonda esté leyendo algo incidental: la representación no solo está ahí, sino que el modelo la usa.

Qué no se demostró, y esto importa más para tu criterio:

  • No se demostró que el modelo tenga reglas. Que pueda reconstruir el tablero no implica que represente “el alfil se mueve en diagonal”; puede haber aprendido las regularidades estadísticas que el tablero produce, sin la regla que las genera. Las dos cosas son distintas, y la segunda generaliza a posiciones raras mientras que la primera no.
  • No se demostró que busque ni calcule. No hay nada parecido a una exploración de variantes: hay una única pasada hacia delante con un número fijo de operaciones. Ese es, seguramente, el techo de este enfoque, y es el motivo de que 2895 de Elo se consiguieran con 270 millones de parámetros y diez millones de partidas anotadas por Stockfish, no con trucos.
  • No se demostró que cualquier representación interna sea recuperable. Una sonda lineal encuentra lo que es linealmente legible; que no encuentres algo no significa que no esté, solo que no está de esa forma. El resultado negativo de una sonda no es un resultado.
  • No se demostró nada sobre nuestra tokenización. Karvonen usó PGN por carácter; Rukh usa una jugada por token. Es razonable esperar un comportamiento parecido o mejor —al modelo se le ahorra reconstruir la noción de jugada— pero es una hipótesis hasta que la midas, y por eso M3 monta sondas lineales sobre nuestro propio encoder en vez de citar el artículo.
  • Y una trampa lógica que conviene nombrar: el estado del tablero es una función determinista de la lista de jugadas, así que la información está necesariamente en la entrada. Lo sorprendente no es que esté, sino que el modelo la haya destilado en una forma explícita y lineal; esa es la frontera exacta entre “memoriza la secuencia” y “modela el dominio”.

Qué has aprendido, cómo se mide

Has escrito un Transformer decoder entero, línea a línea, y puedes defender cada decisión: por qué los embeddings son 2 030 × 512, por qué la máscara causal no es un detalle sino la definición de la tarea, por qué ocho cabezas de 64 valen más que una de 512 al mismo precio, por qué pre-norm hace entrenable una pila de doce, por qué el weight decay no toca las normas y por qué bf16 te ahorra una noche de NaN. Lo has entrenado con una receta que sabes justificar número a número, lo has medido con un harness que no se engaña a sí mismo y lo has puesto a jugar en un navegador.

Lo que salió distinto de lo previsto

Cuatro cosas de este módulo no fueron como estaban escritas antes de medir, y las cuatro enseñan más que los aciertos.

  1. El entrenamiento duró 42 minutos, no de cuatro a ocho horas. El spec estimaba un orden de magnitud de más. La estimación se hizo sin haber medido nunca el bucle en esta GPU, y el error no es anecdótico: si la cifra buena hubiera sido la estimada, medio diseño del curso (iterar, repetir, probar tres tokenizaciones) habría sido inviable. Medir una vez al principio vale más que estimar diez veces.
  2. La temperatura de muestreo vale unos 220 puntos de Elo. Los mismos pesos dan 1007 con muestreo casi determinista y 785 con la temperatura 0,6 de la demo. Es más de lo que separa a small de su objetivo, y más de lo que cambiaría cualquier ajuste de arquitectura que quepa en este módulo. La consecuencia práctica: un Elo publicado sin decir con qué muestreo se midió no significa nada.
  3. El int8 es un modelo medible y distinto. 95,40 % de paridad de jugada es un 4,6 % de decisiones cambiadas, y ese es justo el fichero que descarga un teléfono sin WebGPU. La intuición cómoda (“int8 mueve los logits pero no la jugada”) es verdad para fp16 y solo a medias para int8.
  4. La métrica de puzles no midió nada hasta que se arregló el prompt. Daba 1,07 % porque le pasaba al modelo la posición del puzle sin el prefijo real de la partida; con el prefijo da 22,1 %. Veinte veces de diferencia, y el número malo era perfectamente creíble. Una métrica nueva no vale hasta que se comprueba con un caso cuyo resultado se conoce de antemano.

Cómo se mide el módulo 2. Todo lo que sigue es un número o un test:

  • Legalidad sin máscara por argmax y muestreada. Es la primera columna de la tabla única y la métrica de comprensión: el objetivo de GOAL para small es ≥ 99 % en el argmax. Medido: 99,4 %, el listón se cumple. tiny se queda en 94,5 %.
  • Top-1 y top-3 de siguiente jugada contra la humana, global y por tramo de Elo. Medido: 51,1 % y 79,4 %. Baja la línea de base para todo lo que venga después.
  • Puzles resueltos por tramo de dificultad, con la secuencia completa como criterio. Medido: 22,1 % (34,7 / 21,1 / 10,3 % por tramo).
  • Elo estimado con intervalo de confianza del 95 % por bootstrap sobre las partidas contra los ocho escalones de Stockfish. Objetivo de GOAL: ≥ 1200 para small. Medido: 1007 (IC 920-1101) con muestreo casi determinista y 785 (IC 680-896) con la temperatura 0,6 de la demo. No se alcanza el objetivo, y la regla se aplica tal cual está escrita: se documenta la cifra real, no se mueve el objetivo.
  • Paridad ONNX ≥ 99,9 % de jugadas idénticas a PyTorch en 1 000 posiciones, para fp32, fp16 e int8, más la diferencia máxima de logits de cada uno. Medido: 100 % en fp32, 99,80 % en fp16 y 95,40 % en int8; las dos cuantizaciones se quedan por debajo del listón.
  • Tests unitarios que se quedan para siempre: formas (B, T, V), causalidad con diferencia exactamente nula en el pasado, pérdida escalar con ignore_index=0, num_params de small entre 38 y 45 millones, determinismo con semilla, y mil muestreos enmascarados sin una sola jugada ilegal.
  • Reproducibilidad: cada checkpoint lleva el paso, la configuración, el hash del vocabulario, el del manifiesto de datos y el SHA de git, y cada run está en MLflow con sus curvas.

Lo siguiente es M3, el encoder: el mismo bloque sin la máscara causal, entrenado con una tarea distinta (tapar jugadas en medio de la partida y reconstruirlas) para producir representaciones que miran hacia los dos lados. Sobre él montarás cabezas de valor y de error, y las sondas lineales que aquí has leído en un artículo las vas a entrenar tú sobre tu propio modelo. Y en M4 volverás a estos mismos pesos para enseñarles a jugar como un 1500 o como un 2200 cambiando dos tokens del principio.

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

// cheatsheet M2

Ocho preguntas para llevarte

01Explica la atención en tres frases.
Cada posición proyecta su vector en una consulta (Q), una clave (K) y un valor (V). El producto escalar entre la consulta de una posición y las claves de todas las demás, dividido por la raíz de la dimensión de la cabeza y pasado por softmax, da un reparto de pesos que suma 1. La salida es la media de los valores ponderada por esos pesos: softmax(QKᵀ/√d)·V. La escala 1/√d existe porque el producto escalar de d dimensiones tiene varianza d y sin ella el softmax saturaría y el gradiente se anularía.
02¿Qué es la máscara causal y qué pasa si falta?
Poner a menos infinito, antes del softmax, el peso de toda clave posterior a la consulta, de modo que la matriz de atención queda triangular inferior. Sin ella la posición t puede leer el token t+1, que es justo el que tiene que predecir: la tarea pasa de predecir a copiar, la pérdida se desploma y el top-1 se dispara, pero al generar (donde no hay futuro) el modelo juega al azar. Se detecta porque la legalidad sin máscara se hunde, y se verifica con un test: cambiar un token futuro no debe mover los logits pasados ni un decimal.
03¿Por qué pre-norm y no post-norm?
Pre-norm pone el LayerNorm dentro de la rama, antes de la subcapa (x = x + attn(ln(x))), así que la conexión residual queda libre: la derivada de cada bloque es la identidad más la de la subcapa y el gradiente llega íntegro desde la pérdida hasta el embedding. Post-norm normaliza después de la suma, mete la normalización en el camino residual y hace que una pila profunda solo entrene con warmup largo e inicialización muy cuidada. Es un cambio de dos caracteres que convierte doce capas entrenables con suerte en doce capas entrenables.
04¿Qué hace el warmup y qué pasa sin él?
Sube la tasa de aprendizaje linealmente desde cero durante los primeros pasos (1 000 en rukh-small) antes de empezar el coseno. En los primeros pasos, Adam estima el segundo momento con un puñado de gradientes y su paso normalizado puede mover un parámetro tanto como la tasa entera; sobre una red recién inicializada eso satura la atención y el modelo cae en predecir siempre las jugadas más frecuentes. No suele explotar con un NaN: se queda en una pérdida mediocre que ya no baja, que es peor porque no se nota.
05Temperatura y top-k: ¿en qué se diferencian?
La temperatura divide los logits antes del softmax y reescala toda la distribución: por debajo de 1 la concentra (con 0 es el argmax), por encima la aplana. El top-k no reescala, recorta: deja solo los k tokens de mayor probabilidad y pone el resto a cero. Son complementarios, no alternativas. Con 2 030 jugadas, la cola de mil opciones improbables suma un porcentaje apreciable de disparates; el top-k la elimina y la temperatura decide cuánto arriesga el modelo dentro de lo que queda. Rukh usa temperatura 0,6 y k = 20.
06¿Qué mide la legalidad sin máscara, por qué hay dos cifras y por qué no vale medirla con máscara?
Mide el porcentaje de jugadas legales que propone el modelo cuando se le deja emitir el token que quiera, sobre 10 000 posiciones de validación. Se publica dos veces: legality_argmax toma el token más probable (sin temperatura ni top-k) y legality_sampled saca uno como lo saca la demo (temperatura 0,6 y top-k 20), que es siempre la menor de las dos porque muestrear mete cola. El listón de ≥ 99 % para rukh-small es el argmax, porque habla de lo que saben los pesos y no de un ajuste del muestreador; la muestreada es lo que experimenta un jugador y va al lado con su definición. Con la máscara de legalidad puesta el 100 % está garantizado por construcción, así que estarías midiendo a python-chess y no al modelo.
07¿Por qué bf16 y no fp16?
Los dos ocupan 16 bits, pero reparten distinto. fp16 tiene 5 bits de exponente y 10 de mantisa: mucha precisión y poco rango, con el mínimo normal en torno a 6e-5, justo donde viven los gradientes de una red profunda, así que se van a cero y hace falta un GradScaler con escalado dinámico de la pérdida. bf16 tiene 8 bits de exponente (el mismo rango que fp32) y 7 de mantisa: menos precisión por número y nada que se desborde por abajo, así que entrena sin escalar nada. En una GPU moderna es nativo, así que la simplicidad sale gratis.
08¿Qué es un checkpoint y qué tiene que llevar dentro?
Una fotografía del entrenamiento en disco. No basta con los pesos: lleva el paso alcanzado, el estado del optimizador (los momentos de Adam), el estado de los generadores aleatorios, la configuración del modelo y de la tirada y la procedencia (hash del vocabulario, hash del manifiesto de datos y SHA de git). Con eso se puede reanudar exactamente donde se cortó, evaluar el modelo meses después y decir con qué datos exactos se entrenó. Rukh escribe uno cada 1 000 pasos más best.pt por pérdida de validación.
09¿Qué es la perplejidad?
La exponencial de la entropía cruzada media. Se lee como entre cuántas opciones equiprobables duda el modelo: con un vocabulario de 2 030 jugadas, un modelo sin entrenar tiene perplejidad 2 030 y pérdida ln(2030) ≈ 7,6; una pérdida de 3,0 es perplejidad 20, es decir, acierta como si eligiera al azar entre veinte jugadas. Como en una posición típica hay unas treinta legales, esa cifra ya implica bastante aprendizaje. No es una métrica aparte: es la misma pérdida en otra escala.
10¿Qué significa un intervalo de confianza del Elo y por qué importa?
El Elo estimado es el valor que mejor explica los resultados contra rivales de fuerza conocida según la fórmula logística P = 1/(1+10^((rival−modelo)/400)); el intervalo del 95 % dice cuánto podría moverse ese valor si se repitieran las partidas, y se calcula por bootstrap remuestreando los resultados y reajustando mil veces. Con 100 partidas al 50 % la desviación típica ronda los 35 puntos; con las 800 de la suite completa, unos 12. Es decir: una mejora de 10 o 20 puntos de Elo medida con cien partidas no es una mejora, es ruido, y presumir de ella es el error clásico de los benchmarks.
Todas las cheatsheets, imprimibles →