// 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.
// el recorrido · dónde estás
- M0 El tallerEntorno reproducible(hecho)
- M1 Datos y tokensDatos y tokenización(estás aquí)
- M2 El decoderPreentrenar un GPT(por delante)
- M3 El encoderEmbeddings y clasificar(por delante)
- M4 Fine-tuningSFT y LoRA(por delante)
- M5 AlineamientoPreferencias: DPO, GRPO(por delante)
- M6 Evaluar y publicarMedir y desplegar(por delante)
- FASE 2 El entrenadorAgentes, RAG, MCP
M1, lección a lección
- 01Del PGN al tensor: qué es un token
- 02De PGN a UCI: reproducir seis millones de partidas
- 03El pipeline: un YAML, nueve pasos, siete subcomandos
- 04Posiciones y evaluaciones: el join que no descarga 42 GB
- 05Puzles y pares de preferencia: dos conjuntos que ya estaban hechos
- 06Los tres vocabularios
- 07Empaquetar y servir lotes
- 08Publicar el dataset
- 09El tokenizador en el navegador
- 10Los labs del pipeline
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 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 desdepython-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 conpython-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 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, y el modelo tiene que descubrir que los caracteres van de cuatro en cuatro y quee2e4no tiene nada que ver cone2e3aunque compartan tres de cuatro.
La diferencia se nota 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; 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.
- 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.
- 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.
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.
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 → publishLa 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.
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 |