rukh · lab

// M1 · lección 10

Los labs del pipeline

Los seis labs de M1 con sus salidas reales: explorar el parquet con DuckDB, convertir a UCI, empaquetar los tres esquemas, leer un lote del dataloader, mirar las fusiones del BPE y lanzar el resto del pipeline.

  • tiempo de trabajo60 min
  • además, ejecución sin supervisión+ 175 min de GPU y red
  • nivel base
  • actualizado el23 de septiembre de 2026

Qué vas a hacer

Ejecutar el módulo entero. Las lecciones anteriores tienen el código; esta tiene los comandos, los tres scripts de labs/m1/ y las salidas reales de la ejecución de referencia, que sostienen las decisiones del módulo: el 99,8 % de partidas que caben en 200 tokens, el 5,2× entre UCI y SAN y el medio por ciento de relleno del dataloader. Los labs siguen el orden del pipeline, y el sexto lanza el resto de los pasos que leen M2 a M6.

// Antes de empezarQué cuesta cada lab, y cuál puedes saltarte

LabRutaRelojDejaAtajo
Lab 1Explorar el parquet con DuckDBimprescindiblesegundoslas consultas de Elo, longitud y resultado, en pantalla; lee data/uci/ del lab 2no hay
Lab 2De SAN a UCI con python-chesscuesta máquina10-20 min de CPUdata/uci/, 5 896 388 partidasrukh pull rukh-games-1800
Lab 3Las tres tokenizaciones y sus estadísticascuesta máquina3-8 min el BPE, 5-10 el UCI; el SAN, ~1,5 h y es opcionalartifacts/tokenizer/bpe.json, data/tokens/{bpe,san,uci}/rukh pull rukh-tokenizer deja el tokenizador; los packs se regeneran en minutos
Lab 4Dataset y DataLoader con empaquetado por bloquesimprescindiblesegundosel precio del padding, medidono hay
Lab 5Mirar las fusiones del BPE del lab 3imprescindiblesegundoslas primeras fusiones, impresasno hay
Lab 6El resto del pipeline, que leen M2 a M6cuesta máquina1,5-3,5 h, casi todo en data evalspositions, evals, puzzles, pairs, elite y elo-binsrukh pull rukh-positions-eval se salta data evals, el único caro
imprescindible
Segundos o pocos minutos, y el lab siguiente da por hecho que lo corriste.
cuesta máquina
Tiempo real de GPU, red o motor. El reloj es el de la RTX 5090 de referencia.

Los tres scripts de labs/m1/ se ejecutan desde la raíz del repositorio y cada uno necesita un paso previo: explore.py lee data/uci/, loader_check.py el pack de tokenize --scheme uci --pack y bpe_merges.py el artifacts/tokenizer/bpe.json de tokenize --scheme bpe.

Lab 1 · Explorar el parquet con DuckDB

// Viene delección 2 · De PGN a UCI: reproducir seis millones de partidas

Antes de tokenizar nada, mira los datos. Un detalle 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)).

labs/m1/explore.py
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
""")
)

labs/m1/explore.pylíneas 1-41 · p1

Terminal
uv run python labs/m1/explore.py

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

El Elo empieza en 1800 por el filtro y cae a plomo: el 53,2 % de las partidas son de un 1800 o un 1900, y de 2500 para arriba queda el 1,5 %. Eso justifica el muestreo de elo-bins.

La duración tiene cola larga: media 76,4 medias jugadasPly (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., p95 135 y máximo 300, el tope del filtro. Caben en 195 plies (200 tokens menos cinco de control) el 99,8 %, así que max_len = 200 cubre prácticamente todo el recorte.

En las aperturas mandan los cajones de sastre de ECO (A40, B00, A00: primeras jugadas sin teoría clasificada) junto a aperturas enteras como la escandinava o la francesa. Es lo esperable a 1800-2000 en ritmo rápido: se sale pronto de la teoría. Y la victoria de blancas varía entre aperturas (47,3 % en B10, 50,0 % en D02), una asimetría que el modelo 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, encode_game construye la secuencia entera y la corta en el token 200: el resultado y el <eos> se quedan fuera, y la partida termina en una jugada cualquiera. Es el mal menor de la lección de los vocabularios, y afecta al 0,2 % de las partidas.

Lab 2 · De SAN a UCI

// Viene delección 2 · De PGN a UCI: reproducir seis millones de partidas

El código está entero en la lección «De PGN a UCI». Aquí solo se ejecuta:

Terminal
uv run rukh data uci

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

months: 2025-01, 2025-02
2025-01: 2949514 games kept
2025-02: 2946874 games kept
manifest: data/uci/manifest.json

El desglose está en data/uci/manifest.json, bajo filters.conversion. De 6 000 000 de partidas quedan 5 896 388; se descartan 103 251 por cortas y 361 por largas, y las ilegales son 0. Ese cero es el que había que mirar: como Lichess valida las partidas, un solo ilegal habría delatado una limpieza del movetext que rompe algo.

// 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 san y 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 parece una jugada) y san_to_uci devuelve None, para no interrumpir el lote de veinte mil dentro del pool. El camino de vuelta ya está escrito: uci_to_san, en src/rukh/tokenize/san_chars.py.

Lab 3 · Los tres esquemas, empaquetados

// Viene delección 6 · Los tres vocabularios y lección 7 · Empaquetar y servir lotes

Tres comandos, y el orden importa: la fixture solo incluye los ids BPE si bpe.json ya existe, así que el BPE va primero y el uci con la fixture, al final. El BPE tarda de tres a ocho minutos y el uci, de cinco a diez; el SAN, en torno a hora y media. Ese es opcional: ningún módulo posterior lee su pack, y su recuento está más abajo.

Terminal
uv run rukh data tokenize --scheme bpe --pack
uv run rukh data tokenize --scheme san --pack
uv run rukh data tokenize --scheme uci --stats --export-fixture --pack

El comando imprime el esquema, el tamaño del vocabulario y los ficheros que ha escrito, con la ruta absoluta de tu repositorio (aquí, la de la máquina de referencia):

scheme: bpe
vocab_size: 4096
written:
E:/work/ai/chess-lm/rukh/artifacts/tokenizer/bpe.json
E:/work/ai/chess-lm/rukh/data/tokens/bpe/train/meta.json
E:/work/ai/chess-lm/rukh/data/tokens/bpe/val/meta.json
scheme: san
vocab_size: 35
written:
E:/work/ai/chess-lm/rukh/data/tokens/san/train/meta.json
E:/work/ai/chess-lm/rukh/data/tokens/san/val/meta.json
scheme: uci
vocab_size: 2030
written:
E:/work/ai/chess-lm/rukh/artifacts/web/tokenizer-stats.json
E:/work/ai/chess-lm/rukh/data/tokens/uci/train/meta.json
E:/work/ai/chess-lm/rukh/data/tokens/uci/val/meta.json
E:/work/ai/chess-lm/rukh/artifacts/tokenizer/vocab.json
E:/work/ai/chess-lm/rukh/artifacts/tokenizer/fixtures/games.json

Los recuentos no salen en pantalla: cada pack los guarda en su meta.json. Léelos los seis de una vez:

Terminal window
uv run python -c "import json, pathlib; [print(p.parent.as_posix(), json.loads(p.read_text())['n_games'], json.loads(p.read_text())['n_tokens']) for p in sorted(pathlib.Path('data/tokens').glob('*/*/meta.json'))]"

Los meta.json de la ejecución de referencia (2026-09-19) dan:

data/tokens/bpe/train 2949514 202817459
data/tokens/bpe/val 2946874 201497600
data/tokens/san/train 2949514 1258749609
data/tokens/san/val 2946874 1250789500
data/tokens/uci/train 2949514 240068954
data/tokens/uci/val 2946874 238657571

Párate en las filas de san y uci, el argumento más fuerte del módulo. Son las mismas partidas; solo cambia cómo se escriben las jugadas. Y el flujo de entrenamiento pasa de 240 068 954 tokens a 1 258 749 609: 5,2 veces más tokens para el mismo corpus. El BPE, con 202 817 459, se queda en 0,84 veces el UCI.

El pack uci es el corpus del primer decoder de M2: 240 068 954 tokens de entrenamiento (enero) y 238 657 571 de validación (febrero). La tabla de estadísticas que escribe el último comando se lee en «Empaquetar y servir lotes».

// 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 y no hace falta calcularlos a mano. Lo importante es que la posición y los ids de los tokens de control son fijos, y eso permite que TypeScript sea idéntico sin compartir código.

Lab 4 · Un lote del dataloader

// Viene delección 7 · Empaquetar y servir lotes

Este script lee el pack de enero y pide un lote. El if __name__ == "__main__": hace falta: en Windows los workers del DataLoader arrancan con spawn, reimportando el módulo, y sin la guarda cada worker volvería a ejecutar el script entero.

labs/m1/loader_check.py
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()

labs/m1/loader_check.pylíneas 1-14 · p1

Terminal
uv run python labs/m1/loader_check.py

Salida 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.004999999888241291

2949514 son las partidas de enero, una por ejemplo. Los seis primeros ids de x decodifican a <bos> <w2000> <b2000> e2e4 c7c6 d2d4, una Caro-Kann entre dos 2000, e y es la misma lista desplazada una posición (acaba en 918, d7d5). La última cifra es la fracción de <pad> en los objetivos: 0,005. Con una partida por fila rondaría el 60 %.

Lab 5 · Las fusiones del BPE

// Viene delección 6 · Los tres vocabularios

Entrenar el BPE es una llamada a una librería; lo interesante es mirar qué ha aprendido.

labs/m1/bpe_merges.py
import json
from 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}")

labs/m1/bpe_merges.pylíneas 1-16 · p1

Terminal
uv run python labs/m1/bpe_merges.py

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

Las primeras fusiones son casillas de las jugadas de desarrollo más comunes; después vienen jugadas enteras, y después lo interesante: los diez tokens más largos son aperturas completas. El más largo, de 40 caracteres, es 1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6, la siciliana Najdorf, diez medias jugadas en un solo id. El otro de 40 es la francesa de avance, y entre los de 32 está la india de rey.

El BPE no sabe ajedrez: ha contado pares. Que contar pares baste para descubrir, sin un libro de aperturas ni saber que hay un tablero, que esas diez jugadas van juntas es la intuición detrás de que los LLM de texto tengan tokens para ing o the. También es su límite: un modelo que emita ese token se compromete con diez jugadas de golpe, y 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 curso entrena con UCI.

Las 4 069 fusiones para 4 096 tokens cuadran: los 27 restantes son los caracteres iniciales y los ocho especiales, que estaban desde el principio.

// Ejercicio 04Un 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 de la del UCI fijo: 68,05 frente a 80,64. 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 M2 entrena con los tres.

Lab 6 · El resto del pipeline

// Viene delección 3 · El pipeline: un YAML, nueve pasos, siete subcomandos, lección 4 · Posiciones y evaluaciones: el join que no descarga 42 GB, lección 5 · Puzles y pares de preferencia: dos conjuntos que ya estaban hechos y lección 8 · Publicar el dataset

Los seis pasos que quedan producen lo que leen M2 a M5, un comando cada uno; su código está en las lecciones de posiciones, de puzles y pares, y del pipeline.

evals es el paso caro y el único que merece la pena saltarse: desde el clon de rukh del punto de partida, RUKH_HOME=<ruta-a-tu-repo> uv run rukh pull rukh-positions-eval (en PowerShell, con $env:RUKH_HOME como arriba) deja data/evals/positions-eval.parquet con su manifiesto. Los demás se regeneran en minutos.

Y con los seis hechos, el módulo se publica:

Terminal
uv run rukh data publish --list
uv run rukh data publish --name rukh-games-1800 --dry-run

Así, en seco, la card sale a nombre de chorcat, el owner de la configuración del curso. Para subirlo de verdad a tu cuenta, añade --config data/mi-hub.yaml, el YAML de dos líneas de «Publicar el dataset», y quita --dry-run.

// Ejercicio 05Lee un manifiesto antes de fiarte del fichero

Abre data/pairs/manifest.json y compara counts.candidates con la suma de las tres fases. ¿Por qué el balanceado tira más de dos tercios de los candidatos? Después mira filters.empty_phases y filters.candidates_by_phase: ¿qué te dirían si el fichero hubiera salido vacío?

// SoluciónVer la solución

Los candidatos son 69 246 y el balanceado deja 4 629 por fase, 13 887 en total, porque manda la fase más pequeña, los finales. Tirar dos tercios es el precio de que las tres fases pesen lo mismo.

Si una fase se hubiera quedado a cero, balance devolvería un dataframe vacío y el paso terminaría en verde con un parquet sin filas. Esas dos claves y el LOGGER.warning serían lo único que lo diría, y la causa casi siempre sería la cobertura del join de evals.

Qué has aprendido, cómo se mide

El aprendiz ya sabe leer. Al empezar el módulo tenía una biblioteca de partidas escritas para personas; ahora cada partida es una fila de símbolos que entiende, con el nivel de los jugadores delante y el resultado al final, y la biblioteca está ordenada para que pueda leer millones de ellas sin esperar. Todavía no ha jugado una sola jugada: eso empieza en M2.

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 delante y el resultado al final. Sabes qué cuestan las otras dos opciones y puedes defender la elección con una tabla medida. Tienes siete artefactos 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 y porcentaje que cabe en 200, en tokenizer-stats.json: 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, 35 y 4 096, con len(vocab) == 2030 como test en Python y buildVocab().length === 2030 en TypeScript.
  • Paridad Python ↔ TypeScript: tests/parity.test.ts reproduce id a id las 20 partidas de la fixture, así que pnpm test falla si la enumeración cambia en un lado y no en el otro.
  • Conteos y hashes del manifiesto en cada paso: la procedencia que va en las dataset cards.
  • Fracción de <pad> en un lote del dataloader: 0,005.

Lo siguiente es M2: con estos lotes (x, y) construirás la atención causal a mano, entrenarás rukh-small sobre las tres tokenizaciones con el mismo presupuesto y pondrás la primera fila en la tabla única.

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

// el recorrido · lo conseguido

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

lo que tienes ahora

Seis millones de partidas limpias convertidas en lotes de enteros, tres tokenizaciones comparadas con números, seis datasets publicados y el mismo tokenizador en Python y en el navegador.

siguiente parada · M2 El decoder

Lee millones de partidas y aprende a adivinar la jugada siguiente. Juega de oído: sabe qué suele venir, todavía no por qué.

// cheatsheet M1

Diez 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 el modelo aprende que después de `<w1900>` vienen unas jugadas y después de `<w2400>` otras. Condicionar elige a quién imitar, no sube el nivel, y un token que no aparece en los datos no aprende nada: los tramos por debajo de 1800 están en el vocabulario pero el recorte no los contiene. El resultado, en cambio, va 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. Y el tamaño de lo apartado se decide por el error típico de la medida que sostiene, no por un porcentaje.
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. Los 480 MB de tokens de un mes arrancan 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.
Todas las cheatsheets, imprimibles →