// M1 · lección 02
Del PGN al tensor: los labs
Los cinco labs del pipeline de datos con sus salidas reales: explorar el parquet con DuckDB, de SAN a UCI, las tres tokenizaciones medidas, el dataloader con empaquetado y las fusiones del BPE. Cierra con el tokenizador en el navegador y una lectura de diez minutos sobre lo que había antes.
Parte 2 de 2 del módulo «Del PGN al tensor». Viene de «Del PGN al tensor: qué es un token» y cierra el módulo.
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. El sexto no es un lab: es el resto del pipeline, lo que los módulos siguientes van a leer, con el comando de cada paso.
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 factor 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, y por eso la tokenización es un módulo
entero y no un apartado de media página. 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. No es una preferencia de formato: es un factor sobre la
factura medido sobre seis millones de partidas reales, 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 el primer decoder de M2: 240 068 954 tokens de entrenamiento (enero) y 238 657 571 de validación (febrero entero; el recorte de la validación a 100 000 partidas llega en la parte 4 de M2, y esta salida es anterior).
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 el mismo 5,2× que acabas de ver sobre el corpus entero: 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.
// 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.
Lab 6 · El resto del pipeline, para los módulos que vienen
Los cinco labs anteriores dejan data/uci/ y data/tokens/. Los seis pasos que quedan del
diagrama de la parte anterior producen lo que M2, M3, M4 y M5 leen, y son un comando cada uno:
uv run rukh data positions # 5-10 min · data/positions/positions.parquetuv run rukh data evals # 1-3 h con red, reanudable · data/evals/positions-eval.parquetuv run rukh data puzzles # 3-6 min con red · data/puzzles/puzzles.parquetuv run rukh data pairs # segundos · data/pairs/pairs.parquet y dpo-prompts.parquetuv run rukh data elite # 5-10 min con red · data/elite/games.parquet (dos meses)uv run rukh data elo-bins # 1-2 min · data/elo-bins/games.parquetQuién lee qué: la suite de evaluación de M2 necesita los puzles y las partidas; las etiquetas del
encoder de M3 salen de positions-eval.parquet cruzado con data/uci; los pares de M5 son
dpo-prompts.parquet, cada par con las jugadas que llevaron a su posición y los dos Elo, que es la
única forma en que un modelo que lee secuencias puede usar un par definido por un FEN. evals es
el paso caro y el único que merece la pena saltarse con uv run rukh pull rukh-positions-eval; los
demás se regeneran en minutos, y cada uno deja un manifest.json con sus filtros, conteos y hashes.
// Ejercicio 06Lee 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 compara counts.prompts con
la suma: ¿qué pares se quedan sin prompt, y por qué no se recortan en vez de descartarse?
// SoluciónVer la solución
Los candidatos son 69 246 y el balanceado deja 4 629 por fase, porque la fase más pequeña —los
finales— manda: igualar las tres al tamaño de la menor es lo que impide que el conjunto sea solo
aperturas. Los prompts son 13 842 de 13 887: los que faltan son pares en partidas tan largas que
las tres cabeceras más las jugadas hasta la posición no caben en los 200 tokens del decoder.
Recortar el principio del prefijo le daría al modelo una posición que nunca ocurrió, así que se
descartan y se cuentan (prompts_dropped), nunca se inventan.
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
Cuatro 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: una nota adhesiva que se reescribe después de cada página del libro, y en
la que solo cabe lo que cabe. 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. Es un dictado corregido palabra a palabra: el alumno propone la
siguiente, se le corrige y se sigue desde el texto correcto, no desde lo que él escribió, para que
un error al principio no arrastre todo lo demás.
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
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 `<w1500>` vienen unas jugadas y después de `<w2400>` otras. Lo que esta ficha decía hasta que se midió —que en M4 bastaría con poner `<w1500>` al principio sin entrenar nada nuevo— es **falso**: el recorte se descarga con `min_elo: 1800`, así que los doce tokens por debajo nunca recibieron un gradiente y pedirlos era enseñarle ruido. Hizo falta un corpus nuevo y un afinado (M4). 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); desde M2 parte 4 la validación son las primeras 100 000 partidas de febrero y el resto del mes entrena. 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.