// 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.
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:
# Two recent months of rated standard games, filtered remotely (see docs/spec/01-datos.md).dataset: Lichess/standard-chess-gamesmonths: ["2025-01", "2025-02"]min_elo: 1800min_base_seconds: 180terminations: ["Normal", "Time forfeit"]min_plies: 20exclude_variants: trueout_dir: data/rawlimit: 3000000configs/data/lichess-2025-01-02.yaml
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.
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:
# 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-gamesmonths: ['2025-01', '2025-02']min_elo: 1000max_elo: 1799min_base_seconds: 180terminations: ['Normal', 'Time forfeit']min_plies: 20exclude_variants: trueout_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: 1200000cache_dir: data/hf-cacheCambian 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://
The months are read **shard by shard, downloaded first**, not streamed over ``hf://``. Streamingwas the original design and it is the obvious one: DuckDB reads the remote parquet directly andonly the rows that pass the filter ever land on disk. It stopped working. A month is 72 files ofabout a gigabyte, and reading them remotely means thousands of small range requests against onehost, which earns an ``HTTP 429`` partway through and then a retry schedule that doubles untilthe throughput is measured in kilobytes. Measured on 2026-09-20: streaming stalled at 0.02 MB/safter 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 offproperly), filtered locally into a part file and deleted again. Peak disk is one shard, the workresumes where it stopped, and ``limit`` now stops the download early instead of only trimming theresult -- for a slice of the rating range that is the difference between 8 shards and 72.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://
Finally, it signs the requests to ``hf://``. Reading a month of Lichess means a few hundred rangerequests against the same host, and anonymous ones get ``HTTP 429 Too Many Requests`` partwaythrough -- which arrives as a failed ``COPY`` after minutes of work, with no partial file toresume from. A token raises the allowance and identifies the traffic, and the retry budget israised from three attempts to eight because a 429 is a "wait", not a "no".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.
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."""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.
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 TrueEl 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.
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 conUna 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
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."""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.
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 selflimit 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
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)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
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(), ) )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.
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 0La 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.
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 totalAquí 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
breakse 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.
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 == 0Un fichero de cero bytes, lo que deja un COPY que murió a medias, no cuenta como mes descargado.
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) )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.
uv run rukh data fetch --config configs/data/lichess-low.yamluv run rukh data uci --config configs/data/pipeline-low.yamlDiez 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.
"""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 allthe 1800+ corpus could support. It turned out to be the reason "play like 1500" did nothing: thewhole project had been fetched with ``min_elo: 1800``, so ``data/elo-bins`` started at the 1800bin and the header tokens below it never received a gradient. Conditioning on a weak ratingshowed 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 bandlives in its own corpus (``data/uci-low``) and must not be mixed into ``data/uci``, which is whatevery published model trained on. And a **band width**, because a flat sample over 100-Elo binswastes 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 aparameter 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 bandteaches it what each header *means*, which is what conditioning needs."""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 selfsources 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.
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}El muestreo entero es una consulta, con tres decisiones dentro:
- La banda trunca, igual que
elo_binen 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 lasn_per_binprimeras: determinista y sin cargar la banda en memoria. La semilla desplaza el hash y no el identificador, porquehash(game_id + seed)desbordabaBIGINT, en silencio, con los identificadores más altos.union_by_name = trueune por nombre de columna: si los dos corpus salieron de versiones distintas del pasouci, unir por posición pondría el Elo de las negras en la columna del resultado.
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 manifestEl 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
# 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: 0uci: raw_dir: data/raw-low out_dir: data/uci-low min_plies: 20 max_plies: 300 workers: 0elo_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: 42tokenize: 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: 200En 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:
# 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: 200configs/data/pipeline-masters.yaml
Y otro fichero de fetch, el del mes de más que se descargó para ampliar el corpus de 1800+:
# 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-gamesmonths: ["2025-03"]min_elo: 1800min_base_seconds: 180terminations: ["Normal", "Time forfeit"]min_plies: 20exclude_variants: trueout_dir: data/rawlimit: 6000000duckdb: memory_limit: 16GB threads: 3 # 8 tripped HTTP 429 three times running; fewer parallel range requestsconfigs/data/lichess-more.yaml
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
@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}]"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.
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")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.
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: breakLa 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.
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."""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.
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}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
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 writtenSeparar 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
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()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.
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."""--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.
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()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.
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)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.
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, )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
# `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: mediuminit_from: checkpoints/medium-v4-20260919-174623/best.pttokens_dir: data/tokens-elo/uciblock: 200batch_size: 32grad_accum: 8 # 256 effective sequences of 200 tokenslr: 1.0e-4min_lr_ratio: 0.1warmup: 200max_steps: 3800weight_decay: 0.1betas: [0.9, 0.95]grad_clip: 1.0precision: bf16compile: trueeval_every: 200eval_batches: 50ckpt_every: 500out_dir: checkpointsseed: 42run_name: medium-elounique_run_name: trueworkers: 4log_every: 10Los 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:
# `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: mediuminit_from: checkpoints/medium-v4-20260919-174623/best.pttokens_dir: data/tokens-masters/uciblock: 200batch_size: 32grad_accum: 8lr: 1.0e-4min_lr_ratio: 0.1warmup: 200max_steps: 3800weight_decay: 0.1betas: [0.9, 0.95]grad_clip: 1.0precision: bf16compile: trueeval_every: 200eval_batches: 50ckpt_every: 500out_dir: checkpointsseed: 42run_name: medium-mastersunique_run_name: trueworkers: 4log_every: 10configs/train/medium-masters.yaml
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.
uv run rukh train --config configs/train/medium-elo.yamluv run rukh data tokenize --config configs/data/pipeline-masters.yaml --scheme uci --packuv run rukh train --config configs/train/medium-masters.yamlEl 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».