// 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.
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
| Lab | Ruta | Reloj | Deja | Atajo |
|---|---|---|---|---|
| Lab 1Explorar el parquet con DuckDB | imprescindible | segundos | las consultas de Elo, longitud y resultado, en pantalla; lee data/uci/ del lab 2 | no hay |
| Lab 2De SAN a UCI con python-chess | cuesta máquina | 10-20 min de CPU | data/uci/, 5 896 388 partidas | rukh pull rukh-games-1800 |
| Lab 3Las tres tokenizaciones y sus estadísticas | cuesta máquina | 3-8 min el BPE, 5-10 el UCI; el SAN, ~1,5 h y es opcional | artifacts/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 bloques | imprescindible | segundos | el precio del padding, medido | no hay |
| Lab 5Mirar las fusiones del BPE del lab 3 | imprescindible | segundos | las primeras fusiones, impresas | no hay |
| Lab 6El resto del pipeline, que leen M2 a M6 | cuesta máquina | 1,5-3,5 h, casi todo en data evals | positions, evals, puzzles, pairs, elite y elo-bins | rukh 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)).
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 columnsEl 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:
uv run rukh data uciSalida 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 keptmanifest: data/uci/manifest.jsonEl 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.
uv run rukh data tokenize --scheme bpe --packuv run rukh data tokenize --scheme san --packuv run rukh data tokenize --scheme uci --stats --export-fixture --packEl 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: bpevocab_size: 4096written: 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.jsonscheme: sanvocab_size: 35written: E:/work/ai/chess-lm/rukh/data/tokens/san/train/meta.json E:/work/ai/chess-lm/rukh/data/tokens/san/val/meta.jsonscheme: ucivocab_size: 2030written: 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.jsonLos recuentos no salen en pantalla: cada pack los guarda en su meta.json. Léelos los seis de una
vez:
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 202817459data/tokens/bpe/val 2946874 201497600data/tokens/san/train 2949514 1258749609data/tokens/san/val 2946874 1250789500data/tokens/uci/train 2949514 240068954data/tokens/uci/val 2946874 238657571Pá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.
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.0049999998882412912949514 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.
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 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:
uv run rukh data publish --listuv run rukh data publish --name rukh-games-1800 --dry-runAsí, 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) == 2030como test en Python ybuildVocab().length === 2030en TypeScript. - Paridad Python ↔ TypeScript:
tests/parity.test.tsreproduce id a id las 20 partidas de la fixture, así quepnpm testfalla 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
- M0 El tallerEntorno reproducible(hecho)
- M1 Datos y tokensDatos y tokenización(hecho)
- 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
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.