// M1 · lección 01
Del PGN al tensor: datos y tokenización
La primera decisión de modelado del curso: qué es un token. Tres tokenizaciones de la misma partida (UCI de vocabulario fijo, SAN por carácter y BPE) comparadas con datos, el pipeline que convierte seis millones de partidas de Lichess en tensores, dataloaders con empaquetado en flujo y un tokenizador que corre en el navegador.
Qué vas a construir
En M0 lanzaste una descarga y te fuiste. Ahora hay seis millones de partidas en data/raw/, en
formato 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 puede leer eso. Un modelo lee tensores de enteros, de tamaño fijo, servidos en lotes.
Este módulo es el camino completo entre las dos cosas, y en ese camino 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..
Todas las cifras de esta lección (la cobertura del bloque de 200 tokens, la tabla de
tokenizer-stats.json y las salidas pegadas de los labs) son las de la ejecución real del pipeline
en la máquina de referencia —RTX 5090, 19 de septiembre de 2026—, no estimaciones.
Al terminar tendrás:
- El pipeline
rukh dataejecutado 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 en 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.. Y una tabla, medida sobre datos reales, que dice cuántos tokens ocupa una partida con cada uno y cuántas partidas caben en 200 tokens.
- 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 cargar nada en memoria, con 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 implementado dos veces, en Python y en TypeScript, con una fixture de paridad que garantiza que el navegador y el entrenamiento hablan el mismo idioma. Es el que usa la visualización de esta lección y el que usará la demo en M2.
Todo lo que hagas 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. Si M1 está mal, todo lo demás está mal y no se nota hasta muy tarde. Por eso este módulo es largo y por eso cada decisión lleva un número al lado.
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 es la función que convierte texto en una lista de enteros (ids) y de vuelta. El modelo nunca ve letras, jugadas ni tableros: ve ids, y para cada id aprende un vector. Cambiar el tokenizador cambia qué tiene que aprender la red, cuánto cuesta cada partida y qué errores puede cometer.
Piensa en la jugada e2e4. Hay dos formas obvias de dársela al modelo:
- Como un solo token. Existe un id para
e2e4, otro parae7e5, otro parag1f3. 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 solo en jugadas, y el modelo tiene que descubrir que los caracteres van de cuatro en cuatro, que el primer par es la casilla de origen y el segundo la de destino, y quee2e4no tiene nada que ver cone2e3aunque compartan tres caracteres de cuatro.
Las consecuencias se miden en tres sitios:
- 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 (medido, no estimado: la tabla de más abajo). 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 95 % de las partidas quepan en 200 tokens UCI. que necesita el modelo para ver una partida entera se multiplica por cinco.
- Qué tiene que aprender el modelo. Con vocabulario fijo, el modelo recibe cada jugada ya “entera” y dedica su capacidad a lo que importa: qué jugada viene después de estas. Por carácter, una parte de las capas se gasta en reconstruir la noción de jugada a partir de trozos. No es imposible (los modelos públicos de referencia de 25-50M parámetros se entrenaron por carácter y llegaron a jugar), pero es capacidad que se paga.
- Coste de atención. La atención compara cada token con todos los anteriores: su coste crece con el cuadrado de la longitud, O(n²). Pasar de 200 a 800 tokens no es cuatro veces más caro, son dieciséis. Con una sola GPU, esa diferencia es la que separa “entrena en dos horas” de “no entrena esta noche”.
Hay un cuarto sitio donde importa, más sutil: lo que el modelo puede decir. Con vocabulario
fijo solo puede emitir jugadas bien formadas (e2e4, nunca e2e9), pero puede emitir jugadas
imposibles en la posición actual (e2e4 con el peón ya en e4). Por carácter puede emitir cualquier
cosa, incluidas cadenas que no son jugadas. La tasa de legalidad sin máscara de la tabla única mide
exactamente eso, y por eso se compara entre tokenizaciones con el mismo modelo.
Vocabulario fijo, BPE y carácter
Las tres tokenizaciones se construyen de forma distinta, y entender cómo se construye cada una es lo que te permite predecir cómo falla.
Vocabulario fijo UCI. Enumeramos todas las cadenas UCI que pueden ser una jugada de ajedrez,
sin mirar ningún dato. Una jugada UCI es casilla de origen, casilla de destino y, en promociones,
la pieza. Un par de casillas es una jugada posible si una dama o un caballo podrían ir de la
primera a la segunda en un tablero vacío: eso cubre todas las piezas (el peón y el rey se mueven
como una dama recortada) y descarta a1b3 o a1c2, que ninguna pieza puede hacer. Salen 1 792
pares. Las promociones son los pares del rango 7 al 8 (blancas) y del 2 al 1 (negras) con
desplazamiento de columna de como mucho uno, por cuatro piezas (q, r, b, n): 176 más. Total,
1 968 jugadas. Añadimos 8 tokens especiales y 54 tramos de Elo (27 por bando) y el vocabulario
tiene 2 030 entradas, con cada id fijado por la enumeración. No depende de los datos, así que
dos implementaciones independientes (Python y TypeScript) producen la misma tabla, y una fixture de
20 partidas codificadas desde Python vigila que sea así.
Qué pasa con una jugada nunca vista: no existe. Todas las jugadas legales del ajedrez están en la
tabla, así que el token <unk> (desconocido) solo aparece si el texto está corrupto. Lo que sí pasa
es que algunas entradas son rarísimas en los datos (a1h8 como movimiento de alfil desde la esquina)
y el modelo aprenderá poco de ellas: su vector existe, pero casi no se entrena.
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 es fijo y pequeño: espacio, #, +,
-, ., dígitos, las letras de las piezas (B K N O Q R), las columnas (a-h), x, = y /.
Treinta y dos símbolos más <pad>, <bos> y <eos>: 35 tokens. Es el vocabulario más
pequeño posible y el que menos sabe de ajedrez. Su virtud es que no hay nada fuera de vocabulario y
que reproduce exactamente el formato en que la gente escribe partidas; su precio es la longitud.
BPE (byte-pair encoding). Es la familia que usan casi todos los LLM de texto, y conviene saber construirla a mano una vez. Se parte de los caracteres como tokens iniciales. Se cuenta, en un corpus de entrenamiento, qué par de tokens adyacentes aparece más veces; ese par se fusiona en un token nuevo y se añade al vocabulario; se vuelve a contar; se repite hasta llegar al tamaño de vocabulario que se ha pedido (4 096 en nuestro caso). Cada fusión queda registrada con su orden, y tokenizar un texto nuevo es aplicar las fusiones en ese mismo orden.
Sobre texto UCI las primeras fusiones son aburridas y muy reveladoras: e + 2 → e2, e + 4
→ e4, e2 + e4 → e2e4. BPE redescubre las casillas y después las jugadas, porque son
los pares más frecuentes. Lo interesante viene después, cuando las jugadas más frecuentes empiezan
a fusionarse entre sí y aparecen trozos de apertura: la secuencia inicial de una española o de una
siciliana como un solo token. En el lab 5 vas a mirar las fusiones más largas del BPE entrenado sobre
200 000 partidas y ver hasta dónde llega.
Qué pasa con una jugada nunca vista en BPE: se descompone en trozos más cortos que sí están en el
vocabulario, hasta los caracteres si hace falta. Nunca hay <unk>, pero una jugada rara cuesta
tres o cuatro tokens y una común, uno. Esa es la propiedad que hace BPE tan bueno para texto libre
(cualquier palabra nueva se puede escribir) y que 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 según el contexto.
| 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 no son jugadas. No son un detalle de implementación: son la interfaz de control del modelo, la única forma que tienes de decirle algo que no sea “esta es la siguiente jugada”.
<pad>(id 0) rellena las secuencias cortas hasta la longitud del bloque. La pérdida lo ignora; el modelo no aprende a predecirlo ni a partir de él.<bos>(id 1), beginning of sequence, marca el principio de una partida. Es lo que el modelo ve cuando todavía no hay jugadas y lo que le permite aprender “así empiezan las partidas”.<eos>(id 2), end of sequence, marca el final. Cuando el modelo lo emite, la generación para.<mask>(id 3) no se usa en este módulo: es el hueco que rellenará el encoder de M3 (masked move modeling), y está en el vocabulario desde ahora para que el encoder y el decoder compartan la misma tabla.<unk>(id 4), desconocido, no debería aparecer nunca con datos limpios; que exista 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, con el número recortado al rango 600-3299.
Y la secuencia codificada de una partida es siempre:
<bos> <w1800> <b1900> e2e4 e7e5 g1f3 b8c6 … <1-0> <eos>Fíjate en dónde van el Elo y el resultado. El Elo va al principio, antes de la primera
jugada, y esto es una decisión con consecuencias hasta M4. El modelo es causal: cada posición solo
ve lo anterior. Si el Elo va al principio, cada jugada que predice está condicionada a “una partida
entre un 1800 y un 1900”; el modelo aprende que después de <w1500> vienen unas jugadas y
después de <w2400> otras. En M4, cuando quieras que juegue “como un 1500”, no habrá que entrenar
nada nuevo: bastará con poner <w1500> al principio y muestrear. Ese selector de la demo se decide
aquí, con dos tokens de control. Es la versión más pequeña posible de lo que en los LLM de texto se
llama instruction tuning: condicionar el comportamiento con tokens que van delante.
El resultado, en cambio, va al final. Si fuera al principio, el modelo sabría quién gana antes de ver la partida y aprendería a jugar “como el que ganó”, lo cual suena bien hasta que piensas que 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 (una cabeza de valor gratis) y no contamina la generación.
Padding, máscaras y streaming
Una GPU quiere tensores rectangulares: un lote de 64 secuencias de exactamente 200 tokens. Las partidas no son rectangulares: unas duran 25 jugadas y otras 90. Hay dos formas de cuadrar el círculo, y elegimos la segunda.
Una partida por fila, con padding. Cada partida se rellena con <pad> hasta 200 (o se corta
si es más larga). Es lo más fácil de entender y lo más caro: en una partida de 60 tokens, 140 son
relleno, y la GPU calcula atención sobre relleno. Además hay que decirle a la pérdida que no cuente
los <pad>: eso es ignore_index. En PyTorch, CrossEntropyLoss(ignore_index=0) hace que
cualquier posición cuyo objetivo sea el id 0 no contribuya a la pérdida ni al gradiente. Si se te
olvida, el modelo aprende que después de cualquier cosa viene <pad> y lo predice con confianza.
Empaquetado en flujo. Concatenamos todas las partidas codificadas, una detrás de otra, en un
único vector de enteros: <bos> … <eos> <bos> … <eos> <bos> …. Guardamos aparte la lista de
posiciones donde empieza cada partida. Un ejemplo de entrenamiento es una ventana de 200 tokens
que empieza en un <bos>: incluye una partida entera y, si sobra sitio, el principio de la
siguiente. Solo la última ventana del fichero necesita padding. Casi no hay relleno, la GPU calcula
sobre jugadas reales y el fichero es un solo array uint16 que se lee con memmap.
La alternativa que descartamos es el empaquetado “a bloques puros”, el de nanoGPT: trocear el flujo
en ventanas de 200 tokens en posiciones arbitrarias, sin alinear con <bos>. Es todavía más simple
y funciona para texto, pero aquí una ventana que empieza en la jugada 23 de una partida no lleva el
Elo (que iba al principio) y el modelo estaría prediciendo jugadas sin saber quién juega. Alinear
las ventanas a <bos> cuesta un índice de partidas de unos pocos MB y garantiza que cada ejemplo
empieza con los tokens de control. Es la decisión D-019 del registro de ejecución.
Queda una cosa: la ventana incluye a veces el final de una partida y el principio de la siguiente.
¿No debería el modelo dejar de mirar hacia atrás cuando cruza un <eos>? En rigor sí, y hay
modelos que lo hacen con una máscara de atención por documento. Nosotros no, a propósito: el modelo
aprende que después de <eos> viene <bos> y después de <bos> viene un tramo de Elo, y lo que
ve de la partida anterior es ruido que aprende a ignorar. Cuesta una fracción de capacidad y evita
una máscara dinámica por lote. Si en M2 resulta que 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 máscara 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.
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, febrero valida), por PuzzleId con semilla en los puzles, y entrenando el BPE solo con datos de enero. es cualquier camino por el que información del conjunto de validación llega al de entrenamiento. Es el error más caro del aprendizaje automático porque no falla: al contrario, los números mejoran. Descubres que era mentira cuando el modelo sale al mundo.
Con partidas de ajedrez, la fuga obvia son las partidas duplicadas, y la menos obvia son los jugadores repetidos. Imagina que partes al azar: el 90 % de las partidas a entrenamiento, el 10 % a validación. Un jugador activo de Lichess juega 400 partidas al mes con el mismo repertorio, las mismas trampas de apertura y los mismos errores en los finales. 360 de sus partidas están en entrenamiento; las otras 40, en validación. Cuando el modelo “acierta” la siguiente jugada de ese jugador en validación, ¿ha aprendido ajedrez o ha aprendido a ese jugador? No lo sabes, y la exactitud top-1 que publicas está inflada por una cantidad que no puedes medir.
La solución es partir por algo que separe el mundo, no las filas: por tiempo. Entrenamos con enero de 2025 y validamos con febrero. Los jugadores se repiten entre meses, claro, pero febrero está en el futuro de enero, que es exactamente la situación en la que va a estar el modelo cuando juegue: partidas que todavía no existían. Si el modelo memoriza a un jugador de enero, en febrero esa memoria vale menos, y la métrica lo refleja.
Con los puzles pasa algo parecido, con otra clave. Un puzle tiene un PuzzleId único, pero varios
puzles vienen de la misma partida (GameId) y muchos comparten la posición inicial con pequeñas
variaciones. Partimos por PuzzleId con una semilla fija (42), de modo que el split es
reproducible y el conjunto de test (2 000 por tramo de dificultad) no cambia aunque se vuelva a
ejecutar el pipeline. Ese conjunto de test es el que usa la tabla única desde M2 hasta M6, y lo
peor que le puede pasar a una tabla de resultados es que el conjunto sobre el que se mide cambie
por el camino.
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, no se ignora), 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 → publishMerece la pena saber qué escribe cada paso y por qué existe, porque son los datasets que van a aparecer en la tabla única con nombre propio.
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. sobre
hf://datasets/Lichess/standard-chess-games/… y materializa solo lo que pasa: ambos Elo ≥ 1800,
base de tiempo ≥ 180 s, terminación Normal o Time forfeit, sin variantes. Dos detalles de la
consulta ya los viste: TRY_CAST para que las partidas por correspondencia (TimeControl = '-') se
descarten en vez de abortar el COPY entero, y hive_partitioning = false para que DuckDB no
invente columnas year y month a partir de la ruta. Lo que no viste en M0 es el número que salió
del sondeo: de un fichero de 1 394 617 partidas del 1 de enero de 2025, los filtros dejan 251 618,
el 18 %. Un mes de Lichess son unos cien millones de partidas, así que quedarían unos 18
millones por mes, muy por encima de los 3-6 millones por dos meses que pedía el diseño. La decisión
(D-017) es un tope de 3 millones por mes: los ficheros del mes están en orden cronológico, así que
el tope se lleva los primeros doce ficheros, unos cinco días de enero y otros cinco de febrero. Seis
millones de partidas en total, registrado en el manifiesto como recorte temporal. Es más que lo que
usaron los modelos públicos de 25-50M parámetros que tomamos de referencia. De esos seis millones,
la conversión a UCI del paso siguiente conserva 5 896 388.
uci convierte el movetext SAN de cada partida a UCI con python-chess, jugada a jugada
sobre un tablero real, verificando la legalidad. Antes limpia los comentarios {[%clk 0:03:00]} y
{[%eval 0.3]}, los números de jugada y el resultado final. Una partida con una jugada ilegal o un
SAN que no se puede resolver se descarta entera (no se “arregla”), y aquí se aplica por fin 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. que M0 dejó pendiente porque necesitaba parsear las
jugadas. Escribe data/uci/year=YYYY/month=MM/games.parquet con game_id (hash de la URL de
Lichess), uci, n_plies, white_elo, black_elo, result, time_control, utc_date, eco y
month. Está paralelizado por lotes de 20 000 partidas porque una conversión son unos 0,3 ms y seis
millones son media hora en un solo proceso.
tokenize codifica las partidas con uno de los tres esquemas y las empaqueta en un flujo
uint16 (tokens.npy) con un índice de inicios (starts.npy) y un meta.json con conteos y el
hash del vocabulario. Con --stats calcula tokens por partida (media, p50, p95), el porcentaje de
partidas que caben en 200 tokens y el tamaño de vocabulario, y lo escribe en
artifacts/web/tokenizer-stats.json, que es lo que ves en la tabla de esta lección. Con
--export-fixture escribe vocab.json y las 20 partidas de paridad.
positions recorre hasta 300 000 partidas de enero con python-chess, escribe cada posición
como un FEN de cuatro campos (piezas, turno, enroques, al paso; sin los contadores de jugadas, que
harían que dos posiciones idénticas parecieran distintas), la etiqueta con su fase (apertura ≤ 10
plies, medio juego con ≥ 14 piezas, final) y deduplica por FEN contando cuántas veces se ha visto.
evals es el paso caro y el más interesante. 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 (SELECT e.* FROM read_parquet(remoto) e SEMI JOIN positions p ON e.fen = p.fen4) y solo trae las filas cuyo FEN tenemos. Es resumible: cada
parte escribe su parquet y las partes ya escritas se saltan, porque 42 GB por la red es algo que se
deja corriendo entre sesiones. Lo que hace especial a este dataset es que tiene varias filas por
posición: una por línea principal (multi-PV), ordenadas de mejor a peor para el bando que mueve.
Al consolidar nos quedamos con la mejor línea (el valor de la posición) y guardamos todas las demás
en una columna pvs.
puzzles descarga los seis millones de puzles con DuckDB, filtra los mal calibrados
(RatingDeviation ≤ 100, NbPlays ≥ 100), los agrupa en tres tramos (1000-1500, 1500-2000, 2000+)
y hace el split por PuzzleId con semilla: 2 000 de test por tramo, hasta 50 000 de entrenamiento.
pairs es donde las líneas múltiples de evals dan un dataset entero gratis. Para cada
posición con al menos dos líneas, la primera jugada de la mejor línea es la jugada elegida y la
primera jugada de una línea peor en al menos 100 centipeones es la rechazada. Eso es exactamente
un par de preferencia para DPO (M5), sin ejecutar Stockfish ni una vez: la evaluación ya está
hecha y publicada. Se equilibra por fase para que el dataset no sea solo aperturas, y las dos
jugadas se verifican legales con python-chess. Es la decisión D-018.
elite descarga la base de partidas 2500+ (dos zips mensuales de unos 80 MB) y las convierte
con el mismo uci.py, mismas columnas, para el SFT “como los maestros” de M4.
elo-bins toma los dos meses, calcula el tramo de cada partida como la media de los dos Elo
truncada a la centena (((white_elo + black_elo) // 2) // 100 * 100, división entera, no redondeo:
un 1899 de media cae en el tramo 1800), y muestrea hasta 50 000 partidas por tramo con semilla. Es
el dataset equilibrado con el que se afina el condicionamiento por Elo en M4: sin él, el modelo
vería diez veces más partidas de 1800 que de 2400 y el token <w2400> apenas se entrenaría.
publish renderiza una card por dataset (Jinja, en inglés, con la licencia CC0 y la atribución
a Lichess), crea el repo bajo chorcat/ y sube parquet, manifiesto y card. --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ú.
Labs
Los cinco labs siguen el orden del pipeline. Los comandos son los reales y las salidas son las de la ejecución de referencia (RTX 5090, 19 de septiembre de 2026), pegadas tal cual, sin recortes ni reconstrucciones.
Lab 1 · Explorar el parquet con DuckDB
Antes de tokenizar nada, mira los datos. DuckDB lee parquet en local igual que en remoto, y en
Python es una línea. Guarda este script como labs/m1/explore.py en el repo rukh y ejecútalo
desde su raíz; cada consulta va sobre el parquet de enero convertido a UCI. Ojo con un detalle de
DuckDB que muerde a todo el mundo: avg() no acepta booleanos, así que un porcentaje sobre una
condición se escribe avg(CAST(cond AS DOUBLE)) y no avg(cond).
import duckdb
GAMES = "data/uci/year=2025/month=01/games.parquet"con = duckdb.connect()
print("== Distribución de Elo (blancas), tramos de 100 ==")print( con.sql(f""" SELECT (white_elo // 100) * 100 AS tramo, count(*) AS partidas, round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct FROM read_parquet('{GAMES}') GROUP BY tramo ORDER BY tramo """))
print("== Duración en plies ==")print( con.sql(f""" SELECT count(*) AS partidas, round(avg(n_plies), 1) AS media, quantile_cont(n_plies, 0.5) AS p50, quantile_cont(n_plies, 0.95) AS p95, max(n_plies) AS max, round(100.0 * avg(CAST(n_plies <= 195 AS DOUBLE)), 1) AS pct_cabe_en_200 FROM read_parquet('{GAMES}') """))
print("== Aperturas más frecuentes (código ECO) ==")print( con.sql(f""" SELECT eco, count(*) AS partidas, round(100.0 * avg(CAST(result = '1-0' AS DOUBLE)), 1) AS pct_blancas FROM read_parquet('{GAMES}') GROUP BY eco ORDER BY partidas DESC LIMIT 10 """))uv run python labs/m1/explore.pySalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
== Distribución de Elo (blancas), tramos de 100 ==┌───────┬──────────┬────────┐│ tramo │ partidas │ pct ││ int16 │ int64 │ double │├───────┼──────────┼────────┤│ 1800 │ 790423 │ 26.8 ││ 1900 │ 779933 │ 26.4 ││ 2000 │ 579651 │ 19.7 ││ 2100 │ 372170 │ 12.6 ││ 2200 │ 214890 │ 7.3 ││ 2300 │ 113278 │ 3.8 ││ 2400 │ 55563 │ 1.9 ││ 2500 │ 25886 │ 0.9 ││ 2600 │ 10078 │ 0.3 ││ 2700 │ 3835 │ 0.1 ││ 2800 │ 1402 │ 0.0 ││ 2900 │ 828 │ 0.0 ││ 3000 │ 1517 │ 0.1 ││ 3100 │ 36 │ 0.0 ││ 3200 │ 19 │ 0.0 ││ 3300 │ 5 │ 0.0 │└───────┴──────────┴────────┘ 16 rows 3 columns
== Duración en plies ==┌──────────┬────────┬────────┬────────┬───────┬─────────────────┐│ partidas │ media │ p50 │ p95 │ max │ pct_cabe_en_200 ││ int64 │ double │ double │ double │ int16 │ double │├──────────┼────────┼────────┼────────┼───────┼─────────────────┤│ 2949514 │ 76.4 │ 71.0 │ 135.0 │ 300 │ 99.8 │└──────────┴────────┴────────┴────────┴───────┴─────────────────┘
== Aperturas más frecuentes (código ECO) ==┌─────────┬──────────┬─────────────┐│ eco │ partidas │ pct_blancas ││ varchar │ int64 │ double │├─────────┼──────────┼─────────────┤│ B01 │ 124281 │ 48.0 ││ A40 │ 122842 │ 49.8 ││ B00 │ 108915 │ 48.6 ││ C00 │ 104106 │ 48.8 ││ A00 │ 102337 │ 48.3 ││ D00 │ 92266 │ 49.8 ││ D02 │ 73265 │ 50.0 ││ A45 │ 64959 │ 48.2 ││ B10 │ 63572 │ 47.3 ││ B06 │ 56536 │ 49.3 │└─────────┴──────────┴─────────────┘ 10 rows 3 columnsTres cosas que mirar en esa salida. La distribución de Elo empieza en 1800 por construcción (es
el filtro) y cae a plomo: el 53,2 % de las partidas son de un 1800 o un 1900 y solo el 7,3 % de un
2200; de 2500 para arriba queda el 1,5 %. Eso es exactamente lo que justifica elo-bins en M4, y
avisa de que el token <w2400> se va a entrenar con una fracción minúscula de los datos.
La duración tiene una cola larga: media 76,4 medias jugadas, mediana 71, p95 135 y un máximo de
300, que es el tope del filtro. El porcentaje que cabe en 195 plies (200 tokens menos los cinco de
control) es 99,8 %: la cobertura del vocabulario UCI con max_len = 200 no es un compromiso,
es prácticamente todo el recorte.
Y las aperturas: la lista no es la que uno esperaría. Los códigos ECO más frecuentes no son las líneas principales sino los cajones de sastre —A40 (1.d4 sin respuesta clasificada), B00 (1.e4 irregular), A00 (primera jugada rara), D00 y A45—, junto a aperturas enteras que caben en un solo código: B01 la escandinava, C00 la francesa, B10 la Caro-Kann y B06 la moderna. Es lo que cabe esperar de partidas de 1800-2000 a ritmo rápido: se sale pronto de la teoría, y ECO clasifica eso en el cajón de la primera jugada. Sus porcentajes de victoria de blancas tampoco son iguales (47,3 % en B10 frente a 50,0 % en D02): el modelo va a ver esa asimetría y la va a aprender.
// Ejercicio 01¿Cuántas partidas duran más de 200 tokens?
Modifica la segunda consulta para contar cuántas partidas tienen más de 195 plies (las que el
tokenizador UCI va a truncar), agrupadas por tramo de 100 plies a partir de 200. Después responde:
¿cuál es la partida más larga de enero y cuánto dura? ¿Qué pasa con ella al tokenizar con
max_len = 200?
// SoluciónVer la solución
SELECT (n_plies // 100) * 100 AS tramo, count(*) FROM read_parquet(...) WHERE n_plies > 195 GROUP BY tramo ORDER BY tramo. La más larga se saca con ORDER BY n_plies DESC LIMIT 1.
Al tokenizar pasa algo más brusco de lo que suele suponerse. encode_game construye la
secuencia entera (<bos>, los dos tokens de Elo, todas las jugadas, el resultado y <eos>) y
después hace ids[:max_len], un corte seco por el token 200. No hay ningún <eos> al
final: el resultado y el <eos> estaban en las dos últimas posiciones y el corte se los ha
llevado por delante. La partida truncada termina en una jugada cualquiera de la mitad del
medio juego.
Y eso es justo lo que importa. Si el corte añadiera <eos>, el modelo aprendería un final
falso (una partida que “acaba” en tablas invisibles); como no lo añade, lo que aprende es una
secuencia que simplemente no termina, y nunca ve <eos> después de una posición de medio
juego. Es el mal menor de los dos, pero sigue siendo ruido: cada partida truncada gasta 200
posiciones de contexto sin enseñar cómo se acaba una partida. Por eso la cifra que se mide en
el lab 1 es la que decide max_len, y salió mejor de lo previsto: con 200 tokens se cubre el
99,8 % de las partidas (la lección estimaba un 95 % antes de medirlo). El 0,2 % restante
aporta ese ruido y la mayoría de esos finales largos son tablas teóricas. Aceptamos el sesgo y
lo dejamos anotado en el manifiesto.
Lab 2 · De SAN a UCI con python-chess
El texto de Lichess trae comentarios, números de jugada y el resultado dentro del movetext:
1. e4 { [%clk 0:03:00] } 1... c5 { [%clk 0:03:00] } 2. Nf3 { [%clk 0:02:58] } 2... d6 … 1-0clean_movetext quita las llaves con su contenido, las anotaciones $1, los números (1. y
1...) y el resultado. san_to_uci empieza con un tablero en la posición inicial, va leyendo cada
SAN, pregunta al tablero qué jugada es (board.parse_san), la aplica y anota su UCI. Si el SAN no
corresponde a ninguna jugada legal, la función devuelve None y la partida se descarta.
Hay dos casos que conviene tener en la cabeza porque son los que rompen las implementaciones
ingenuas. El enroque, O-O, se escribe en UCI como el movimiento del rey, e1g1; y la promoción,
e8=Q+, es e7e8q, con la pieza en minúscula al final y sin el +. Por eso la conversión pasa por
un tablero real y no por expresiones regulares: solo el tablero sabe desde qué casilla vino el
caballo de Nf3.
uv run rukh data uciSalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
24,2 GB freemonths: 2025-01, 2025-02 2025-01: 2949514 games kept 2025-02: 2946874 games keptmanifest: data/uci/manifest.jsonEl comando imprime poco: los meses convertidos, cuántas partidas se conservan en cada uno y la
ruta del manifiesto. El desglose completo está en ese data/uci/manifest.json, bajo
filters.conversion: filas leídas, conservadas, descartadas por jugada ilegal, por cortas (menos
de 20 plies) y por largas (más de 300). De los 6 000 000 de partidas leídas quedan 5 896 388
(2 949 514 de enero y 2 946 874 de febrero); se descartan 103 251 por cortas y 361 por
largas, y las ilegales son 0, exactamente cero en seis millones. Es el número que había que
mirar: Lichess valida las partidas al guardarlas, así que un solo ilegal habría significado que la
limpieza del movetext estaba rompiendo algo, no que los datos vinieran mal.
// Ejercicio 02Rompe la conversión a propósito
Toma una partida del parquet de enero (SELECT uci FROM read_parquet(...) LIMIT 1), reconstruye
su SAN con python-chess (board.san(move) jugada a jugada) y vuelve a convertirla con
san_to_uci: tiene que dar exactamente la cadena original. Después cambia una jugada del SAN por
otra ilegal (un caballo que salta tres casillas) y comprueba qué devuelve la función.
// SoluciónVer la solución
La ida y vuelta es exacta porque tanto san como parse_san usan el mismo tablero y las mismas
reglas de desambiguación. Con la jugada ilegal, parse_san lanza IllegalMoveError (o
InvalidMoveError si la cadena ni siquiera parece una jugada) y san_to_uci lo captura y
devuelve None. Que sea None y no una excepción es deliberado: dentro de un Pool de
procesos, una excepción por partida corrupta interrumpiría el lote entero.
Lab 3 · Las tres tokenizaciones y sus estadísticas
Ahora sí, la comparación. El mismo comando con tres esquemas y tres banderas: --pack escribe el
flujo de tokens empaquetado de cada esquema en data/tokens/<esquema>/ (lo que usa el lab 4),
--stats calcula las estadísticas de los tres esquemas a la vez y --export-fixture escribe el
vocabulario y la fixture de paridad que después se copian a las dos webs. El orden importa:
--scheme bpe entrena el BPE (artifacts/tokenizer/bpe.json) y la fixture solo incluye los ids
BPE si ese fichero ya existe, así que el BPE va primero y el uci con la fixture, al final.
uv run rukh data tokenize --scheme bpe --packuv run rukh data tokenize --scheme san --packuv run rukh data tokenize --scheme uci --stats --export-fixture --packSalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
scheme: bpevocab_size: 4096scheme: sanvocab_size: 35 train: 2,949,514 games, 1,258,749,609 tokens val: 2,946,874 games, 1,250,789,500 tokensPárate en esas dos líneas, porque son el argumento más fuerte de todo el módulo. Son las mismas
2 949 514 partidas de enero y las mismas 2 946 874 de febrero que acaba de empaquetar el esquema
bpe y que empaquetará el uci dentro de un comando: no cambia ni un dato, no se filtra ni se
añade nada, solo cambia la forma de escribir las mismas jugadas. Y el flujo de tokens pasa de
240 068 954 a 1 258 749 609. Son 5,2 veces más tokens para exactamente el mismo corpus
(5,24× en entrenamiento y 5,24× en validación), con un vocabulario de 35 símbolos en lugar de
2 030.
Ese 5,2× no se paga en disco —el tokens.npy de san ocupa 2,5 GB en vez de 480 MB, y eso da
igual—, se paga en tiempo de GPU y en ventana de contexto. Con el presupuesto fijo que gasta M2
(20 000 pasos × 51 200 tokens = 1 024 millones de tokens) el esquema UCI hace algo más de cuatro
épocas sobre enero; el mismo presupuesto sobre SAN no llega a completar una sola: 0,81
épocas. Con la misma GPU y el mismo reloj, un esquema ve el corpus cuatro veces y el otro se queda
sin terminar de leerlo. Y como el coste de la atención crece con el cuadrado de la longitud,
igualar las épocas no costaría 5,2 veces más, sino unas 27.
Esta es la razón de que la tokenización sea un módulo entero y no un apartado de media página: no es una preferencia de formato, es un factor 5,2 sobre la factura, medido sobre seis millones de partidas reales y no estimado.
Salida real de la ejecución de referencia (RTX 5090, 2026-09-19):
scheme: ucivocab_size: 2030 train: 2,949,514 games, 240,068,954 tokens val: 2,946,874 games, 238,657,571 tokensEsas dos últimas líneas son el tamaño real del corpus con el que se entrena M2: 240 068 954
tokens de entrenamiento (enero) y 238 657 571 de validación (febrero). Los 20 000 pasos de
small, a 51 200 tokens cada uno, son 1 024 millones de tokens: algo más de cuatro épocas sobre
enero.
La tabla siguiente se genera desde artifacts/web/tokenizer-stats.json (pnpm sync:data la copia
al sitio), así que es la misma que verás en la página del proyecto:
| Esquema | Vocabulario | Tokens/partida (media) | p50 | p95 | % ≤ 200 tokens |
|---|---|---|---|---|---|
| UCI (vocabulario fijo) | 2030 | 80,64 | 76 | 139 | 99,8 |
| SAN (carácter) | 35 | 422,44 | 393 | 754 | 7,77 |
| BPE | 4096 | 68,05 | 62 | 129 | 99,84 |
e2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6b1c3a7a6· 10 jugadase2e4e7e6d2d4d7d5e4e5c7c5c2c3b8c6g1f3d8b6· 10 jugadase2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6b1c3· 9 jugadase2e4e7e6d2d4d7d5e4e5c7c5c2c3b8c6g1f3· 9 jugadase2e4c7c5g1f3b8c6d2d4c5d4f3d4g8f6b1c3· 9 jugadase2e4c7c5g1f3b8c6d2d4c5d4f3d4e7e5d4b5· 9 jugadase2e4e7e5g1f3b8c6f1c4g8f6f3g5d7d5e4d5· 9 jugadasd2d4g8f6c2c4g7g6b1c3f8g7e2e4d7d6· 8 jugadase2e4e7e6g1f3d7d5e4d5e6d5d2d4g8f6· 8 jugadase2e4c7c6g1f3d7d5e4d5c6d5d2d4b8c6· 8 jugadas
Cómo leerla. Las estadísticas se calculan sobre las primeras 20 000 partidas de enero, y los tres
esquemas cuentan los mismos cinco tokens de encuadre (<bos>, dos de Elo, resultado, <eos>)
para que la comparación sea justa. Tokens por partida es la columna que decide el coste: la
media dice cuánto cuesta una época y el p95 dice qué ventana de contexto necesitas para no truncar
casi nada. % ≤ 200 es la cobertura del bloque de 200 tokens que usa el dataloader. Y
vocabulario es lo que decide el tamaño de la capa de embeddings y de la capa de salida: 2 030
frente a 35 frente a 4 096 vectores de la dimensión del modelo.
Lo que salió medido, y por qué zanja la decisión:
- UCI fijo: media 80,64 tokens por partida, p50 76, p95 139 y 99,8 % por debajo de 200.
La decisión de
max_len = 200no truncaría ni dos partidas de cada mil. - SAN por carácter: media 422,44, p50 393, p95 754 y solo el 7,77 % cabe en 200. Es 5,24 veces más largo que UCI, que es exactamente la misma proporción que acabas de ver sobre el corpus entero (1 258 749 609 tokens frente a 240 068 954): la muestra de 20 000 partidas y los seis millones dicen el mismo número. Con la ventana de 200 tokens del curso sería inservible: más de nueve de cada diez partidas se cortarían.
- BPE de 4 096: media 68,05, p50 62, p95 129 y 99,84 % por debajo de 200. Es el más corto de los tres —las fusiones de apertura se comen diez plies de un bocado— a cambio del vocabulario más grande y de que la longitud de una partida dependa de lo previsible que sea.
La distancia entre 80,64 y 422,44 es la razón de todo el módulo: con carácter, el mismo presupuesto de atención (que crece con el cuadrado de la longitud) rinde unas veintisiete veces menos por partida.
// Ejercicio 03Codifica una partida a mano
Sin ejecutar nada, escribe la secuencia de ids del tokenizador UCI para la partida e2e4 e7e5 g1f3 entre un 1850 y un 1920 que termina en tablas. Después comprueba con
UciTokenizer().encode_game("e2e4 e7e5 g1f3", 1850, 1920, "1/2-1/2").
// SoluciónVer la solución
[1, 20, 48, ?, ?, ?, 7, 2]. El <bos> es 1; <w1800> es el tramo 12 desde 600 (1800 = 600 +
12 × 100), y los tramos blancos empiezan en el id 8, así que 8 + 12 = 20; <b1900> es el tramo
13 desde el id 35, así que 48; <1/2> es 7 y <eos> es 2. Los ids de las jugadas dependen de
la enumeración (e2e4 es el índice de ese par dentro de las 1 792 jugadas más 62) y no hace
falta calcularlos a mano: lo que tiene que quedar claro es que la posición de los tokens de
control es fija y sus ids también, y que eso es lo que permite que la implementación en
TypeScript sea idéntica sin compartir código.
Lab 4 · Dataset y DataLoader con empaquetado por bloques
tokenize --pack ha dejado en data/tokens/uci/train/ (enero) y data/tokens/uci/val/
(febrero) tres ficheros por carpeta: tokens.npy (el flujo uint16), starts.npy (el offset de
cada <bos>, int64) y meta.json (partidas, tokens, esquema y hash del vocabulario). PackedDataset es la clase que
los convierte en ejemplos de entrenamiento, y merece la pena leerla entera porque es el contrato
entre los datos y el modelo.
class PackedDataset(Dataset): def __init__(self, directory: Path, block: int = 200, start_at_game: bool = True): self.tokens = np.load(directory / "tokens.npy", mmap_mode="r") # nunca en RAM entero self.starts = np.load(directory / "starts.npy") # un int64 por partida self.block = block self.pad_id = 0
def __len__(self) -> int: return len(self.starts) # un ejemplo por partida
def window(self, index: int) -> np.ndarray: origin = int(self.starts[index]) # la ventana empieza en un <bos> chunk = np.asarray(self.tokens[origin : origin + self.block + 1], dtype=np.int64) if len(chunk) < self.block + 1: # solo al final del flujo chunk = np.concatenate([chunk, np.full(self.block + 1 - len(chunk), self.pad_id)]) return chunk
def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor]: chunk = self.window(index) x = torch.from_numpy(chunk[: self.block].copy()) y = torch.from_numpy(chunk[1 : self.block + 1].copy()) y[-1] = self.pad_id # la última posición no se entrena return x, yLéelo con calma, porque cada línea es una decisión de las que hablamos arriba:
mmap_mode="r"es el memmap:tokens.npypuede medir un GB ynp.loadno lo lee; mapea el fichero en el espacio de direcciones y el sistema operativo trae las páginas cuando se tocan. Con cuatro workers, los cuatro comparten las mismas páginas físicas.__len__es el número de partidas, no de tokens: un ejemplo por partida y época. Barajar es barajar índices de partida, y lo hace elDataLoadercon su semilla. Constart_at_game=Falseel mismo dataset corta el flujo en bloques consecutivos sin alinear: es la alternativa descartada, y está ahí para poder medirla en M2.- La ventana pide
block + 1tokens porque el objetivoyes la entrada desplazada una posición:y[t]es el token que viene después dex[t]. Por esoy[:-1] == x[1:]en todos los ejemplos, y es una de las aserciones de los tests. La última posición deyse pone a<pad>a propósito: su objetivo real estaría fuera de la ventana, y es más honesto no entrenarla que inventarlo. - El padding solo aparece en la última partida del flujo, cuando no quedan 201 tokens por delante.
Se rellena con
<pad>(id 0), que es exactamente elignore_indexde la pérdida (IGNORE_INDEX = 0en el módulo): esas posiciones no cuentan. dtype=np.int64: se guarda enuint16(2 bytes, suficiente para 4 096 ids) para que el fichero sea pequeño, y se convierte aint64al salir porque es lo quenn.Embeddingy la pérdida esperan como índices. El.copy()es necesario porque un trozo de memmap es de solo lectura ytorch.from_numpyquiere memoria escribible.
make_loader(dataset, batch_size, seed, workers) envuelve el dataset en un DataLoader con
shuffle=True, drop_last=True (el último lote incompleto se descarta para que todos los lotes
tengan la misma forma) y un torch.Generator sembrado con seed, que es lo único que decide el
orden. Dos loaders con la misma semilla devuelven los mismos lotes en el mismo orden: es lo que te
permite reproducir un entrenamiento y lo que comprueba uno de los tests.
Guarda este script como labs/m1/loader_check.py en el repo rukh y ejecútalo desde su raíz. El
if __name__ == "__main__": no es decorativo: en Windows los workers del DataLoader arrancan con
spawn, es decir, reimportando el módulo, y sin esa guarda cada worker volvería a ejecutar el
script entero y el proceso se multiplicaría hasta morir (con workers=0 no haría falta, pero
tampoco medirías el coste real).
from rukh.tokenize.loader import PackedDataset, make_loader
def main() -> None: ds = PackedDataset("data/tokens/uci/train", block=200) x, y = ds[0] print(len(ds), x.shape, y.shape, x[:6].tolist(), y[:6].tolist()) loader = make_loader(ds, batch_size=64, seed=0, workers=4) xb, yb = next(iter(loader)) print(xb.shape, yb.shape, xb.dtype, (yb == 0).float().mean().item())
if __name__ == "__main__": # obligatorio en Windows: los workers arrancan con spawn main()uv run python labs/m1/loader_check.pySalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
2949514 torch.Size([200]) torch.Size([200]) [1, 22, 49, 997, 673, 751] [22, 49, 997, 673, 751, 918]torch.Size([64, 200]) torch.Size([64, 200]) torch.int64 0.004999999888241291Léelo de izquierda a derecha. 2949514 son las partidas de enero, una por ejemplo, las mismas que
conservó el paso uci. Los dos tensores son de 200 posiciones, y los seis primeros ids de x son
[1, 22, 49, 997, 673, 751], que decodificados son <bos> <w2000> <b2000> e2e4 c7c6 d2d4: una
Caro-Kann entre dos jugadores de 2000. y es esa misma lista desplazada una posición —empieza en
22 y sigue con 918, d7d5—, que es el desplazamiento del que dependen la pérdida y todo el módulo.
La última cifra, la fracción de <pad> en los objetivos de un lote, es la medida directa de lo que
se ahorra con el empaquetado en flujo: 0,005, medio por ciento. Con una partida por fila
rellenada a 200 estaría alrededor del 50-60 %, es decir, más de la mitad del cálculo de atención
tirado a la basura.
// Ejercicio 04Mide el precio del padding
Escribe una variante PaddedDataset que devuelva una partida por ejemplo, rellenada con <pad>
hasta 200 y truncada si es más larga. Con el mismo lote de 64, calcula la fracción de objetivos
que son <pad> y compárala con la de PackedDataset. ¿Cuántas veces más tokens “útiles” por
lote sirve el empaquetado?
// SoluciónVer la solución
Con la media medida en el lab 3 (80,64 tokens por partida, encuadre incluido; la mediana es 76),
una partida por fila deja unos 119 <pad> por ejemplo: el 60 % del lote es relleno y
ignore_index lo descarta, pero la atención ya lo ha calculado. PackedDataset mide 0,005 de
relleno en el lote de arriba, así que por lote de 64 × 200 hay unas 2,5 veces más posiciones
que contribuyen a la pérdida. Con el mismo presupuesto de GPU, el modelo ve 2,5 veces más
jugadas.
Lab 5 · Entrenar un BPE y mirar las fusiones
El BPE que usa el esquema bpe se entrena con la librería tokenizers sobre 200 000 partidas UCI
de enero. Lo interesante no es entrenarlo (es una llamada) sino mirar qué ha aprendido: la lista de
fusiones es una radiografía de la frecuencia de las aperturas en el recorte. Guarda este script como
labs/m1/bpe_merges.py en el repo rukh y ejecútalo desde su raíz.
import jsonfrom pathlib import Path
bpe = json.loads(Path("artifacts/tokenizer/bpe.json").read_text(encoding="utf-8"))merges = bpe["model"]["merges"]vocab = bpe["model"]["vocab"]
print("fusiones:", len(merges), " vocabulario:", len(vocab))print("== primeras 10 fusiones (las más frecuentes) ==")for pair in merges[:10]: print(" ", pair)
print("== 10 tokens más largos del vocabulario ==")longest = sorted(vocab, key=len, reverse=True)[:10]for token in longest: print(f" {len(token):3d} {token}")uv run python labs/m1/bpe_merges.pySalida real de la ejecución de referencia (RTX 5090, 2026-09-19):
fusiones: 4069 vocabulario: 4096== primeras 10 fusiones (las más frecuentes) == ['d', '4'] ['f', '3'] ['f', '6'] ['e', '4'] ['d', '5'] ['e', '5'] ['e', '7'] ['d', '7'] ['e', '2'] ['c', '3']== 10 tokens más largos del vocabulario == 40 e2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6b1c3a7a6 40 e2e4e7e6d2d4d7d5e4e5c7c5c2c3b8c6g1f3d8b6 36 e2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6b1c3 36 e2e4e7e6d2d4d7d5e4e5c7c5c2c3b8c6g1f3 36 e2e4c7c5g1f3b8c6d2d4c5d4f3d4g8f6b1c3 36 e2e4c7c5g1f3b8c6d2d4c5d4f3d4e7e5d4b5 36 e2e4e7e5g1f3b8c6f1c4g8f6f3g5d7d5e4d5 32 d2d4g8f6c2c4g7g6b1c3f8g7e2e4d7d6 32 e2e4e7e6g1f3d7d5e4d5e6d5d2d4g8f6 32 e2e4c7c6g1f3d7d5e4d5c6d5d2d4b8c6Las primeras fusiones son casillas, como predijimos en la teoría: d+4, f+3, f+6,
e+4… las casillas de las jugadas de desarrollo más comunes, en orden de frecuencia. Después
vienen las jugadas enteras, y después lo que de verdad interesa.
Los diez tokens más largos son aperturas completas, y se pueden leer. El token más largo del
vocabulario, de 40 caracteres, es
e2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6b1c3a7a6: diez medias jugadas en un solo id, que en notación
algebraica son 1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6. Es la siciliana Najdorf —la
apertura más analizada de la historia del ajedrez— y el BPE la ha aprendido como una palabra. El
otro token de 40 caracteres, e2e4e7e6d2d4d7d5e4e5c7c5c2c3b8c6g1f3d8b6, es 1.e4 e6 2.d4 d5 3.e5 c5 4.c3 Nc6 5.Nf3 Qb6: la francesa de avance, también diez plies. Y entre los de 32 aparece
d2d4g8f6c2c4g7g6b1c3f8g7e2e4d7d6, que es 1.d4 Nf6 2.c4 g6 3.Nc3 Bg7 4.e4 d6: la india de
rey. Los demás son la siciliana abierta sin a6, la variante con Nc6, la Sveshnikov (4…e5 5.Nb5), la defensa de los dos caballos con 4.Ng5, la francesa de cambio y la Caro-Kann de cambio.
El BPE no sabe ajedrez; ha contado pares. Que contar pares baste para “descubrir” —sin supervisión,
sin un libro de aperturas, sin saber siquiera que hay un tablero— que esas diez medias jugadas van
juntas es la intuición que necesitas para entender por qué los LLM de texto tienen tokens para
ing, tion o the. Y también su límite: un solo id para diez plies significa que un modelo que
emita ese token se compromete de golpe con diez medias jugadas, y que la máscara de legalidad de la
demo —que razona jugada a jugada— no sabría qué hacer con él. Es una de las razones por las que el
modelo del curso se entrena con el esquema UCI y no con este.
// Ejercicio 05Un BPE de tamaño distinto
Entrena un BPE de 1 024 tokens con train_bpe sobre las mismas 200 000 partidas y compáralo con
el de 4 096: ¿cuál es el token más largo de cada uno? ¿Cuántos tokens por partida de media? Sin
ejecutar nada, predice primero qué va a pasar con las dos cifras al reducir el vocabulario.
// SoluciónVer la solución
Con 1 024 tokens apenas hay sitio para las 1 968 jugadas posibles, así que muchas jugadas
poco frecuentes se quedan partidas en casillas (e2 + e4) y casi no hay fusiones de varias
jugadas: el token más largo será una jugada o, como mucho, un par de ellas, y la media de tokens
por partida sube claramente por encima de la del UCI fijo. Con 4 096 sobra sitio para todas las
jugadas y unos dos mil trozos de apertura, y la media baja por debajo del UCI fijo: 68,05
frente a 80,64, medido. La regla
general: cuanto mayor el vocabulario, más cortas las secuencias y más grande la capa de
embeddings; el punto óptimo depende del modelo, y por eso en M2 se entrena con los tres.
Visualización: TokenizerPlayground
Lo de abajo corre en tu navegador, sin red y sin modelo: es el tokenizador UCI en TypeScript (el
mismo que la demo usará en M2), el tokenizador por carácter y el BPE cargado desde el bpe.json
que entrenaste en el lab 5. chess.js convierte el PGN a UCI y a SAN; cada esquema tokeniza lo
suyo y muestra las fichas con su id. Las fichas de control van en un color, las de Elo en otro y
las jugadas en el de siempre; en la fila de BPE, las fichas que abarcan más de una jugada van
resaltadas.
13 medias jugadas · bloque de 200 tokens
Vocabulario fijo UCI
18 tokensuna jugada, un token; control y Elo delante, resultado detrás
- <bos>1
- <w1800>20
- <b1900>48
- e2e4997
- e7e51164
- g1f31455
- b8c6460
- f1c41207
- d7d6919
- b1c3268
- c8g4710
- f3e51271
- g4d11526
- c4f7590
- e8e71193
- c3d5549
- <1-0>5
- <eos>2
Carácter (SAN)
73 tokensun carácter, un token, sobre el SAN numerado más el resultado
- <bos>1
- 19
- .7
- e29
- 412
- ␣3
- e29
- 513
- ␣3
- 210
- .7
- N21
- f30
- 311
- ␣3
- N21
- c27
- 614
- ␣3
- 311
- .7
- B19
- c27
- 412
- ␣3
- d28
- 614
- ␣3
- 412
- .7
- N21
- c27
- 311
- ␣3
- B19
- g31
- 412
- ␣3
- 513
- .7
- N21
- x33
- e29
- 513
- ␣3
- B19
- x33
- d28
- 19
- ␣3
- 614
- .7
- B19
- x33
- f30
- 715
- +5
- ␣3
- K20
- e29
- 715
- ␣3
- 715
- .7
- N21
- d28
- 513
- #4
- ␣3
- 19
- -6
- 08
- <eos>2
BPE
8 tokensfusiones aprendidas sobre las jugadas sin espacios; resaltadas las que abarcan varias jugadas
- e2e4e7e5g1f3b8c6f1c4656
- d7d6104
- b1c3c8g42255
- f3e5117
- g4d11704
- c4f7839
- e8e7290
- c3d5157
controlElojugada / carácterfusión de varias jugadas· cada ficha muestra token · id
Tres PGN que conviene probar:
- El de ejemplo, el mate de Légal (siete jugadas). Cuenta las fichas: 13 medias jugadas más cinco de control en UCI, y compáralo con los caracteres de SAN. Cambia el Elo de blancas a 2450 y mira qué ficha cambia y cuál no.
- El mate del pastor,
1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7#. Fíjate en cómo el#y laxocupan un carácter cada uno en SAN y ninguno en UCI: la captura y el jaque mate están implícitos enh5f7. - Una partida larga. El botón “Partida larga” genera una partida legal de más de cien jugadas
con
chess.js; también puedes pegar cualquier PGN exportado desde la demo o desde Lichess. El aviso de “más de 200 tokens” aparece en los esquemas que truncarían, y con SAN por carácter aparece con casi cualquier partida real. - Una siciliana Najdorf,
1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6. Es la línea del token más largo del lab 5: mira la fila de BPE y verás una sola ficha resaltada tragándose varias jugadas de golpe, con el número de jugadas que abarca en su título. Es el lab 5 hecho visible.
Y un PGN inválido (borra una jugada a la mitad, o escribe Nf9) muestra el error en vez de
tokens: es exactamente lo que hace san_to_uci en Python al devolver None.
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 las tres dejaron algo que seguimos usando. No vas a implementarlas; sí conviene saber qué eran, por qué no las usamos y qué se quedó.
N-gramas. El modelo de lenguaje más antiguo es una tabla de conteos: cuántas veces, en el
corpus, después de e2e4 e7e5 viene g1f3. La probabilidad del siguiente token es el conteo del
n-grama dividido por el conteo del contexto. Funciona sorprendentemente bien para contextos cortos
(un modelo de 3-gramas sobre nuestro recorte acertaría muchas jugadas de apertura) y se hunde en
cuanto el contexto crece: casi ninguna secuencia de diez jugadas aparece dos veces en seis millones
de partidas, y la tabla no puede generalizar de e2e4 e7e5 g1f3 a e2e4 e7e5 f1c4, aunque se
parezcan. Lo que sobrevive: la tarea. Predecir el siguiente símbolo dado el contexto es la
misma pérdida, con la misma entropía cruzada, que entrena hoy a un modelo de miles de millones de
parámetros. Cambió la función; la pregunta es la de los años cincuenta.
word2vec. El paso siguiente fue dejar de contar símbolos y representarlos con vectores densos:
cada token es un punto en un espacio de unas cientos de dimensiones, aprendido para que tokens que
aparecen en contextos parecidos estén cerca. Con jugadas, g1f3 y b1c3 acabarían cerca (los
dos desarrollan un caballo en la apertura) sin que nadie se lo diga. Lo que sobrevive: la
representación distribuida. La capa nn.Embedding del decoder de M2 es exactamente eso, una
tabla de 2 030 vectores que se aprende junto con el resto del modelo, y en A1 usarás embeddings de
frases para recuperar comentarios de libros. Lo que no usamos: word2vec aprende un vector por token
independiente del contexto, y una jugada significa cosas distintas según la posición.
RNN. Las redes recurrentes leen la secuencia de izquierda a derecha manteniendo un estado que
resume todo lo anterior. Resolvían el problema de los n-gramas (contexto ilimitado, en teoría) con
un coste lineal en la longitud. Su límite era doble: el estado es un cuello de botella de tamaño
fijo, así que lo que pasó hace cincuenta jugadas se difumina, y cada paso depende del anterior, así
que no se paralelizan: una GPU con veinte mil núcleos calcula una jugada cada vez. El Transformer
sustituyó el estado por atención (cada posición mira directamente a todas las anteriores, en
paralelo) y ahí está el O(n²) de la teoría: es el precio de haber quitado la recurrencia. Lo que
sobrevive: el entrenamiento por teacher forcing, es decir, alimentar la secuencia real y
predecir cada siguiente token a la vez, que es exactamente lo que hacen las ventanas (x, y) del
lab 4.
En resumen: 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 vas a construir en M2, es la forma de mezclar el contexto.
Qué has aprendido, cómo se mide
Has tomado la primera decisión de modelado del curso con datos delante: un token es una jugada UCI de un vocabulario fijo de 2 030 entradas, con el Elo de los dos jugadores como tokens de control al principio y el resultado al final. Sabes por qué las otras dos opciones existen y qué cuestan, y puedes defender la elección con la tabla de estadísticas y no con una opinión. Tienes seis datasets con manifiesto y card, un dataloader que no desperdicia cálculo en relleno y un tokenizador que corre igual en Python y en el navegador.
Cómo se mide el módulo 1. Todo lo que sigue es un número o un test:
- Tokens por partida por esquema (media, p50, p95) en
tokenizer-stats.json, y el porcentaje de partidas ≤ 200 tokens: medido, 80,64 y 99,8 % con UCI fijo, 422,44 y 7,77 % con SAN por carácter, 68,05 y 99,84 % con BPE. - Tamaño de vocabulario: 2 030 (UCI), 35 (SAN carácter), 4 096 (BPE), y
len(vocab) == 2030como test unitario en Python ybuildVocab().length === 2030en TypeScript. - Paridad Python ↔ TypeScript: la fixture de 20 partidas (
fixtures/games.json) se reproduce id a id en las dos webs, cada una con su propiotests/parity.test.ts, así quepnpm testfalla si cambia la enumeración en un lado y no en el otro. Además, un test de hash comprueba que las copias del tokenizador enrukh-labson byte a byte las derukh-web: el hash solo detecta divergencias entre las dos webs, y por eso hace falta también la paridad en las dos. - Conteos y hashes del manifiesto: cada paso del pipeline registra filas leídas y conservadas, el sha256 de cada parquet y los parámetros con los que se ejecutó. Es la procedencia que va en las dataset cards, y es lo que permite decir “este modelo se entrenó con estas partidas exactamente”.
- Fracción de
<pad>en un lote del dataloader: 0,005 medido, medio por ciento.
Lo siguiente es M2: el decoder. Con los lotes (x, y) de este módulo vas a construir la atención
causal a mano, entrenar rukh-small sobre las tres tokenizaciones con el mismo presupuesto y
poner la primera fila con números en la tabla única. El tokenizador de TypeScript que has visto
funcionar aquí es el que convertirá tus jugadas en ids dentro del navegador.
La cheatsheet del módulo, diez preguntas con su respuesta corta, está justo debajo.
// cheatsheet M1
Ocho preguntas para llevarte
- 01¿Qué es un token y quién decide qué cuenta como token?
- La unidad mínima que el modelo lee y escribe, convertida a un id entero por el tokenizador. Lo decide quien diseña el modelo, no los datos: es la primera hipótesis sobre el dominio ("una jugada es la unidad") y cambia la longitud de las secuencias, lo que el modelo tiene que aprender y el coste de atención, que crece con el cuadrado de la longitud.
- 02¿Qué diferencia hay entre un vocabulario fijo, BPE y tokenización por carácter?
- El fijo se enumera sin mirar datos (en Rukh, las 1 968 jugadas UCI posibles más control y Elo: 2 030 tokens). Carácter usa un alfabeto mínimo (35 símbolos) y da secuencias cuatro o cinco veces más largas. BPE aprende fusiones de pares frecuentes desde los caracteres hasta un tamaño objetivo (4 096) y descompone lo raro en trozos, sin token desconocido.
- 03¿Cómo se entrena un BPE en dos frases?
- Se parte de los caracteres, se cuenta el par adyacente más frecuente en el corpus, se fusiona en un token nuevo y se repite hasta el tamaño de vocabulario pedido. Tokenizar es aplicar las fusiones en el mismo orden en que se aprendieron; sobre texto UCI, las primeras fusiones reconstruyen casillas y jugadas, y las últimas, trozos de apertura.
- 04¿Para qué sirven los tokens especiales?
- Son la interfaz de control: <bos> y <eos> marcan principio y fin, <pad> rellena y lo ignora la pérdida, <mask> es el hueco del encoder de M3, <unk> es una red de seguridad, los de resultado codifican quién ganó y los de Elo (<w1800>, <b1900>) condicionan la partida al nivel de cada jugador.
- 05¿Por qué el Elo va al principio de la secuencia y el resultado al final?
- El modelo es causal: solo ve lo anterior. Con el Elo delante, cada jugada se predice condicionada al nivel, y en M4 basta con poner <w1500> al principio para que juegue como un 1500 sin entrenar nada nuevo. El resultado al final se predice desde las jugadas y no contamina la generación.
- 06¿Qué es ignore_index y qué pasa si lo olvidas?
- Un parámetro de la entropía cruzada (CrossEntropyLoss(ignore_index=0)) que hace que las posiciones cuyo objetivo es <pad> no contribuyan a la pérdida ni al gradiente. Sin él, el modelo aprende que después de cualquier cosa viene <pad> y lo predice con confianza.
- 07¿Qué es el empaquetado en flujo y qué alternativa descartamos?
- Concatenar todas las partidas codificadas en un único array uint16 y servir ventanas de 200 tokens que empiezan en un <bos>, de modo que casi no hay padding y cada ejemplo lleva los tokens de control. Descartamos las ventanas en posiciones arbitrarias (estilo nanoGPT) porque perderían el Elo del principio, y las filas de una partida con padding porque desperdician más de la mitad del cálculo.
- 08¿Por qué partimos los datos por mes y los puzles por PuzzleId?
- Para evitar fugas: un jugador activo juega cientos de partidas con el mismo repertorio, y un split al azar pone sus partidas en los dos lados e infla la exactitud. Entrenar con enero y validar con febrero reproduce la situación real (partidas futuras). Los puzles se parten por su id con semilla 42 para que el conjunto de test sea reproducible y no cambie entre ejecuciones.
- 09¿Qué es un memmap y por qué lo usa el dataloader?
- Un fichero mapeado a memoria: np.load(mmap_mode='r') no lee el array, deja que el sistema operativo traiga las páginas cuando se tocan. Un GB de tokens arranca al instante, varios workers comparten las mismas páginas físicas y nadie copia el fichero en RAM.
- 10¿De dónde salen los pares de preferencia para DPO sin ejecutar Stockfish?
- Del dataset público de evaluaciones de Lichess, que tiene varias líneas principales por posición ordenadas de mejor a peor. La primera jugada de la mejor línea es la elegida y la primera jugada de una línea peor en 100 centipeones o más es la rechazada: un par por posición, equilibrado por fase y verificado legal con python-chess.