rukh · lab

// M1 · lección 01

Del PGN al tensor: qué es un token

La primera decisión de modelado del curso: qué es un token. Tres tokenizaciones de la misma partida, los tokens especiales como interfaz de control, padding y máscaras, las fugas entre splits, y el pipeline que convierte seis millones de partidas de Lichess en tensores.

  • tiempo de trabajo50 min
  • nivel base
  • actualizado el23 de septiembre de 2026

// el recorrido · dónde estás

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

entra

  • 6 M partidas en PGN
  • evaluaciones de Lichess

se construye

  • PGN → UCI
  • posiciones y pares
  • tres tokenizadores
  • lotes en memmap

sale

  • 6 datasets en el Hub
  • tokenizador Python y TS

el aprendiz, ahora

Le enseñas el alfabeto: qué es una jugada escrita. Y ordenas la biblioteca para que pueda leer millones de partidas sin esperar.

por qué este paso · fuera del ajedrez

Lo que un modelo puede aprender depende de qué es un token y de qué datos ve. Un error aquí viaja hasta el final sin avisar.

Lección de apertura del módulo «Del PGN al tensor». Es la única en la que no escribes código: aquí se deciden las cosas y en las nueve siguientes se escriben.

Qué vas a construir

Piensa en Rukh como un aprendiz de ajedrez. En M0 le montaste el club: el tablero, el árbitro, el cuaderno de bitácora y una biblioteca con seis millones de partidas. Pero el aprendiz todavía no sabe leer. En este módulo le enseñas el alfabeto, cómo se escribe una jugada, y ordenas la biblioteca para que pueda leer deprisa. Cualquier LLM pasa por esta etapa antes de entrenar: limpiar los datos y decidir cómo se trocea el texto.

Las partidas están en data/raw/, en parquetParquetFormato de fichero columnar y comprimido. Lichess publica sus partidas así en Hugging Face, y DuckDB puede filtrarlas en remoto leyendo solo las columnas y los bloques necesarios (predicate pushdown), lo que hace posible recortar 73 GB a unos pocos sin descargarlo todo., con las jugadas escritas como en los libros (1. e4 e5 2. Nf3) y un manifiestoManifiesto de datosFichero JSON que acompaña a cada recorte con los filtros exactos, los meses, los conteos y el hash de cada fichero. Sin manifiesto no hay reproducibilidad: es lo que permite decir qué datos vio un modelo. que dice de dónde salieron. Un modelo no lee eso: lee tensores de enteros, de tamaño fijo, servidos en lotes. Este módulo recorre el camino entre las dos cosas, y en él aparece la primera decisión de modelado del curso: qué es un tokenTokenUnidad 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..

Al terminar tendrás:

  • El pipeline rukh data ejecutado de principio a fin: partidas limpias en 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., posiciones cruzadas con evaluaciones de Stockfish, puzles con split fijo, pares de preferencia para DPO, la base Elite y un muestreo equilibrado por tramo de EloEloEscala de fuerza de juego: una diferencia de 200 puntos implica que el mejor gana unas tres de cada cuatro veces. Lichess usa Glicko-2, compatible en la práctica. Rukh estima el Elo de cada modelo jugando contra Stockfish limitado y publica el intervalo de confianza.. Seis datasets con su card, listos para subir a Hugging Face.
  • Tres tokenizadores de la misma partida: uno de vocabularioVocabularioLa lista de todos los tokens que el modelo conoce, cada uno con un id entero fijo. En Rukh el vocabulario UCI tiene 2 030 entradas: 8 tokens especiales, 54 tramos de Elo y las 1 968 jugadas posibles, enumeradas sin mirar datos. Su tamaño fija el de la capa de embeddings y el de la capa de salida. fijo (una jugada, un token), uno por carácter sobre SANSANNotación algebraica estándar, la de los libros: Nf3, O-O, exd5+. Es compacta para humanos pero ambigua sin el tablero (hay que saber qué caballo puede ir a f3). Lichess publica las partidas en SAN; Rukh las convierte a UCI con python-chess. y uno aprendido con BPEBPE (byte-pair encoding)Tokenizador aprendido de los datos: parte de los caracteres, fusiona una y otra vez el par de tokens adyacentes más frecuente y guarda cada fusión en orden hasta llegar al tamaño de vocabulario pedido (4 096 en Rukh). Sobre texto UCI redescubre primero las casillas, luego las jugadas y al final trozos de apertura. Nada queda fuera de vocabulario: lo raro se parte en trozos más cortos., con una tabla medida que dice cuántos tokens ocupa una partida con cada uno.
  • Un dataloaderDataloaderEn PyTorch, el objeto que toma un Dataset (que sabe cuántos ejemplos hay y cuál es el ejemplo i), los baraja con una semilla, los agrupa en lotes y los prepara en paralelo con varios workers mientras la GPU calcula el lote anterior. El de Rukh sirve ventanas (x, y) de 200 tokens alineadas a <bos>. que sirve lotes (x, y) de 200 tokens desde un fichero memmapMemmapFichero mapeado a memoria: np.load(..., mmap_mode="r") no lee el array, sino que lo mapea en el espacio de direcciones y el sistema operativo trae las páginas cuando se tocan. Un GB de tokens uint16 arranca al instante y varios workers comparten las mismas páginas físicas sin copiar nada. sin cargarlo en memoria, con el paddingPaddingRelleno con el token <pad> (id 0) para que todas las secuencias de un lote tengan la misma longitud, porque la GPU quiere tensores rectangulares. La pérdida lo ignora con ignore_index. En Rukh casi no hay padding: las partidas se empaquetan en flujo y solo la última ventana se rellena. ignorado por la pérdida.
  • El tokenizador UCI escrito dos veces, en Python y en TypeScript, con una fixture de paridad que garantiza que el navegador y el entrenamiento hablan el mismo idioma.

Todo lo de aquí lo heredan los módulos siguientes: el decoder de M2 se entrena sobre estos lotes, el token de Elo de M4 se decide en esta lección y los pares DPO de M5 salen de este pipeline. Un error en los datos no se nota hasta muy tarde, y por eso este módulo va despacio.

Teoría justa

Qué es un token y por qué la elección importa

Un modelo de lenguaje predice el siguiente símbolo de una secuencia. La pregunta que casi nadie se hace, porque en los LLM de texto viene decidida de fábrica, es qué cuenta como símbolo. Un token es la unidad mínima que el modelo lee y escribe; el tokenizador convierte texto en una lista de enteros (ids) y de vuelta. El modelo nunca ve letras ni jugadas: ve ids, y para cada id aprende un vector.

Piensa en la jugada e2e4. Hay dos formas obvias de dársela al modelo:

  • Como un solo token. Hay un id para e2e4, otro para e7e5, otro para g1f3. Una partida de 40 jugadas son 80 tokens, más unos pocos de control.
  • Como cuatro caracteres. e, 2, e, 4. La misma partida son unos 320 tokens, y el modelo tiene que descubrir que los caracteres van de cuatro en cuatro y que e2e4 no tiene nada que ver con e2e3 aunque compartan tres de cuatro.

La diferencia se nota en tres sitios:

  1. Longitud de secuenciaSecuenciaLa lista ordenada de ids que el modelo recibe como entrada. En Rukh, una partida codificada: <bos>, los dos tramos de Elo, las jugadas, el resultado y <eos>. Su longitud depende de la tokenización: unos 80 tokens por partida en UCI, cuatro o cinco veces más por carácter.. Con un token por jugada, el 99,8 % de las partidas de nuestro recorte cabe en 200 tokens; por carácter cabe el 7,77 %. La 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 99,8 % de las partidas del recorte quepan en 200 tokens UCI (medido sobre 20 000 partidas de enero; por carácter solo cabe el 7,77 %). necesaria se multiplica por cinco.
  2. Qué tiene que aprender el modelo. Con vocabulario fijo recibe cada jugada entera y dedica su capacidad a qué jugada viene después. Por carácter, parte de las capas se gasta en reconstruir la noción de jugada. Se puede (modelos públicos de 25-50M parámetros aprendieron a jugar así), pero esa capacidad se paga.
  3. Coste de atención. La atención compara cada token con todos los anteriores, así que su coste crece con el cuadrado de la longitud: pasar de 200 a 800 tokens cuesta dieciséis veces más. Con una sola GPU, eso separa «entrena en dos horas» de «no entrena esta noche».

En un LLM de texto pasa lo mismo aunque no lo elijas tú: el mismo párrafo ocupa más o menos tokens según el tokenizador, y eso decide cuánto cabe en el contexto y cuánto cuesta cada llamada a la API.

Hay un cuarto sitio, más sutil: lo que el modelo puede decir. Con vocabulario fijo solo emite jugadas bien formadas (e2e4, nunca e2e9), aunque puede emitir una imposible en la posición actual. Por carácter puede emitir cualquier cadena. Eso lo mide la tasa de legalidad sin máscara de la tabla únicaTabla únicaLa tabla de resultados que mide todas las etapas de Rukh con las mismas columnas: tasa de jugadas legales sin máscara, top-1 y top-3 por tramo de Elo, puzles resueltos por dificultad y Elo estimado contra Stockfish con su intervalo de confianza. Se fija en M0 y cada modelo, del primer decoder de M2 al de M6, añade una fila; como las columnas no cambian, dos filas cualesquiera se pueden comparar., una de sus columnas.

Vocabulario fijo, BPE y carácter

Cómo se construye cada tokenización te dice cómo va a fallar.

Vocabulario fijo UCI. Enumeramos todas las cadenas UCI que pueden ser una jugada, sin mirar ningún dato. Una jugada UCI es casilla de origen, casilla de destino y, en promociones, la pieza. Un par de casillas vale si una dama o un caballo podrían ir de una a otra en un tablero vacío (el peón y el rey se mueven como una dama recortada): salen 1 792 pares de los 4 096 posibles. Las promociones añaden 176 (de la fila 7 a la 8 o de la 2 a la 1, con desplazamiento de columna de como mucho uno, por cuatro piezas). Total, 1 968 jugadas. Con 8 tokens especiales y 54 tramos de Elo (27 por bando), el vocabulario tiene 2 030 entradas. Como no depende de los datos, Python y TypeScript producen la misma tabla, y una fixture de 20 partidas lo vigila.

Aquí no existe la jugada nunca vista: todas las legales están en la tabla, y el token <unk> solo aparece si el texto está corrupto. Lo que sí hay son entradas rarísimas (a1h8, un alfil que cruza el tablero desde la esquina) cuyo vector casi no se entrena, y tokens que no aparecen nunca: en este recorte, los doce tramos de Elo por debajo de 1800, porque la descarga empieza en 1800.

Carácter sobre SAN. El texto es la partida en notación algebraica numerada, sin comentarios: 1.e4 e5 2.Nf3 Nc6 3.Bb5 a6, más el resultado. El alfabeto (espacio, signos, dígitos, letras de piezas y columnas) tiene 32 símbolos; con <pad>, <bos> y <eos>, 35 tokens. Es el vocabulario más pequeño posible y el que menos sabe de ajedrez: nada queda fuera de él, y su precio es la longitud.

BPE (byte-pair encoding). Es la familia que usan casi todos los LLM de texto, y conviene construirla a mano una vez. Se parte de los caracteres. Se cuenta qué par de tokens adyacentes aparece más en el corpus, se fusiona en un token nuevo y se repite hasta el tamaño pedido (4 096 aquí). Es un taquígrafo que inventa abreviaturas: lo que más veces tiene que escribir se gana un signo propio, y lo raro lo sigue escribiendo letra a letra. Cada fusiónFusión de BPE (merge)Cada una de las uniones de un par de tokens que el BPE aprende, guardada en el orden en que la aprendió; en inglés, merge, que es como se llama en el fichero merges.txt y en la variable merges del lab. Tokenizar es aplicarlas en ese mismo orden: aplicar una tardía antes que una temprana da otra segmentación y peor. En Rukh son 4 069 para un vocabulario de 4 096. queda registrada con su orden, y tokenizar un texto nuevo es aplicarlas en ese orden: en cada vuelta gana la que se aprendió antes.

Sobre texto UCI, las primeras fusiones del BPE que vas a entrenar son d+4, f+3, f+6 y e+4: BPE redescubre las casillas, y después las jugadas. Más tarde las jugadas frecuentes se fusionan entre sí y aparecen trozos de apertura como un solo token. En el lab 5 mirarás las fusiones más largas.

texto: e2e4e7e5e2e4e7e5fusión n.º 4 · e + 4e2e4e7e5fusión n.º 6 · e + 5e2e4e7e5fusión n.º 7 · e + 7e2e4e7e5fusión n.º 9 · e + 2e2e4e7e5después, fusiones más tardías: e2 + e4 → e2e4, y jugadas en trozos de aperturafusión aplicada en esa vueltasímbolo que ya estaba
fig. 01Cómo codifica el BPE entrenado el texto e2e4e7e5. Los números son la posición real de cada fusión en su lista (las diez primeras las imprime el lab 5). No se recorre el texto de izquierda a derecha: en cada vuelta gana la fusión aprendida antes, esté donde esté.

En BPE, una jugada rara se descompone en trozos más cortos, hasta los caracteres si hace falta. Nunca hay <unk>, pero una jugada rara cuesta tres o cuatro tokens y una común, uno. Por eso BPE va tan bien con texto libre, y por eso un idioma poco representado en el corpus del tokenizador ocupa más tokens (y cuesta más) que el inglés. Aquí es un arma de doble filo: la longitud de una partida depende de lo previsible que sea, y el modelo tiene que aprender que e2e4 y e2 + e4 son la misma jugada.

Esquema Cómo se construye Tamaño Jugada rara Riesgo principal
UCI fijo Enumeración determinista, sin datos 2 030 Existe, poco entrenada Puede emitir jugadas ilegales bien formadas
SAN carácter Alfabeto fijo de 32 símbolos 35 Se escribe igual Secuencias 4-5 veces más largas
BPE Fusiones aprendidas de 200 000 partidas 4 096 Se parte en trozos Longitud variable; una jugada tiene varias formas

Tokens especiales como interfaz de control

Además de las jugadas, el vocabulario tiene unos pocos tokens que son la interfaz de control del modelo: la única forma de decirle algo que no sea «esta es la siguiente jugada».

  • <pad> (id 0) rellena las secuencias cortas. La pérdida lo ignora.
  • <bos> (id 1), beginning of sequence, marca el principio de una partida: es lo que el modelo ve cuando todavía no hay jugadas.
  • <eos> (id 2), end of sequence, marca el final. Cuando el modelo lo emite, la generación para.
  • <mask> (id 3) es el hueco que rellenará el encoder de M3; está desde ahora para que encoder y decoder compartan la misma tabla.
  • <unk> (id 4), desconocido, es una red de seguridad para que un fichero corrupto no rompa el entrenamiento.
  • <1-0>, <0-1>, <1/2> (ids 5-7) codifican el resultado.
  • <w0600> … <w3200> y <b0600> … <b3200> (ids 8-61): el Elo de blancas y de negras en tramos de 100, recortado al rango 600-3299.

La secuencia codificada de una partida es siempre:

<bos> <w1800> <b1900> e2e4 e7e5 g1f3 b8c6 … <1-0> <eos>

El Elo va al principio. El modelo es 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). Es lo que hace «causal» a un modelo: en M3 el encoder es el mismo bloque con causal=False, y cada posición ve la secuencia entera. (cada posición solo ve lo anterior), así que con el Elo delante cada jugada que predice queda condicionada a “una partida entre un 1800 y un 1900”. Es el selector de nivel de la demo, decidido aquí con dos tokens.

Lo que ese selector no hace es la lección de prompting más útil del curso. Pedirle al mismo modelo que juegue como un 2600 le cambia las jugadas de forma visible y no le sube la fuerza. Medida en puntos de 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. (la fuerza que se le calcula al modelo jugando partidas contra Stockfish limitado a niveles conocidos), sale 1397 como 2600 frente a 1425 como 1800: lo mismo, porque la diferencia cae dentro del margen de error de esa medida. Cómo se calcula ese margen está en la estadística justa de M2. Un prefijo elige a quién imitar; no cambia lo que el modelo sabe hacer. Antes de dar por buena una instrucción, decide qué número tiene que moverse y mídelo.

El resultado, en cambio, va al final. Al principio, el modelo sabría quién gana antes de ver la partida, y para generar tendrías que decidir el resultado antes de jugar. Al final, el modelo aprende a predecir el resultado desde las jugadas, que es una señal útil y no contamina la generación.

Padding, máscaras y streaming

Una GPU quiere tensores rectangulares: un loteLote (batch)Las muestras que el modelo ve de una vez antes de un paso del optimizador; en inglés, batch, que es como se llama en el código (batch_size). La GPU quiere tensores rectangulares, así que un lote es una matriz de forma fija: en Rukh, 64 secuencias de 200 tokens. Cuanto más grande, menos ruido tiene el gradiente y más memoria cuesta; cuando no cabe, la acumulación de gradiente simula uno mayor sumando varios lotes pequeños antes del paso. de 64 secuencias de 200 tokens. Las partidas duran 25 jugadas o 90. Hay dos formas de cuadrarlo, y elegimos la segunda.

Una partida por fila, con padding. Cada partida se rellena con <pad> hasta 200 (o se corta). Es lo más fácil y lo más caro: en una partida de 60 tokens, 140 son relleno y la GPU calcula sobre ellos. Además hay que decirle a la pérdidaPérdida (loss)El número que el entrenamiento minimiza: cuánto se equivoca el modelo sobre un lote; en inglés, loss. En el decoder es la entropía cruzada entre los logits y el token que venía de verdad, y las posiciones de relleno no cuentan (ignore_index). No se lee sola: una pérdida de 2,1 no significa nada sin saber sobre qué vocabulario se calculó, y por eso el curso la convierte en perplejidad para compararla. que no cuente los <pad>: CrossEntropyLoss(ignore_index=0) hace que una posición cuyo objetivo es el id 0 no aporte pérdida ni gradiente. Si se te olvida, el modelo aprende a predecir <pad> con confianza.

Empaquetado en flujo. Concatenamos todas las partidas codificadas en un único vector de enteros, <bos> … <eos> <bos> … <eos> <bos> …, y guardamos aparte dónde empieza cada partida. Un ejemplo es una ventana de 200 tokens que empieza en un <bos>: una partida entera y, si sobra sitio, el principio de la siguiente. Solo la última ventana necesita relleno, y el fichero es un único array uint16 que se lee con memmap.

una partida por fila, rellenada hasta 200A<pad> × 138B<pad> × 52C<pad> × 116Dcortada: 30 tokens fueraflujo empaquetado, ventanas alineadas a un <bos>ABCD…ABCD<bos>jugadas<pad>ventana de 200 tokens
fig. 03Las mismas cuatro partidas servidas de las dos formas (longitudes de ejemplo). Arriba, cada fila es una partida y el hueco se rellena; abajo, un solo flujo sin huecos del que se cortan ventanas de 200 tokens, cada una desde el <bos> de una partida. Con las partidas reales de enero, el relleno ronda el 60 % arriba y es del 0,5 % abajo.

La variante de nanoGPT trocea el flujo en posiciones arbitrarias, sin alinear con <bos>. Para texto funciona, pero aquí una ventana que empieza en la jugada 23 no lleva el Elo y el modelo predeciría sin saber quién juega. Alinear a <bos> cuesta un índice de unos pocos MB y garantiza que cada ejemplo empieza con sus tokens de control.

¿Y cuando la ventana cruza un <eos> y mira la partida anterior? Hay modelos que lo impiden con una máscara de atención por documento. Nosotros no: el modelo aprende que después de <eos> viene <bos> y un tramo de Elo, y lo de la partida anterior es ruido que aprende a ignorar. Si algún día importa, la máscaraMáscaraTensor de ceros y unos que dice qué posiciones cuentan y cuáles no. En el curso hay tres con el mismo nombre: la causal (M2) impide mirar hacia delante; la de padding marca el relleno (aquí resuelta con ignore_index); la de legalidad (demo) pone a cero las jugadas ilegales antes de muestrear. causal se convierte en una por documento cambiando una función.

El dataloader completa el cuadro. Un Dataset de PyTorch responde a dos preguntas: cuántos ejemplos hay y cuál es el ejemplo i. Un DataLoader los junta en lotes, los baraja y, con varios workers, los prepara en paralelo mientras la GPU calcula el lote anterior. Con memmap, cada worker abre el mismo fichero y el sistema operativo comparte las páginas; nadie copia un GB en memoria. Es el libro de consulta de la biblioteca: nadie se lo lleva a casa fotocopiado entero, cada lector abre la página que necesita y varios pueden leer el mismo ejemplar a la vez.

Fugas entre splits

Una fugaFuga de datosCualquier camino por el que información del conjunto de validación llega al de entrenamiento. No falla: los números mejoran, y se descubre cuando el modelo sale al mundo. En Rukh se evita partiendo por mes (enero entrena; de febrero, desde M2 parte 4, validan las primeras 100 000 partidas y el resto del mes entrena), por PuzzleId con semilla en los puzles, entrenando el BPE solo con datos de enero y, en M3, repartiendo la tabla de posiciones por game_id con un CRC-32 de la semilla y el id, nunca por posición: dos posiciones consecutivas de la misma partida se diferencian en una jugada. El umbral del detector se elige además en una mitad tune y se mide en otra score, partidas también por partida. es cualquier camino por el que información de validación llega a entrenamiento. Es el error más caro del aprendizaje automático porque no falla: los números mejoran, y descubres que era mentira cuando el modelo sale al mundo.

Con partidas, la fuga obvia son los duplicados; la menos obvia, los jugadores repetidos. Si partes al azar, un jugador activo que juega 400 partidas al mes con el mismo repertorio deja 360 en entrenamiento y 40 en validación. Cuando el modelo “acierta” su siguiente jugada en validación, ¿ha aprendido ajedrez o a ese jugador? No lo sabes, y la exactitud que publicas está inflada en una cantidad que no puedes medir. En un clasificador de tickets de soporte, el cliente que escribe cien veces igual es el mismo problema.

La solución es partir por algo que separe el mundo y no las filas. Aquí se parte por tiempo: enero de 2025 entrena y febrero valida. Los jugadores se repiten entre meses, pero febrero está en el futuro de enero, que es la situación en la que estará el modelo cuando juegue.

Con los puzles la clave es otra. Varios puzles vienen de la misma partida (GameId) y muchos comparten posición con pequeñas variaciones. Partimos por PuzzleId con semilla fija (42), así que el conjunto de test (2 000 por tramo de dificultad) no cambia aunque se repita el pipeline. Es el que usa la tabla única de M2 a M6.

Hay fugas más finas, y la más común fuera del ajedrez es ajustar algo con estadísticas del conjunto entero: la media con la que normalizas, el vocabulario de un TF-IDF o, aquí, el propio tokenizador. Un BPE entrenado también sobre febrero “ha visto” febrero, porque sus fusiones reflejan sus aperturas. Por eso el BPE se entrena con 200 000 partidas de enero y nunca de febrero: todo lo que se ajusta con datos, se ajusta solo con los de entrenamiento.

Lectura de 10 minutos: n-gramas, word2vec y RNN

Antes de los Transformers hubo tres formas de hacer lo que hace este módulo, y cada una dejó algo que seguimos usando.

N-gramas. El modelo de lenguaje más antiguo es una tabla de conteos: cuántas veces, después de e2e4 e7e5, viene g1f3. Funciona bien con contextos cortos y se hunde en cuanto crecen: casi ninguna secuencia de diez jugadas aparece dos veces en seis millones de partidas, y la tabla no generaliza de e2e4 e7e5 g1f3 a e2e4 e7e5 f1c4 aunque se parezcan. Sobrevive la tarea: predecir el siguiente símbolo dado el contexto, con la misma entropía cruzada que entrena hoy a un modelo de miles de millones de parámetros.

word2vec. Después se pasó de contar símbolos a representarlos con vectores densos, aprendidos para que los tokens que aparecen en contextos parecidos queden cerca: g1f3 y b1c3, que desarrollan un caballo, acabarían juntos sin que nadie lo diga. Sobrevive la representación distribuida: la capa nn.Embedding del decoder de M2 es una tabla de 2 030 vectores aprendida con el resto del modelo. Lo que no usamos es su vector fijo por token, porque una jugada significa cosas distintas según la posición.

RNN. Las redes recurrentes leen la secuencia manteniendo un estado que resume todo lo anterior, como una pizarra pequeña que se borra y reescribe después de cada página: solo cabe lo que cabe. Lo que pasó hace cincuenta jugadas se difumina, y como cada paso depende del anterior, no se paralelizan. El Transformer cambió el estado por atención, cada posición mirando a todas las anteriores a la vez, y ese es el coste cuadrático de la atención de más arriba. Sobrevive el teacher forcing: alimentar la secuencia real y predecir cada siguiente token a la vez, que es lo que hacen las ventanas (x, y) del dataloader de este módulo: y es x desplazada un token. Es un dictado corregido palabra a palabra: el alumno sigue desde el texto correcto y no desde lo que él escribió, para que un error al principio no arrastre todo lo demás.

De los n-gramas nos quedamos con la tarea, de word2vec con los embeddings y de las RNN con la forma de entrenar. Lo que aporta el Transformer, y lo que construirás en M2, es la forma de mezclar el contexto.

Los datos: del parquet al tensor

El pipeline es una cadena de comandos, cada uno un módulo de src/rukh/data/ con su configuración pydantic (extra="forbid", como en M0: una clave mal escrita falla), su salida en data/ (fuera de git) y una entrada en el manifiesto:

rukh data fetch → uci → tokenize → positions → evals → puzzles → pairs → elite → elo-bins → publish

La tabla es el mapa; debajo van los tres pasos que esconden una decisión (fetch, evals y pairs). Cada paso tiene después su lección con el código entero.

Paso Lee Escribe Quién lo usa Decisión
fetch Lichess en remoto (hf://) data/raw/ (parquet por mes) y manifiesto uci Filtros de M0 y tope de 3 millones por mes
uci data/raw/ data/uci/year=…/month=…/games.parquet: game_id, uci, n_plies, Elo, resultado, eco tokenize, positions, elo-bins, pairs SAN → UCI sobre un tablero real; una jugada ilegal descarta la partida; aquí se aplica el filtro de ≥ 20 plies
tokenize data/uci/ data/tokens/<esquema>/: tokens.npy (uint16), starts.npy, meta.json El decoder de M2, el encoder de M3 Flujo empaquetado alineado a <bos>; --stats mide los tres esquemas, --export-fixture escribe la fixture de paridad
positions data/uci/ (hasta 300 000 partidas de enero) data/positions/positions.parquet: FEN de cuatro campos, fase, conteo evals FEN sin contadores de jugadas, para que dos posiciones iguales no parezcan distintas; fase por plies y piezas
evals positions.parquet y 42 GB de evaluaciones en remoto data/evals/positions-eval.parquet: mejor línea y columna pvs con las demás pairs, las etiquetas del encoder de M3 Semi join en remoto por FEN, resumible por partes
puzzles Los seis millones de puzles de Lichess, en remoto data/puzzles/puzzles.parquet con split La tabla única de M2 a M6 RatingDeviation ≤ 100, NbPlays ≥ 100; tres tramos; split por PuzzleId con semilla 42, 2 000 de test por tramo
pairs positions-eval.parquet data/pairs/pairs.parquet DPO en M5 Elegida frente a rechazada por ≥ 100 centipeones, equilibrado por fase, ambas verificadas legales
elite La base Elite de Lichess (2500+), dos zips de unos 80 MB data/elite/games.parquet, mismas columnas que uci El SFT «como los maestros» de M4 Convierte con el mismo uci.py, para que un mismo modelo pueda leer los dos corpus
elo-bins data/uci/ (los dos meses) data/elo-bins/games.parquet El condicionamiento por Elo de M4 Tramo = media de los dos Elo truncada a la centena (// 100 * 100: un 1899 cae en 1800); hasta 50 000 partidas por tramo, con semilla
publish Los seis datasets anteriores (todo menos fetch y positions) y artifacts/tokenizer/ Una card por dataset (Jinja, en inglés, CC0, atribución a Lichess) y el repo en chorcat/ Cualquiera: es lo que descargas con rukh pull --dry-run escribe la card en local sin tocar la red; la publicación real necesita HF_TOKEN y es el punto donde intervienes tú

Sin elo-bins, el modelo de M4 vería diez veces más partidas de 1800 que de 2400 y el token <w2400> apenas se entrenaría (el lab 1 enseña esa distribución).

fetch (ya ejecutado en M0) filtra en remoto con DuckDBDuckDBBase de datos analítica embebida que ejecuta SQL sobre ficheros parquet locales o remotos (hf://). Es la herramienta con la que rukh data fetch aplica los filtros del recorte antes de materializar nada en disco. y materializa solo lo que pasa: ambos Elo ≥ 1800, base de tiempo ≥ 180 s, terminación Normal o Time forfeit, sin variantes. Pasa el 18 % de las partidas, unos 18 millones por mes; el diseño pedía de 3 a 6 millones en total, así que la descarga se queda con 3 millones por mes (los primeros días de enero y de febrero). La conversión a UCI conserva 5 896 388, porque el filtro de ≥ 20 pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply. necesita parsear las jugadas y se aplica ahí.

evals es el paso caro. Lichess publica 395 millones de posiciones evaluadas por Stockfish en 20 ficheros parquet (42 GB). No los descargamos: para cada fichero remoto, DuckDB hace un semi join con nuestras posiciones y solo trae las filas cuyo FEN tenemos. Es el patrón de un lago de datos en S3: llevar el filtro a los datos y no los datos a ti. Cada parte escribe su parquet y las ya escritas se saltan, así que se puede dejar corriendo entre sesiones. El dataset tiene varias filas por posición (multi-PV), ordenadas de mejor a peor: nos quedamos con la mejor línea y guardamos las demás en una columna pvs.

pairs saca un dataset entero gratis de esas líneas múltiples. En cada posición con al menos dos líneas, la primera jugada de la mejor es la elegida y la de una línea peor en al menos 100 centipeones es la rechazada. Eso es un par de preferencia para DPO (M5) sin ejecutar Stockfish ni una vez. Se equilibra por fase y las dos jugadas se verifican legales.

orden: fetch → uci → tokenize → positions → evals → puzzles → pairs → elite → elo-bins → publishfetchucitokenizepositionsevalspairselo-binspuzzleselitepublish → chorcat/= los siete que publish subemulti-PV+ prefijos de ucidescarga propiadescarga propia · uci.py
fig. 04El pipeline de M1. Cada caja escribe en data/ y añade una entrada al manifiesto; las flechas son lo que cada paso lee (la discontinua, los prefijos de jugadas que M5 tomará de uci para convertir cada par en una secuencia). puzzles y elite descargan de Lichess por su cuenta, y elite convierte con el mismo uci.py. Los siete pasos con borde marcado son los que publish sube a chorcat/; arriba, el orden en que se ejecutan.

El módulo, lección a lección

Todo el código de M1 está en las nueve lecciones siguientes, en el orden del pipeline. Si vas a escribirlo tú, ese es el orden: cada lección deja funcionando lo que la siguiente necesita.

Lección Qué tiene dentro
De PGN a UCI data/uci.py, data/parallel.py, data/db.py: la conversión y el pool con contrapresión
El pipeline data/pipeline.py, configs/data/pipeline.yaml, data/elite.py, data/elo_bins.py, la CLI
Posiciones y evaluaciones data/positions.py, data/scoring.py, data/evals.py: el fen4 y el semi join remoto
Puzles y pares de preferencia data/puzzles.py, data/pairs.py: los dos conjuntos que ya estaban hechos
Los tres vocabularios tokenize/uci_vocab.py, tokenize/san_chars.py, tokenize/bpe.py
Empaquetar y servir lotes tokenize/pack.py, tokenize/loader.py, tokenize/run.py, tokenize/fixture.py
Publicar el dataset data/publish.py y la plantilla de card en Jinja
El tokenizador en el navegador los cuatro ficheros de TypeScript de rukh-web y el test de paridad
Los labs del pipeline labs/m1/*.py, los comandos y las salidas reales de la ejecución de referencia