rukh · lab

// M4 · lección 02

Los tokens de Elo y el corpus que faltaba

Los doce tokens de Elo por debajo de 1800 que nunca recibieron un gradiente, y el código que arregla la mitad baja del eje: el max_elo que faltaba en fetch.py, un descargador que baja cada shard entero en vez de leerlo en remoto, el muestreo plano de elo_bins.py, las rebanadas de partidas que hacen que un mes alimente los dos splits, y el afinado que empieza desde unos pesos sin heredar el optimizador.

  • tiempo de trabajo150 min
  • además, ejecución sin supervisión+ 35 min de GPU y red
  • nivel medio
  • actualizado el23 de septiembre de 2026

Lección 2 de 10 del módulo «Fine-tuning e instrucción». Viene de «Fine-tuning: qué es y cuántas maneras hay» y sigue en «LoRA escrita a mano». Es la primera lección de código del módulo y la que hay que hacer antes que ninguna otra.

Qué vas a construir

Antes de afinar para que el modelo juegue «como alguien de 1500» hay que arreglar un agujero: la mitad baja de la escala de Elo no se había entrenado nunca. La reparación tiene tres piezas: un segundo corpus que se diferencia del primero solo en el rango de fuerza, un muestreo que lo aplana banda a banda y un afinado que arranca desde unos pesos ya entrenados sin heredar nada más. Al terminar tendrás data/elo-bins-v2/games.parquet, con 1 219 423 partidas en nueve bandas, y un medium-elo entrenado sobre ellas.

El agujero: doce tokens que nunca recibieron un gradiente

El vocabulario de Rukh tiene, desde M1, veintisiete tokens de Elo por bando: <w0600>, <w0700>, …, <w3200>, y sus gemelos <b….>. Cada partida de entrenamiento empieza con <bos> <wXXXX> <bXXXX> y sigue con las jugadas. El modelo aprende qué sigue a cada cabecera porque la ha visto seguida de partidas de gente con esa fuerza. El problema estaba en las diez líneas con las que se descargó todo el corpus del proyecto en M1:

configs/data/lichess-2025-01-02.yaml
# Two recent months of rated standard games, filtered remotely (see docs/spec/01-datos.md).
dataset: Lichess/standard-chess-games
months: ["2025-01", "2025-02"]
min_elo: 1800
min_base_seconds: 180
terminations: ["Normal", "Time forfeit"]
min_plies: 20
exclude_variants: true
out_dir: data/raw
limit: 3000000

configs/data/lichess-2025-01-02.yamllíneas 1-10 · p4

La cuarta línea. min_elo: 1800 se aplica a los dos jugadores, y es el filtro con el que se descargó todo: el corpus base, la Elite Database y los 19 millones de partidas de tokens-v4 con los que se entrenó el modelo de la demo. Así que los doce tokens por debajo de 1800, de <w0600> a <w1700>, nunca recibieron un gradiente. Su embedding sigue donde lo dejó la inicialización, un vector aleatorio sin relación con nada: pedir «juega como 1500» era ponerle ruido delante.

Es el botón de un ascensor que lleva a un piso que no existe: está en el panel, se ilumina al pulsarlo y el ascensor no va a ninguna parte.

el vocabulario · 27 tokens por bando, desde P1el corpus de preentrenamiento · min_elo 1800, los dos jugadoresdoce tokens sin un solo gradientepedirle «juega como 1500» le enseña un vector de la inicializacióntras el afinado de M4 · corpus plano de 1000 a 2600, encima del anteriorcuatro siguen sin gradiente: por debajo de 1000 no se descargó nada600100014001800220026003000
El eje existía en el vocabulario desde el primer día. Lo que no existía eran los datos con los que aprender qué significa la mitad izquierda; el afinado de M4 rellena de 1000 en adelante y deja cuatro tokens, de 600 a 900, tal y como estaban.

Un fallo así no se ve: el token existe, el tokenizador lo emite sin quejarse y el modelo produce una distribución normal sobre jugadas legales. Solo que no cambia como uno esperaría, y eso se confunde con «el condicionamiento no funciona». Lo destapó contar cuántas veces aparece cada cabecera en el corpus; es el lab 1 del módulo.

El filtro que faltaba

La configuración de la reparación es la de M1 con otro rango de Elo, otra carpeta de salida y otro tope de partidas:

configs/data/lichess-low.yaml
# The band the first corpus left out: club players, 1000-1799, both sides.
#
# `lichess-2025-01-02.yaml` fetched everything from 1800 up, so the Elo header tokens below
# `<w1800>` have never seen a gradient and "play like 1500" asks the model to read a vector that
# is still at its initialisation. This file closes that hole with the *same* filters (>= 180 s of
# base time, normal terminations, no variants, >= 20 plies deferred to P1) so the two halves of
# the axis differ in rating and in nothing else.
#
# It goes to its own `out_dir`: the 1800+ corpus is what every published model trained on, and
# mixing a weaker slice into `data/raw` would silently change what `data/uci` means.
dataset: Lichess/standard-chess-games
months: ['2025-01', '2025-02']
min_elo: 1000
max_elo: 1799
min_base_seconds: 180
terminations: ['Normal', 'Time forfeit']
min_plies: 20
exclude_variants: true
out_dir: data/raw-low
# 1.2 M games a month is far more than the balanced sample needs (four 200-Elo bands at up to
# 200 000 each), and the limit stops the download: club games are the bulk of Lichess, so the
# band fills after a handful of the month's 72 one-gigabyte shards instead of all of them.
limit: 1200000
cache_dir: data/hf-cache

configs/data/lichess-low.yamllíneas 1-24 · p4

Cambian cuatro claves: min_elo baja a 1000, aparece un max_elo de 1799, out_dir va a data/raw-low y limit sube a 1 200 000. Todo lo demás es idéntico a propósito (tiempo base, terminaciones, variantes, mínimo de 20 pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply.): las dos mitades del eje tienen que diferenciarse en la fuerza y en nada más, o cualquier otra diferencia acabaría atribuida a la fuerza. El out_dir propio evita que data/uci, el corpus de todo lo publicado, cambie de significado en silencio.

fetch.py: el descargador que sobrevivió al anfitrión

El fichero ya existía desde M1 y este hito lo cambia en dos sitios: el filtro gana un tope y la descarga cambia de estrategia. La razón de lo segundo está en el docstring del módulo, que abre el primer pliegue de abajo.

Todo fetch.py y el db.py que lo acompaña son fontanería de descarga, y los pliegues de esta sección se pueden saltar en la primera lectura. Lo que cuenta para el afinado cabe en una frase: el filtro gana un techo de Elo (max_elo) para bajar solo la banda 1000-1799, y los meses se bajan shard a shard porque leerlos en remoto acababa en HTTP 429. El «Ejecútalo» del final comprueba lo que escribas, así que si saltas los pliegues, trae los dos ficheros en vez de teclearlos: git checkout p4 -- src/rukh/data/fetch.py src/rukh/data/db.py.

// FontaneríaPor qué los meses se bajan shard a shard y no se leen en remoto por hf://
src/rukh/data/fetch.py
The months are read **shard by shard, downloaded first**, not streamed over ``hf://``. Streaming
was the original design and it is the obvious one: DuckDB reads the remote parquet directly and
only the rows that pass the filter ever land on disk. It stopped working. A month is 72 files of
about a gigabyte, and reading them remotely means thousands of small range requests against one
host, which earns an ``HTTP 429`` partway through and then a retry schedule that doubles until
the throughput is measured in kilobytes. Measured on 2026-09-20: streaming stalled at 0.02 MB/s
after fifty minutes with nothing written, while downloading the same shard whole ran at 14.8 MB/s.
So each shard is fetched once, with ``huggingface_hub`` (which caches, resumes and backs off
properly), filtered locally into a part file and deleted again. Peak disk is one shard, the work
resumes where it stopped, and ``limit`` now stops the download early instead of only trimming the
result -- for a slice of the rating range that is the difference between 8 shards and 72.

src/rukh/data/fetch.pylíneas 9-20 · p4

El diseño original, DuckDB leyendo el parquet remoto por hf:// con empuje de predicadosPredicate pushdownEmpujar el filtro hasta el almacén de datos para que solo viaje lo que pasa el filtro, en vez de traerlo todo y descartar en local. Es lo que permite que rukh data fetch saque un recorte de dos meses de Lichess (73 GB por mes en parquet) descargando solo los pocos GB de partidas que cumplen los filtros. para que solo crucen la red las filas que pasan el filtro, es el elegante y el que hay que intentar primero. Falló por algo ajeno a SQL: un mes son 72 shardsShardUno de los ficheros en los que se parte un dataset grande. Un mes de Lichess/standard-chess-games son 72 parquet de un gigabyte, y de cómo se leen depende que la descarga funcione: leerlos en remoto son miles de peticiones de rango contra un mismo anfitrión, que contesta HTTP 429 a mitad de camino (medido: 0,02 MB/s después de cincuenta minutos, con cero bytes escritos). Bajar cada shard entero, filtrarlo en local y borrarlo va a 14,8 MB/s, deja un pico de disco de un fichero y permite reanudar por el shard en el que se paró. de un gigabyte, leerlos en remoto son miles de peticiones de rango contra un mismo servidor, y el servidor contesta HTTP 429. Pedir pocos ficheros grandes termina; pedir miles de trozos pequeños, no.

La otra mitad de la respuesta al 429

Quedan las conexiones de DuckDB que siguen leyendo por hf://. Eso vive en src/rukh/data/db.py, el módulo que fabrica todas las conexiones del pipeline.

// Fontaneríadb.py: reintentos, tiempos de espera y token para las lecturas por hf://
src/rukh/data/db.py
Finally, it signs the requests to ``hf://``. Reading a month of Lichess means a few hundred range
requests against the same host, and anonymous ones get ``HTTP 429 Too Many Requests`` partway
through -- which arrives as a failed ``COPY`` after minutes of work, with no partial file to
resume from. A token raises the allowance and identifies the traffic, and the retry budget is
raised from three attempts to eight because a 429 is a "wait", not a "no".

src/rukh/data/db.pylíneas 14-18 · p4

La idea es que un 429 significa «espera»: un cliente que lo trata como un fallo definitivo tira minutos de trabajo. Vale para cualquier API con cuota, incluidas las de los LLM, que contestan con este mismo código cuando te pasas de peticiones por minuto.

src/rukh/data/db.py
class DuckDbConfig(BaseConfig):
"""Settings applied to every DuckDB connection (``duckdb:`` in ``pipeline.yaml``)."""
memory_limit: str = "32GB"
threads: int = Field(default=0, ge=0)
"""``0`` = ``cpu_count()``."""
preserve_insertion_order: bool = False
"""Keep the input order in the output. Off: it buffers the whole result of a ``COPY``."""
http_retries: int = Field(default=8, ge=0)
"""Attempts per HTTP request. The default of 3 is not enough for a rate-limited host."""
http_retry_wait_ms: int = Field(default=500, ge=0)
"""First backoff, doubled by ``http_retry_backoff`` on each attempt."""
http_timeout: int = Field(default=120, ge=1)
"""Seconds before a single request is given up on; the default 30 is tight over ``hf://``."""
authenticate: bool = True
"""Sign ``hf://`` requests with the locally stored Hugging Face token when there is one."""

src/rukh/data/db.pylíneas 41-56 · p4

Cada ajuste dice en su docstring contra qué valor por defecto va: ocho intentos en vez de tres, 120 segundos por petición en vez de 30 y una bandera para firmar las peticiones.

src/rukh/data/db.py
def hf_token() -> str | None:
"""The Hugging Face token this machine is logged in with, or None.
Read through ``huggingface_hub`` rather than from the environment so it picks up the token
that ``huggingface-cli login`` wrote, which is where it usually lives on a workstation.
"""
try:
from huggingface_hub import get_token
except ImportError: # pragma: no cover - the hub is a hard dependency
return None
token = get_token()
return token or None
def authenticate(con: duckdb.DuckDBPyConnection, token: str | None = None) -> bool:
"""Give the connection a Hugging Face secret; return whether one was installed."""
secret = token if token is not None else hf_token()
if not secret:
log.info("no Hugging Face token found: hf:// reads go out anonymous and rate-limited")
return False
con.execute("INSTALL httpfs")
con.execute("LOAD httpfs")
con.execute("CREATE OR REPLACE SECRET hf (TYPE huggingface, TOKEN ?)", [secret])
return True

src/rukh/data/db.pylíneas 66-89 · p4

El token se lee a través de huggingface_hub porque en una estación de trabajo suele vivir en el fichero que escribió huggingface-cli login, no en una variable de entorno. Si no hay ninguno, la función lo dice en el log y sigue: sin token se lee igual, con menos cuota. Y el SECRET va con parámetro (?) en vez de interpolado, porque lo interpolado sería una credencial en cualquier traza de la consulta.

src/rukh/data/db.py
def connect(cfg: DuckDbConfig | None = None) -> duckdb.DuckDBPyConnection:
"""An in-memory DuckDB connection with the spill directory, memory limit and threads set."""
import duckdb
settings = cfg or DuckDbConfig()
con = duckdb.connect()
con.execute("SET temp_directory = ?", [temp_directory().as_posix()])
con.execute("SET memory_limit = ?", [settings.memory_limit])
con.execute("SET threads = ?", [settings.threads or (os.cpu_count() or 4)])
con.execute("SET preserve_insertion_order = ?", [settings.preserve_insertion_order])
con.execute("SET http_retries = ?", [settings.http_retries])
con.execute("SET http_retry_wait_ms = ?", [settings.http_retry_wait_ms])
con.execute("SET http_timeout = ?", [settings.http_timeout])
if settings.authenticate:
authenticate(con)
return con

src/rukh/data/db.pylíneas 92-107 · p4

Una sola fábrica de conexiones permite que un ajuste como este llegue a los ocho pasos del pipeline cambiando un fichero.

El tope de Elo, en la configuración

// FontaneríaFetchConfig: el campo max_elo, el nuevo sentido de limit y su validador
src/rukh/data/fetch.py
class FetchConfig(BaseConfig):
"""What to fetch and how to filter it."""
dataset: str = "Lichess/standard-chess-games"
months: list[str] = Field(min_length=1)
min_elo: int = Field(default=1800, ge=0)
max_elo: int | None = Field(default=None, ge=0)
"""Upper bound on both ratings, so a second corpus can cover the band the first one left out.
The project's whole corpus was fetched with ``min_elo: 1800`` and no ceiling, which means the
Elo header tokens below ``<w1800>`` were never trained: conditioning the model on 1500 shows it
a vector that is still at its initialisation. Filling that band needs a fetch that is bounded
above as well, and a bound that is applied to *both* players for the same reason ``min_elo``
is -- a 1200 against a 2400 is not a 1200-rated game, it is a mismatch."""

src/rukh/data/fetch.pylíneas 44-57 · p4

max_elo es int | None y no un entero grande por defecto: «sin techo» no añade condición a la consulta, y «un techo altísimo» añade una que DuckDB evalúa sobre cada fila.

src/rukh/data/fetch.py
min_base_seconds: int = Field(default=180, ge=0)
terminations: list[str] = Field(default=["Normal", "Time forfeit"], min_length=1)
min_plies: int = Field(default=20, ge=0)
exclude_variants: bool = True
out_dir: str = "data/raw"
limit: int | None = Field(default=None, ge=1)
"""Rows to keep per month. It also stops the download: once the parts hold this many games
the remaining shards are never fetched."""
cache_dir: str = "data/hf-cache"
"""Where downloaded shards are parked before being filtered, and deleted from afterwards."""
keep_shards: bool = False
"""Keep the downloaded shards instead of deleting each one once it has been filtered.
72 GB per month, so it is off; useful when the same month is going to be re-filtered."""
duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig)
@field_validator("months")
@classmethod
def _check_months(cls, months: list[str]) -> list[str]:
for month in months:
if not MONTH_RE.match(month):
raise ValueError(f"month {month!r} must look like YYYY-MM")
return months
@model_validator(mode="after")
def _check_elo_band(self) -> FetchConfig:
if self.max_elo is not None and self.max_elo < self.min_elo:
raise ValueError(f"max_elo {self.max_elo} is below min_elo {self.min_elo}")
return self

src/rukh/data/fetch.pylíneas 58-85 · p4

limit ya existía pero cambia de significado: ahora para la descarga, no solo recorta el resultado. Con 505 547 partidas útiles en el primer shard, 1,2 millones salen de tres shards en vez de setenta y dos. keep_shards está apagado porque dejarlos cuesta 72 GB por mes. Y el validador rechaza un techo por debajo del suelo al principio, en vez de dejar que aparezca como un COPY de cero filas.

El filtro se construye una vez

// Fontaneríawhere_clause: un solo predicado para el ensayo y para cada shard
src/rukh/data/fetch.py
def where_clause(cfg: FetchConfig) -> str:
"""The filter itself, one condition per line: the only thing that decides what is kept.
``TRY_CAST`` drops rows whose base time is not an integer (correspondence games export
``TimeControl = '-'``). It is built once and used twice -- in the query the dry run prints
and against every downloaded shard -- so what is documented is what actually runs.
"""
conditions = [
f"WhiteElo >= {cfg.min_elo} AND BlackElo >= {cfg.min_elo}",
f"TRY_CAST(split_part(TimeControl, '+', 1) AS INTEGER) >= {cfg.min_base_seconds}",
"Termination IN (" + ", ".join(_sql_string(t) for t in cfg.terminations) + ")",
]
if cfg.max_elo is not None:
conditions.insert(1, f"WhiteElo <= {cfg.max_elo} AND BlackElo <= {cfg.max_elo}")
if cfg.exclude_variants:
conditions.append("Event NOT ILIKE '%variant%'")
return "\n AND ".join(conditions)

src/rukh/data/fetch.pylíneas 120-136 · p4

La condición WHERE se construye aparte y se usa dos veces: en la consulta que imprime el ensayo y contra cada shard descargado. Con dos copias del predicado, la del ensayo se quedaría atrás en la primera edición y nadie lo notaría, porque las dos seguirían siendo SQL válido.

Un shard a la vez

// FontaneríaLa descarga por shards: bajar, filtrar a una parte, borrar y reanudar
src/rukh/data/fetch.py
def month_shards(cfg: FetchConfig, month: str) -> list[str]:
"""Repository paths of the parquet shards of one month, in order."""
from huggingface_hub import HfApi
year, mm = _split_month(month)
prefix = f"data/year={year}/month={mm}/"
api = HfApi()
files = api.list_repo_files(cfg.dataset, repo_type="dataset")
return sorted(name for name in files if name.startswith(prefix) and name.endswith(".parquet"))
def _download(cfg: FetchConfig, shard: str) -> Path:
"""One shard as a real file on disk, resumed if a previous run was cut off partway.
``local_dir`` and not ``cache_dir``. The Hub cache stores the bytes under ``blobs/`` and hands
back a link into ``snapshots/``, so deleting what this returns frees nothing: the first run
that way left 5.7 GB of shards behind after claiming to have cleaned up. With ``local_dir``
the path *is* the file, and ``unlink`` means what it says.
"""
from huggingface_hub import hf_hub_download
return Path(
hf_hub_download(
cfg.dataset,
shard,
repo_type="dataset",
local_dir=paths.resolve(cfg.cache_dir).as_posix(),
)
)

src/rukh/data/fetch.pylíneas 195-223 · p4

Los import van dentro de las funciones para que un rukh data fetch --dry-run no necesite huggingface_hub. Y local_dir en vez de cache_dir corrige un fallo que costó 5,7 GB: la caché del Hub guarda los bytes en blobs/ y devuelve un enlace, así que borrar lo que devuelve hf_hub_download borraba el enlace y no los bytes. Con local_dir la ruta es el fichero.

src/rukh/data/fetch.py
def _filter_shard(
con: Any, cfg: FetchConfig, month: str, local: Path, target: Path, remaining: int | None
) -> int:
"""Write the rows of ``local`` that pass the filter into ``target``; return how many."""
limit = "" if remaining is None else f"\nLIMIT {remaining}"
con.execute(
f"""
COPY (
SELECT *, {_sql_string(month)} AS month
FROM read_parquet({_sql_string(local.as_posix())}, hive_partitioning = false)
WHERE {where_clause(cfg)}{limit}
) TO {_sql_string(target.as_posix())} (FORMAT PARQUET)
"""
)
row = con.execute(
f"SELECT count(*) FROM read_parquet({_sql_string(target.as_posix())})"
).fetchone()
return int(row[0]) if row else 0

src/rukh/data/fetch.pylíneas 226-243 · p4

La función devuelve las filas contadas del fichero escrito, no las que se pidieron: un COPY puede escribir menos de lo que pediste, y lo único que sabe cuántas escribió es el fichero.

src/rukh/data/fetch.py
def _fetch_month(con: Any, cfg: FetchConfig, month: str, target: Path) -> int:
"""Download, filter and delete the month's shards until ``limit`` is reached.
Parts are kept next to the target until the month is done, so an interrupted month resumes
from the shard it stopped at instead of starting over.
"""
parts_dir = target.parent / "parts"
parts_dir.mkdir(parents=True, exist_ok=True)
shards = month_shards(cfg, month)
if not shards:
raise FileNotFoundError(f"{cfg.dataset} has no parquet shards for {month}")
kept = 0
for index, shard in enumerate(shards):
part = parts_dir / f"part-{index:05d}.parquet"
if part.is_file() and part.stat().st_size:
kept += _count(con, part)
log.info("part %s/%s already filtered (%s games so far)", index + 1, len(shards), kept)
else:
remaining = None if cfg.limit is None else cfg.limit - kept
if remaining is not None and remaining <= 0:
break
local = _download(cfg, shard)
kept += _filter_shard(con, cfg, month, local, part, remaining)
if not cfg.keep_shards:
local.unlink(missing_ok=True)
log.info(
"shard %s/%s of %s filtered: %s games so far", index + 1, len(shards), month, kept
)
if cfg.limit is not None and kept >= cfg.limit:
log.info("%s reached the limit of %s games", month, cfg.limit)
break
parts = sorted(path for path in parts_dir.glob("part-*.parquet") if path.stat().st_size)
# Counted in rows, not in bytes: a parquet with no rows still has a header and a footer, so
# an empty month would sail through a size check and land downstream looking like a corpus.
if not parts or not kept:
raise RuntimeError(f"no games of {month} passed the filter")
files = ", ".join(_sql_string(path.as_posix()) for path in parts)
# `hive_partitioning = false` again: the parts sit under `year=/month=/parts/`, so without it
# DuckDB would read the directory names as columns and collide with the explicit `month`.
con.execute(
f"COPY (SELECT * FROM read_parquet([{files}], hive_partitioning = false, "
f"union_by_name = true)) TO {_sql_string(target.as_posix())} (FORMAT PARQUET)"
)
total = _count(con, target)
shutil.rmtree(parts_dir, ignore_errors=True)
return total

src/rukh/data/fetch.pylíneas 246-291 · p4

Aquí está toda la descarga nueva. Tres decisiones:

  • Las partes se quedan en disco hasta que el mes termina, así que un mes interrumpido reanuda por el shard en el que se quedó. Acumular en memoria dejó, en el intento anterior, 8,6 GB de búfer y cero bytes escritos.
  • El break se comprueba antes de descargar y después de filtrar; sin lo primero, el último shard se bajaría entero para tirarlo.
  • Un mes que no deja pasar nada es un error. Un parquet de cero filas tiene cabecera y pie, así que una comprobación por tamaño lo daría por bueno, y aguas abajo produciría un modelo que no aprende nada.
src/rukh/data/fetch.py
def _is_empty(path: Path) -> bool:
"""Whether the month still has to be fetched: missing, or the stub a failed COPY leaves."""
return not path.is_file() or path.stat().st_size == 0

src/rukh/data/fetch.pylíneas 182-184 · p4

Un fichero de cero bytes, lo que deja un COPY que murió a medias, no cuenta como mes descargado.

src/rukh/data/fetch.py
fetch_plan = plan(cfg)
if dry_run:
return fetch_plan
out_dir = Path(fetch_plan.out_dir)
counts: dict[str, int] = {}
files: list[FileHash] = []
con = connect(cfg.duckdb)
try:
for month, out_path in zip(cfg.months, fetch_plan.out_paths, strict=True):
target = out_dir / out_path
target.parent.mkdir(parents=True, exist_ok=True)
if not (overwrite or _is_empty(target)):
log.info("%s is already there, keeping it (pass overwrite=True to refetch)", target)
counts[month] = _count(con, target)
else:
log.info("fetching %s into %s", month, target)
counts[month] = _fetch_month(con, cfg, month, target)
files.append(
FileHash(path=out_path, sha256=_sha256(target), bytes=target.stat().st_size)
)

src/rukh/data/fetch.pylíneas 314-334 · p4

En el bucle principal, un mes que ya está en disco se cuenta en vez de volver a descargarse. El manifiesto registra lo mismo en las dos ramas (hash y tamaño), así que una descarga en tres tandas produce el mismo manifiesto que una de una sola.

La descarga de verdad y su conversión a UCI son dos comandos. El segundo lee configs/data/pipeline-low.yaml, que se enseña en la sección siguiente: escríbela antes (o tráela con git checkout p4 -- configs/data/pipeline-low.yaml), o deja la conversión para cuando la tengas.

Terminal
uv run rukh data fetch --config configs/data/lichess-low.yaml
uv run rukh data uci --config configs/data/pipeline-low.yaml

Diez minutos la descarga, cinco la conversión. Lo que sale:

Paso Partidas
Partidas descargadas (1000-1799, dos meses) 2 400 000
Convertidas a UCI 2 292 475
Ilegales encontradas 0
Descartadas por cortas (< 20 plies) 107 513
Descartadas por largas (> 300 plies) 12

elo_bins.py: aplanar el eje

Con las dos mitades en disco, el corpus del afinado se construye plano: el mismo número de partidas por banda de 200 Elo, de 1000 a 2600.

src/rukh/data/elo_bins.py
"""Elo-balanced sample: up to ``n_per_bin`` games per band of the average rating.
The first version of this file read one directory of months and binned by hundreds, which is all
the 1800+ corpus could support. It turned out to be the reason "play like 1500" did nothing: the
whole project had been fetched with ``min_elo: 1800``, so ``data/elo-bins`` started at the 1800
bin and the header tokens below it never received a gradient. Conditioning on a weak rating
showed the model a vector that was still at its initialisation.
Filling the axis needs two things this module now has. **Several sources**, because the low band
lives in its own corpus (``data/uci-low``) and must not be mixed into ``data/uci``, which is what
every published model trained on. And a **band width**, because a flat sample over 100-Elo bins
wastes the budget at the crowded end and starves the thin one: the top bins are naturally small
(2 316 games at 2800 against 50 000 at 1800), so the band the sample is balanced over is a
parameter and the manifest publishes what each one really got.
"Flat" is the point. A corpus whose ratings are distributed like Lichess teaches the model that
`<w1500>` is rare and `<w2000>` is normal; a corpus with the same number of games per band
teaches it what each header *means*, which is what conditioning needs.
"""

src/rukh/data/elo_bins.pylíneas 1-19 · p4

src/rukh/data/elo_bins.py
class EloBinsConfig(BaseConfig):
"""Source trees, band width, per-band cap and seed."""
sources: list[str] = ["data/uci"]
"""Every tree of ``year=YYYY/month=MM/games.parquet`` to sample from, in order.
A list and not one directory because the axis is split across corpora: the 1800+ games and
the 1000-1799 ones were fetched separately and live apart on purpose, so that
``data/uci`` keeps meaning exactly what the published models trained on."""
out_dir: str = "data/elo-bins"
bin_width: int = Field(default=100, ge=50)
"""Width of a band in Elo. The sample is flat over *these*, so widening them trades
resolution along the axis for more games in the thin bands at the top."""
min_bin: int | None = None
"""Lowest band kept, inclusive. ``None`` keeps everything the sources have."""
max_bin: int | None = None
"""Highest band kept, inclusive of its whole width."""
n_per_bin: int = Field(default=50_000, ge=1)
seed: int = 42
duckdb: DuckDbConfig = Field(default_factory=DuckDbConfig)
@model_validator(mode="after")
def _check(self) -> EloBinsConfig:
if not self.sources:
raise ValueError("sources must name at least one directory")
if self.min_bin is not None and self.max_bin is not None and self.max_bin < self.min_bin:
raise ValueError(f"max_bin {self.max_bin} is below min_bin {self.min_bin}")
return self

src/rukh/data/elo_bins.pylíneas 36-63 · p4

sources es una lista: las dos mitades del eje viven aparte en disco y se juntan solo aquí, en el paso cuyo trabajo es enseñar qué significa una cabecera. bin_width es 200 en este módulo: con 100, las bandas altas, naturalmente pequeñas, se quedarían sin partidas, y con 400 «1500» y «1800» se mezclarían en un solo token de significado.

src/rukh/data/elo_bins.py
def _bin_expression(bin_width: int) -> str:
return f"((white_elo + black_elo) // 2) // {bin_width} * {bin_width}"
def sample_bins(
sources: list[Path],
target: Path,
n_per_bin: int,
seed: int,
duckdb_cfg: DuckDbConfig | None = None,
bin_width: int = 100,
min_bin: int | None = None,
max_bin: int | None = None,
) -> dict[str, int]:
"""DuckDB: ``bin = avg(white_elo, black_elo) // width * width``; keep ``n_per_bin`` by hash.
The seed shifts the hash, not the id: ``hash(game_id + seed)`` overflowed BIGINT for ids
near the top of the range.
"""
files = ", ".join(f"'{p.as_posix()}'" for p in sources)
where = ["true"]
if min_bin is not None:
where.append(f"bin >= {int(min_bin)}")
if max_bin is not None:
where.append(f"bin <= {int(max_bin)}")
band_filter = " AND ".join(where)
con = connect(duckdb_cfg)
try:
con.execute(
f"""
COPY (
SELECT * EXCLUDE (rk)
FROM (
SELECT *,
row_number() OVER (PARTITION BY bin ORDER BY hash(game_id) + {seed}) AS rk
FROM (
SELECT *, {_bin_expression(bin_width)} AS bin
FROM read_parquet([{files}], hive_partitioning = false, union_by_name = true)
)
WHERE {band_filter}
)
WHERE rk <= {int(n_per_bin)}
ORDER BY bin, rk
) TO '{target.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD)
"""
)
rows = con.execute(
f"SELECT bin, count(*) FROM read_parquet('{target.as_posix()}') "
"GROUP BY bin ORDER BY bin"
).fetchall()
finally:
con.close()
return {str(int(b)): int(n) for b, n in rows}

src/rukh/data/elo_bins.pylíneas 66-118 · p4

El muestreo entero es una consulta, con tres decisiones dentro:

  • La banda trunca, igual que elo_bin en el tokenizador: una media de 1899 cae en la de 1800. Con dos redondeos distintos, el modelo aprendería un eje desplazado media banda y todas las mediciones seguirían pareciendo plausibles.
  • row_number() OVER (PARTITION BY bin ORDER BY hash(game_id) + seed) numera las partidas de cada banda en un orden pseudoaleatorio y se queda con las n_per_bin primeras: determinista y sin cargar la banda en memoria. La semilla desplaza el hash y no el identificador, porque hash(game_id + seed) desbordaba BIGINT, en silencio, con los identificadores más altos.
  • union_by_name = true une por nombre de columna: si los dos corpus salieron de versiones distintas del paso uci, unir por posición pondría el Elo de las negras en la columna del resultado.
src/rukh/data/elo_bins.py
def run(cfg: EloBinsConfig) -> Manifest:
"""Sample every month under each source and write ``out_dir/games.parquet`` + manifest."""
months: list[tuple[str, Path]] = []
for source in cfg.sources:
found = month_dirs(resolve(source))
if not found:
raise FileNotFoundError(f"no games under {resolve(source)}")
months.extend(found)
out_dir = resolve(cfg.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / GAMES_FILE
counts = sample_bins(
[p for _, p in months],
target,
cfg.n_per_bin,
cfg.seed,
cfg.duckdb,
bin_width=cfg.bin_width,
min_bin=cfg.min_bin,
max_bin=cfg.max_bin,
)
manifest = Manifest(
dataset="Lichess/standard-chess-games",
months=sorted({month for month, _ in months}),
filters={
"bin": _bin_expression(cfg.bin_width),
"sources": list(cfg.sources),
"bin_width": cfg.bin_width,
"min_bin": cfg.min_bin,
"max_bin": cfg.max_bin,
"n_per_bin": cfg.n_per_bin,
"seed": cfg.seed,
},
counts=counts,
files=[FileHash(path=GAMES_FILE, sha256=sha256_file(target), bytes=target.stat().st_size)],
)
(out_dir / "manifest.json").write_text(manifest.model_dump_json(indent=2) + "\n", "utf-8")
return manifest

src/rukh/data/elo_bins.pylíneas 121-158 · p4

El conteo del manifiesto sale de leer el fichero escrito, así que puede decir que una banda se quedó corta. Guarda además la expresión del bin, el ancho, los extremos y la semilla: todo lo necesario para reproducir este corpus.

La configuración del corpus plano

La configuración entera se puede saltar; las tres claves que son el experimento se explican debajo del pliegue. Si la saltas y no la trajiste ya para la conversión de arriba, tráela ahora, porque la usan los dos comandos del final de la lección: git checkout p4 -- configs/data/pipeline-low.yaml.

// Fontaneríaconfigs/data/pipeline-low.yaml entero
configs/data/pipeline-low.yaml
# The P4 corpus: the 1000-1799 band that `pipeline-v4.yaml` never had, converted and balanced.
#
# Only the steps P4 needs are configured. `uci` reads `data/raw-low` into its own `data/uci-low`
# so `data/uci` keeps meaning "what the published models trained on"; `elo_bins` is the only step
# that joins the two, and it is the step whose whole job is to teach the Elo header.
duckdb:
memory_limit: 32GB
threads: 0
uci:
raw_dir: data/raw-low
out_dir: data/uci-low
min_plies: 20
max_plies: 300
workers: 0
elo_bins:
# Both halves of the axis, in bands of 200 Elo. 200 is a compromise: 100 would put the four
# club bands at the same resolution as the master ones and then starve them of games, and 400
# would blur "1500" and "1800" into one token's worth of meaning.
sources: [data/uci-low, data/uci]
out_dir: data/elo-bins-v2
bin_width: 200
min_bin: 1000
max_bin: 2600
# Flat by construction. The real corpus is a pyramid and that is exactly what teaches the model
# that `<w1500>` is rare rather than what it means; the manifest publishes what each band got,
# because the top ones cannot fill their quota and the lesson says so.
n_per_bin: 150000
seed: 42
tokenize:
uci_dir: data/uci
out_dir: data/tokens-elo
artifacts_dir: artifacts/tokenizer
web_artifacts_dir: artifacts/web
fixture_pgn: tests/fixtures/games.pgn
# No months: the training split is the balanced sample and nothing else, so the fine-tune sees
# the flat distribution and not the pyramid it came from.
train_months: []
extra_train_parquets: ['data/elo-bins-v2/games.parquet']
val_month: '2025-02'
val_games: 100000
# The balanced sample is the whole training split: the rest of the validation month must not
# join it, or 2.85 M games of 1800+ play would flatten the very shape this corpus exists for.
val_remainder_trains: false
stats_games: data/uci/year=2025/month=01/games.parquet
bpe_vocab_size: 4096
bpe_train_games: 200000
stats_n_games: 20000
max_len: 200

configs/data/pipeline-low.yamllíneas 1-48 · p4

En la sección tokenize, tres claves son el experimento. train_months: [] deja entrar solo la muestra equilibrada (extra_train_parquets). val_month y val_games son los de todas las demás ejecuciones, para que las pérdidas se puedan comparar. Y val_remainder_trains: false tiene su propia sección más abajo.

Los otros dos ficheros de datos del módulo son variaciones del mismo patrón; se pueden saltar. Si lo haces, tráelos en vez de teclearlos (pipeline-masters.yaml lo usa el Lab 3 para el corpus de maestros): git checkout p4 -- configs/data/pipeline-masters.yaml configs/data/lichess-more.yaml.

// FontaneríaLas configuraciones del corpus de maestros y del mes de más

El otro corpus que usa este módulo es el de maestros, el del segundo afinado:

configs/data/pipeline-masters.yaml
# Token pack of the Elite Database alone, for the masters fine-tune of M4.
#
# The elite games are already part of `tokens-v4`, mixed in with the 1800+ corpus; this pack has
# nothing else in it. That is the whole experiment: `docs/spec/02` predicts that a last fine-tune
# on masters raises the Elo and lowers the diversity, and neither half of that sentence can be
# read off a corpus where masters are one ingredient among several.
#
# Same validation split as every other run -- the first 100 000 games of 2025-02 -- and its
# remainder stays out of training, so what the model is fine-tuned on is masters and only masters.
tokenize:
uci_dir: data/uci
out_dir: data/tokens-masters
train_months: []
extra_train_parquets: ['data/elite/games.parquet']
val_month: '2025-02'
val_games: 100000
val_remainder_trains: false
stats_games: data/uci/year=2025/month=01/games.parquet
max_len: 200

configs/data/pipeline-masters.yamllíneas 1-19 · p4

Y otro fichero de fetch, el del mes de más que se descargó para ampliar el corpus de 1800+:

configs/data/lichess-more.yaml
# Lever 2: months beyond the two P1 fetched. Same filters, a higher cap -- the 3,000,000 of
# `lichess-2025-01-02.yaml` was our own ceiling, not the dataset's (7,136,017,970 games).
# `threads` is held below cpu_count() on purpose: this runs while the GPU trains and the
# dataloader needs cores.
dataset: Lichess/standard-chess-games
months: ["2025-03"]
min_elo: 1800
min_base_seconds: 180
terminations: ["Normal", "Time forfeit"]
min_plies: 20
exclude_variants: true
out_dir: data/raw
limit: 6000000
duckdb:
memory_limit: 16GB
threads: 3 # 8 tripped HTTP 429 three times running; fewer parallel range requests

configs/data/lichess-more.yamllíneas 1-16 · p4

threads: 3 va por debajo del número de núcleos porque esta descarga corre mientras la GPU entrena, y el dataloader necesita núcleos; ocho hilos provocaron tres 429 seguidos. El comentario al lado es lo que evita que ese 3 sea un número mágico.

pack.py: rebanadas de partidas

El empaquetador leía meses enteros, y un mes era o entrenamiento o validación. La solución es un tipo de tres campos: pack.py aprende a leer rebanadas de un parquet en vez de meses enteros. La mecánica está plegada y se puede saltar; lo que importa para el experimento es la regla del bloque que sigue al pliegue. El «Ejecútalo» de la sección prueba las dos cosas, así que si saltas los pliegues, trae los dos ficheros que tocan y lee el bloque visible sin teclearlo: git checkout p4 -- src/rukh/tokenize/pack.py src/rukh/data/pipeline.py.

// Fontaneríapack.py: la rebanada (parquet, skip, take) y cómo se empaqueta una lista de ellas
src/rukh/tokenize/pack.py
@dataclass(frozen=True)
class GameSlice:
"""A contiguous run of games inside one UCI parquet.
``skip`` games are dropped from the front and at most ``take`` are kept (``None`` = to the
end). It exists so one month can feed both splits: the validation slice is the first
``val_games`` rows and training gets the remainder, instead of a whole month sitting idle
as validation when validation only ever reads a few hundred thousand tokens per eval.
"""
path: Path
skip: int = 0
take: int | None = None
def label(self) -> str:
"""``month=02/games.parquet[100000:]``: what ``meta.json`` records as the source."""
name = f"{self.path.parent.name}/{self.path.name}"
if not self.skip and self.take is None:
return name
end = "" if self.take is None else str(self.skip + self.take)
return f"{name}[{self.skip}:{end}]"

src/rukh/tokenize/pack.pylíneas 162-182 · p4

Una rebanada es un parquet más un skip y un take, congelada porque se construye y no cambia. label() hace que meta.json registre como origen month=02/games.parquet[100000:], que dice qué partidas entraron; games.parquet a secas describiría igual de bien dos packs distintos.

src/rukh/tokenize/pack.py
def pack_month(games: Path, encoder: GameEncoder, out: Path) -> PackInfo:
"""Encode every game of ``games`` (a UCI parquet) into ``out/tokens.npy`` + ``starts.npy``."""
return pack_games([GameSlice(Path(games))], encoder, out)
def pack_games(sources: Sequence[GameSlice], encoder: GameEncoder, out: Path) -> PackInfo:
"""Encode every game of ``sources``, in order, into ``out/tokens.npy`` + ``starts.npy``.
Each parquet is read row group by row group and the token stream is appended to a raw file;
once its length is known the raw file is copied into the ``.npy`` memmap in 64 MiB chunks,
so neither the games nor the whole stream are ever held in memory at once. Several sources
concatenate into a single stream, which is why ``starts.npy`` is written last: its offsets
are absolute over the concatenation.
"""
if encoder.vocab_size() > np.iinfo(np.uint16).max + 1:
raise ValueError("vocabulary does not fit in uint16")
if not sources:
raise ValueError("pack_games needs at least one source")

src/rukh/tokenize/pack.pylíneas 185-202 · p4

pack_month se queda como una línea sobre pack_games, y así los tests y las llamadas de M1 y M2 siguen en verde sin reescribirlos. Varias fuentes se concatenan en un solo flujo, y por eso starts.npy se escribe al final, con desplazamientos absolutos sobre la concatenación.

src/rukh/tokenize/pack.py
with raw_path.open("wb") as raw:
for source in sources:
reader = pq.ParquetFile(source.path)
seen = 0
taken = 0
for batch in reader.iter_batches(
batch_size=CHUNK_GAMES, columns=["uci", "white_elo", "black_elo", "result"]
):
rows_here = batch.num_rows
lo = min(max(source.skip - seen, 0), rows_here)
hi = rows_here
if source.take is not None:
hi = min(hi, lo + source.take - taken)
seen += rows_here
if hi <= lo:
if source.take is not None and taken >= source.take:
break
continue
batch = batch.slice(lo, hi - lo)
taken += hi - lo
buffer: list[int] = []
rows = zip(
batch.column("uci").to_pylist(),
batch.column("white_elo").to_pylist(),
batch.column("black_elo").to_pylist(),
batch.column("result").to_pylist(),
strict=True,
)
for uci, w_elo, b_elo, result in rows:
ids = encoder.encode_game(uci, int(w_elo), int(b_elo), result, max_len=1 << 30)
starts.append(n_tokens)
n_tokens += len(ids)
buffer.extend(ids)
raw.write(np.asarray(buffer, dtype=np.uint16).tobytes())
if source.take is not None and taken >= source.take:
break

src/rukh/tokenize/pack.pylíneas 208-243 · p4

La aritmética de lo y hi es lo único intrincado del fichero. seen son las filas que ya han pasado por delante, taken las que ya se han quedado. Para un lote de 10 000 filas con skip = 25 000 y seen = 20 000, lo = min(max(5000, 0), 10000) = 5000: se tiran las cinco mil primeras del lote y se cogen las otras cinco mil. El continue cubre el caso que se olvida: un lote entero por delante del skip no aporta nada, pero la fuente no ha terminado.

batch.slice no copia: recorta la vista antes de convertir las columnas a listas de Python, que es donde está el coste de verdad.

Qué rebanadas van a cada split lo decide la sección tokenize: de la configuración, y TokenizeConfig, en src/rukh/data/pipeline.py, gana para eso cuatro claves: train_months en plural, val_games, extra_train_parquets y val_remainder_trains.

src/rukh/data/pipeline.py
train_months: list[str] = Field(default_factory=lambda: ["2025-01"])
val_month: str = "2025-02"
val_games: int = Field(default=0, ge=0)
extra_train_parquets: list[str] = Field(default_factory=list)
"""UCI parquets appended to the training split, outside the ``year=/month=`` layout."""
val_remainder_trains: bool = True
"""Whether the part of ``val_month`` that is not held out joins the training split.
Off for a corpus whose shape is the point: the Elo-balanced sample of M4 has the same number
of games per rating band on purpose, and 2.85 M games of rated-1800+ play poured on top would
undo it."""
"""Games of ``val_month`` held out; the rest of it trains. 0 = the whole month validates."""

src/rukh/data/pipeline.pylíneas 32-43 · p4

Fíjate en la última línea del bloque. El docstring de val_games quedó después del de val_remainder_trains, lejos de su campo, y pydantic no lo recoge: val_games se queda sin descripción en el esquema. Es un error real del repositorio en el tag p4. Un docstring de atributo es una convención y no sintaxis, así que moverlo una línea lo anula sin que ninguna herramienta lo note.

src/rukh/tokenize/pack.py
def split_sources(cfg: TokenizeConfig) -> dict[str, list[GameSlice]]:
"""The parquet slices behind each split, as ``pack_scheme`` will read them.
``train_months`` are taken whole. ``val_month`` is cut in two: its first ``val_games`` games
are the validation set and, unless ``val_remainder_trains`` is off, everything after them
joins training. With ``val_games = 0`` the whole month is validation, which is what P1 and P2
did -- and what left 238 M tokens unused, since an eval reads 50 batches and not a month
(D-062).
``val_remainder_trains`` exists for a corpus whose *shape* is the point. The Elo-balanced
sample of M4 has the same number of games per rating band on purpose, because that is what
teaches the model what a header means rather than how common it is; pouring 2.85 M rated-1800+
games of the validation month on top would quietly undo exactly that. Turning it off keeps the
validation set identical to the one every other run is measured against -- the same first
``val_games`` of the same month -- while leaving the training split as it was configured.
``extra_train_parquets`` appends UCI parquets that live outside the ``year=/month=`` layout,
which is how the Elite Database (2200+ only) joins the corpus without pretending to be a
Lichess month.
"""
uci_dir = resolve(cfg.uci_dir)
train = [GameSlice(month_parquet(uci_dir, month)) for month in cfg.train_months]
val_parquet = month_parquet(uci_dir, cfg.val_month)
if cfg.val_games:
val = [GameSlice(val_parquet, take=cfg.val_games)]
if cfg.val_remainder_trains:
train.append(GameSlice(val_parquet, skip=cfg.val_games))
else:
val = [GameSlice(val_parquet)]
train += [GameSlice(resolve(extra)) for extra in cfg.extra_train_parquets]
return {"train": train, "val": val}

src/rukh/tokenize/pack.pylíneas 285-315 · p4

Aquí está la clave por la que casi se estropea el experimento. El empaquetador tenía una regla razonable: el mes de validación se parte en dos, sus primeras val_games partidas validan y el resto se suma al entrenamiento. En el preentrenamiento eso recuperó 238 millones de tokens.

Sobre un corpus cuya forma es el experimento, esa regla añade 2,85 millones de partidas de 1800+ encima del reparto plano y lo deshace sin decir nada. val_remainder_trains: false mantiene la validación de siempre y deja el entrenamiento en el corpus equilibrado y nada más. Una opción por defecto que mejora el caso normal tiene que poder apagarse.

// FontaneríaLa función que decide qué rebanadas van a cada split
src/rukh/tokenize/pack.py
def pack_scheme(cfg: TokenizeConfig, scheme: str, bpe: Tokenizer | None = None) -> list[Path]:
"""Pack the ``train`` and ``val`` splits under ``out_dir/<scheme>/{train,val}``."""
encoder = make_encoder(scheme, bpe)
written: list[Path] = []
for split, sources in split_sources(cfg).items():
out = resolve(cfg.out_dir) / scheme / split
pack_games(sources, encoder, out)
written.append(out / META_FILE)
return written

src/rukh/tokenize/pack.pylíneas 318-326 · p4

Separar la función que decide qué se empaqueta de la que lo empaqueta permite probar la primera sin escribir un solo .npy.

loader.py: el pickle que no cabía

El corpus de M2 dejó un fallo que aparece cuando el número de partidas crece, y la corrección vive en loader.py. Es un arreglo de multiprocessing en Windows, no de afinado, y se puede saltar en la primera lectura; el «Ejecútalo» de después lo prueba, así que si lo saltas, tráelo: git checkout p4 -- src/rukh/tokenize/loader.py.

// Fontaneríaloader.py: el dataset que se serializa sin sus arrays
src/rukh/tokenize/loader.py
def __init__(self, directory: Path, block: int = 200, start_at_game: bool = True) -> None:
if block < 2:
raise ValueError("block must be at least 2")
self.directory = Path(directory)
self.block = block
self.start_at_game = start_at_game
self.info: PackInfo = read_pack_info(self.directory)
self.pad_id = 0
self._open()
def _open(self) -> None:
"""Map the two arrays from disk. Called on construction and in every worker process."""
self.tokens = np.load(self.directory / TOKENS_FILE, mmap_mode="r")
self.starts = np.load(self.directory / STARTS_FILE, mmap_mode="r")
def __getstate__(self) -> dict[str, object]:
"""Ship the paths to a worker, never the arrays.
``DataLoader`` workers are spawned on Windows, so the dataset is pickled once per worker.
``starts`` is one int64 per game: 95 MB at 12.5 M games, 145 MB at 18.9 M, and at 145 MB
the spawn died with ``UnpicklingError: pickle data was truncated`` before a single step
ran. Both arrays live in files the worker can map itself (D-067).
"""
return {k: v for k, v in self.__dict__.items() if k not in ("tokens", "starts")}
def __setstate__(self, state: dict[str, object]) -> None:
self.__dict__.update(state)
self._open()

src/rukh/tokenize/loader.pylíneas 24-51 · p4

En Windows, los workers del DataLoader se lanzan con spawn, y el dataset se serializa una vez por worker. starts es un int64 por partida, y a 145 MB (18,9 millones de partidas) el arranque murió con UnpicklingError: pickle data was truncated antes de dar un paso. La corrección: __getstate__ manda todo menos los dos arrays y __setstate__ los vuelve a mapear desde disco, y starts también se abre con mmap_mode="r".

En M2 se mapeó tokens, el array grande, que era el que preocupaba; el que rompió fue starts, el índice pequeño, que crece con el número de partidas igual que el grande.

Empezar desde unos pesos, sin heredar nada más

Afinar necesita empezar desde un checkpoint, y el bucle ya sabía hacerlo con --resume. Pero --resume hace lo contrario de lo que quiere un afinado.

src/rukh/train/loop.py
init_from: str | None = None
"""Checkpoint to start the weights from, for a fine-tune.
Deliberately not ``--resume``, which is for a run that was interrupted: resuming restores the
optimizer moments, the step counter, the RNG and the MLflow run so the curve carries on as if
nothing had stopped. A fine-tune wants the opposite -- the weights and nothing else -- because
it is a **new** run, with its own data, its own learning rate and its own schedule, and the
Adam moments of a 48 000-step cosine that has already decayed would fight the warmup of the
one that starts now. Asking for both at once is a contradiction and is refused.
It lives here and not on ``RunConfig`` because the encoder's runs already have their own way
in (``HeadsConfig.encoder_ckpt``), and a field that three configs accept but only one honours
is a silent no-op waiting to be written into a YAML."""

src/rukh/train/loop.pylíneas 94-106 · p4

--resume restaura los momentos de Adam, el contador de pasos, el generador aleatorio y la ejecución de MLflow. Un afinado es una ejecución nueva y quiere solo los pesos: los momentos de un coseno de 48 000 pasos ya decaído pelearían contra el warmup que empieza ahora. --resume es volver del café: la mesa está como la dejaste. init_from es empezar en otra empresa: te llevas lo que sabes y nada más.

src/rukh/train/common.py
def load_init_weights(init_from: str | None, model: nn.Module, where: torch.device) -> str | None:
"""Load ``init_from`` into ``model`` and return the resolved path, or None when there is none.
Only ``model_state`` is read. The optimizer, the step counter and the MLflow run of the
checkpoint are ignored on purpose: this is where a fine-tune starts, not where a run left off.
"""
if init_from is None:
return None
path = paths.resolve(init_from)
if not path.is_file():
raise FileNotFoundError(f"init_from checkpoint not found: {path}")
payload = load_checkpoint(path, map_location=where)
load_state(model, payload["model_state"])
log.info("initialised from %s (step %s), fresh optimizer", path, payload.get("step", "?"))
return path.as_posix()

src/rukh/train/common.pylíneas 203-217 · p4

Del checkpoint se lee model_state y nada más. El log.info importa: un afinado que arranca desde el checkpoint equivocado produce números plausibles, y esa línea permite descubrirlo al leer el log en vez de al comparar dos Elos que no cuadran.

src/rukh/train/loop.py
model = MoveDecoder(model_cfg).to(where)
if resume is not None and cfg.init_from is not None:
raise ValueError("--resume continues a run; init_from starts a new one: pick one")
# The weights are loaded *before* the adapters go on, because the checkpoint knows the module
# names of a plain decoder and wrapping renames them.
initialised_from = load_init_weights(cfg.init_from, model, where)

src/rukh/train/loop.pylíneas 177-182 · p4

Pedir --resume e init_from a la vez se rechaza en vez de elegir uno en silencio. Y los pesos se cargan antes de poner los adaptadores de LoRA, porque envolver renombra los módulos; ese orden no se deduce de ninguna de las dos funciones por separado, y por eso lleva comentario.

src/rukh/train/loop.py
def _warn_if_best_is_not_the_result(
cfg: TrainConfig, out_dir: Path, final: Path, best_val: float
) -> None:
"""Say out loud when ``best.pt`` is not this run's answer, because usually it is.
``best.pt`` means "lowest validation loss", and validation is imitation of 1800+ play. For a
pretraining run that is the objective and the two coincide. For a fine-tune whose whole point
is to shift the corpus somewhere else -- the Elo-balanced sample, masters only, one opening --
the validation loss is *expected* to rise, so ``best.pt`` freezes an almost-untouched model a
few hundred steps in. Anyone evaluating it later would be measuring the wrong weights, and the
numbers would look plausible, which is the dangerous kind of wrong.
"""
best = out_dir / BEST_NAME
if cfg.init_from is None or not best.is_file():
return
from rukh.train.checkpoint import load_checkpoint
best_step = int(load_checkpoint(best).get("step", 0))
if best_step >= cfg.max_steps:
return
log.warning(
"%s holds step %d (val/loss %.4f), not the end of this fine-tune. The result of this run "
"is %s; validation measures imitation of the old corpus, which is not what it optimised.",
best,
best_step,
best_val,
final,
)

src/rukh/train/loop.pylíneas 305-332 · p4

best.pt significa «la pérdida de validación más baja», y la validación mide imitación de juego de 1800+. En un preentrenamiento coincide con el objetivo. En un afinado que cambia de corpus a propósito, la pérdida sube desde el principio y best.pt se queda en el paso 200, un modelo casi sin tocar. La función no arregla nada, lo dice: avisa cuando hay un init_from y el paso de best.pt está por debajo de max_steps.

// Ejercicio 01¿Por qué best.pt es el paso 200, y no el 0 ni el 3 800?

Mira configs/train/medium-elo.yaml más abajo (eval_every, warmup, max_steps) y la rama del bucle que guarda best.pt en src/rukh/train/loop.py. Antes de leer la solución, escribe en qué paso se quedaría best.pt si eval_every fuera 50, y qué tendría que pasar con la pérdida de validación para que se quedara en el último paso.

// SoluciónVer la solución

Tres hechos, y ninguno es del modelo. El bucle no evalúa en el paso 0: best_val empieza en infinito y la primera evaluación llega en el paso 200. Esa primera evaluación siempre mejora a infinito, así que best.pt se escribe en el 200 pase lo que pase. Y como el 49 % del corpus del afinado está por debajo de 1800, la pérdida de validación sube desde ahí y ninguna evaluación posterior bate a la del 200. Con eval_every: 50 se quedaría en el paso 50, todavía dentro del warmup. Para que fuera el 3 800, la pérdida tendría que bajar durante el afinado, es decir, el corpus nuevo tendría que parecerse más a la validación que el viejo: lo contrario de lo que este afinado busca.

Las dos configuraciones de afinado

configs/train/medium-elo.yaml
# `medium-v4` fine-tuned on the Elo-balanced corpus: the run that is supposed to make the dial real.
#
# The base is `medium-v4` and not `medium-v4 + DPO`. DPO taught the model to prefer the engine's
# move over the human one; pouring 1.2 M games of imitation on top would partly undo that, and the
# question this run answers is about the Elo header and nothing else. Whether the two compose is a
# P5 question, asked after this one has an answer.
#
# `data/tokens-elo` is flat from `<w1000>` to `<w2400>` by construction (about 70-85 k games per
# bin against the 20-to-1 pyramid a real corpus has). That flatness is the whole intervention: a
# corpus shaped like Lichess teaches the model that `<w1500>` is *rare*, which is not the same
# thing as what it means.
#
# 3 800 steps is 194.6 M tokens over a 95.7 M-token corpus: about two passes. Deliberately short
# and at the spec's fine-tuning rate (1e-4, sixteen times below pretraining), because the risk
# here is not underfitting the new corpus -- it is forgetting the strong play that 1.68 B tokens
# bought. The measurement that decides whether that happened is the `<w2400>` row of the sweep
# against `medium-v4`'s own 1504.
preset: medium
init_from: checkpoints/medium-v4-20260919-174623/best.pt
tokens_dir: data/tokens-elo/uci
block: 200
batch_size: 32
grad_accum: 8 # 256 effective sequences of 200 tokens
lr: 1.0e-4
min_lr_ratio: 0.1
warmup: 200
max_steps: 3800
weight_decay: 0.1
betas: [0.9, 0.95]
grad_clip: 1.0
precision: bf16
compile: true
eval_every: 200
eval_batches: 50
ckpt_every: 500
out_dir: checkpoints
seed: 42
run_name: medium-elo
unique_run_name: true
workers: 4
log_every: 10

configs/train/medium-elo.yamllíneas 1-41 · p4

Los comentarios contestan lo que te preguntarías al leer el YAML. El primero nombra DPO (Direct Preference OptimizationDPODirect Preference Optimization: entrena la política directamente sobre los pares, sin reward model y sin bucle de refuerzo. La referencia es implícita —una copia congelada de los pesos de partida— y β mide cuánto se le deja alejarse de ella. Con una jugada por respuesta, la pérdida se reduce a comparar dos log-probabilidades en una sola posición.): un afinado sobre pares de jugadas «esta sí, esta no» que empuja al modelo hacia la del motor. El curso lo construye en M5; aquí basta saber que la base de este afinado es el modelo de M2 sin esa etapa. Y hay dos cifras que conviene leer bien. Los «70-85 k games per bin» son por token de cabecera, que va de 100 en 100 Elo: cada banda de 200 de la tabla de más abajo junta dos tokens, y por eso allí salen 150 000 por banda. Y donde el comentario dice «sixteen times below pretraining», la cuenta es otra: medium-v4 se preentrenó a 4,5e-4, así que 1e-4 es cuatro veces y media menos, como en la tabla de la lección anterior. El riesgo de 3 800 pasos a 1e-4 no es quedarse corto con el corpus nuevo: es olvidar el juego fuerte que costaron 1 680 millones de tokens. La segunda configuración es la misma con el corpus cambiado:

configs/train/medium-masters.yaml
# `medium-v4` fine-tuned on masters only: the trade `docs/spec/02` predicts, measured.
#
# "Seguir preentrenando con rukh-games-elite (lr 1e-4). Medir: sube el Elo, baja la diversidad."
# Both halves need a number. The Elo comes from the usual suite; the diversity is the opening
# entropy `rukh.eval.diversity` measures, which was `n/a` in every report before this milestone
# precisely because nothing had needed it yet.
#
# The token budget is the same as `medium-elo`'s (3 800 steps of 51 200 tokens, 194.6 M) so the
# two fine-tunes differ in their corpus and in nothing else. Against the 1 210 419 259 tokens of
# the elite pack (13 146 352 games) that is 0.16 of an epoch: a fine-tune, not a second
# pretraining, and every sample it sees is new.
preset: medium
init_from: checkpoints/medium-v4-20260919-174623/best.pt
tokens_dir: data/tokens-masters/uci
block: 200
batch_size: 32
grad_accum: 8
lr: 1.0e-4
min_lr_ratio: 0.1
warmup: 200
max_steps: 3800
weight_decay: 0.1
betas: [0.9, 0.95]
grad_clip: 1.0
precision: bf16
compile: true
eval_every: 200
eval_batches: 50
ckpt_every: 500
out_dir: checkpoints
seed: 42
run_name: medium-masters
unique_run_name: true
workers: 4
log_every: 10

configs/train/medium-masters.yamllíneas 1-35 · p4

Del block en adelante es idéntica a medium-elo.yaml salvo el run_name, así que los dos afinados se diferencian en su corpus y en nada más. Sobre el pack de élite, los 194,6 M tokens son 0,16 de una épocaÉpoca (epoch)Una pasada completa por el conjunto de entrenamiento; en inglés, epoch. Sirve para contar cuántas veces se le ha enseñado el mismo dato. Con corpus grandes Rukh no llega ni a una —el afinado por maestros son 194,6 M de tokens sobre el pack de élite, 0,16 de una época, así que cada muestra que ve es nueva—, y en ese régimen la unidad útil es el paso, no la época.: cada muestra que ve es nueva.

Por qué el corpus de afinado es plano y el mundo no

Un corpus con la forma real de Lichess le enseña al modelo que <w1500> es raro, y lo que aquí interesa es qué significa. Un diccionario le dedica una entrada a «casa» y otra a «abstruso» aunque la primera se use mil veces más, porque se le pide qué significa cada palabra, no cuántas veces sale. El corpus plano es ese diccionario. Por lo mismo, los conjuntos de SFTSFT (supervised fine-tuning)Seguir entrenando un modelo ya preentrenado con el mismo objetivo —predecir el siguiente token— pero sobre un corpus elegido. No cambia la pérdida ni la arquitectura: cambia de qué distribución salen los ejemplos, y eso es todo lo que hace falta para mover el comportamiento. En Rukh son los dos afinados del hito: medium-masters sobre partidas de élite y medium-elo sobre un corpus plano en Elo. de un LLM se equilibran por tarea: si el 90 % de los ejemplos son de un tipo, el modelo aprende sobre todo que ese tipo es el frecuente.

Banda Partidas
1000 150 000
1200 150 000
1400 150 000
1600 150 000
1800 150 000
2000 150 000
2200 150 000
2400 146 232
2600 23 191

Las dos últimas filas son el límite de los datos: en dos meses de Lichess hay 23 191 partidas de 2600+, y ninguna manera honesta de fabricar las que faltan. El corpus queda plano donde puede, y el criterio del hito (1500, 2000 y 2400) vive entero dentro de la parte plana. Total: 1 219 423 partidas, 95 722 318 tokens.

Por abajo, la descarga empieza en 1000, así que <w0600> a <w0900> siguen sin un solo gradiente. Siguen siendo el botón del piso que no existe, y por eso ni el barrido de la lección 7 ni el selector de la demo las ofrecen.

Contado sobre el flujo empaquetado, token a token, así queda la cabecera que el modelo lee:

Token Partidas Token Partidas
<w1000> 59 335 <w1900> 79 730
<w1100> 87 662 <w2000> 85 267
<w1200> 71 956 <w2100> 68 393
<w1300> 78 537 <w2200> 85 367
<w1400> 71 209 <w2300> 68 169
<w1500> 79 820 <w2400> 81 258
<w1600> 81 664 <w2500> 47 622
<w1700> 69 817 <w2600> 19 073
<w1800> 76 633 <w2700>+ 7 911

Qué costó el afinado

medium-v4 afinado 3 800 pasos sobre ese corpus son 194,56 M de tokens, unas dos pasadas. La validación mide imitación de juego 1800+, lo que este afinado deja de optimizar, así que se espera que empeore. Empeoró, y poco:

pérdida de validación top-1 de validación
medium-v4 (paso 48 000) 1,3733 54,73 %
medium-elo (paso 3 800) 1,3987 54,66 %

Veinticinco milésimas de nat y siete centésimas de punto de top-1: es el precio del eje. Lo que compró se mide en la lección 7 y se lee en la 9.

Terminal
uv run rukh train --config configs/train/medium-elo.yaml
uv run rukh data tokenize --config configs/data/pipeline-masters.yaml --scheme uci --pack
uv run rukh train --config configs/train/medium-masters.yaml

El afinado son 3 800 pasos y 19,5 minutos. El de maestros empaqueta antes la Elite Database sola en data/tokens-masters/uci/ (necesita los 44 meses de data/elite/games.parquet) y tarda 20,7.

Qué has aprendido

Un token que nunca aparece en el entrenamiento no existe, aunque tenga fila en la tabla de embeddings, y se descubre contando, no mirando el modelo. La reparación: un max_elo sobre los dos jugadores, un descargador que baja cada shard entero, un muestreo por hash que aplana nueve bandas y publica lo que cada una recibió, un empaquetador que no deja que la validación le robe la forma al corpus, y un init_from que se lleva los pesos y deja el optimizador. El eje costó 1,3733 → 1,3987 de pérdida; lo que compró se mide después.

Lo siguiente es la otra forma de afinar: en vez de mover los 115 millones de parámetros, mover 393 216. Sigue en «LoRA escrita a mano».