rukh · lab

// 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.

  • 210 min
  • nivel base
  • vigente
  • actualizado el21 de septiembre de 2026

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).

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
""")
)
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

Tres 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-0

clean_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.

Terminal
uv run rukh data uci

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

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

El 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.

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

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

scheme: bpe
vocab_size: 4096
scheme: san
vocab_size: 35
train: 2,949,514 games, 1,258,749,609 tokens
val: 2,946,874 games, 1,250,789,500 tokens

Pá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: uci
vocab_size: 2030
train: 2,949,514 games, 240,068,954 tokens
val: 2,946,874 games, 238,657,571 tokens

Esas 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:

EsquemaVocabularioTokens/partida (media)p50p95% ≤ 200 tokens
UCI (vocabulario fijo)203080,647613999,8
SAN (carácter)35422,443937547,77
BPE409668,056212999,84
// tokens BPE más largos
  1. e2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6b1c3a7a6 · 10 jugadas
  2. e2e4e7e6d2d4d7d5e4e5c7c5c2c3b8c6g1f3d8b6 · 10 jugadas
  3. e2e4c7c5g1f3d7d6d2d4c5d4f3d4g8f6b1c3 · 9 jugadas
  4. e2e4e7e6d2d4d7d5e4e5c7c5c2c3b8c6g1f3 · 9 jugadas
  5. e2e4c7c5g1f3b8c6d2d4c5d4f3d4g8f6b1c3 · 9 jugadas
  6. e2e4c7c5g1f3b8c6d2d4c5d4f3d4e7e5d4b5 · 9 jugadas
  7. e2e4e7e5g1f3b8c6f1c4g8f6f3g5d7d5e4d5 · 9 jugadas
  8. d2d4g8f6c2c4g7g6b1c3f8g7e2e4d7d6 · 8 jugadas
  9. e2e4e7e6g1f3d7d5e4d5e6d5d2d4g8f6 · 8 jugadas
  10. e2e4c7c6g1f3d7d5e4d5c6d5d2d4b8c6 · 8 jugadas

Fuente: rukh data tokenize --stats · 20.000 partidas

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 = 200 no 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.

src/rukh/tokenize/loader.py (esquema)
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, y

Léelo con calma, porque cada línea es una decisión de las que hablamos arriba:

  • mmap_mode="r" es el memmap: tokens.npy puede medir un GB y np.load no 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 el DataLoader con su semilla. Con start_at_game=False el 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 + 1 tokens porque el objetivo y es la entrada desplazada una posición: y[t] es el token que viene después de x[t]. Por eso y[:-1] == x[1:] en todos los ejemplos, y es una de las aserciones de los tests. La última posición de y se 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 el ignore_index de la pérdida (IGNORE_INDEX = 0 en el módulo): esas posiciones no cuentan.
  • dtype=np.int64: se guarda en uint16 (2 bytes, suficiente para 4 096 ids) para que el fichero sea pequeño, y se convierte a int64 al salir porque es lo que nn.Embedding y la pérdida esperan como índices. El .copy() es necesario porque un trozo de memmap es de solo lectura y torch.from_numpy quiere 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).

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()
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

Lé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.

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}")
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, 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:

Terminal
uv run rukh data positions # 5-10 min · data/positions/positions.parquet
uv run rukh data evals # 1-3 h con red, reanudable · data/evals/positions-eval.parquet
uv run rukh data puzzles # 3-6 min con red · data/puzzles/puzzles.parquet
uv run rukh data pairs # segundos · data/pairs/pairs.parquet y dpo-prompts.parquet
uv 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.parquet

Quié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 tokens

una 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 tokens

un 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 tokens

fusiones 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:

  1. 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.
  2. El mate del pastor, 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7#. Fíjate en cómo el # y la x ocupan un carácter cada uno en SAN y ninguno en UCI: la captura y el jaque mate están implícitos en h5f7.
  3. 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.
  4. 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) == 2030 como test unitario en Python y buildVocab().length === 2030 en 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 propio tests/parity.test.ts, así que pnpm test falla 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 en rukh-lab son byte a byte las de rukh-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.
Todas las cheatsheets, imprimibles →