// M3 · lección 06
Entrenar las cabezas: etiquetas, congelación y la curva
`data/labels.py` y `train/heads.py` enteros: la etiqueta de error que necesita la posición anterior, el reparto por partida con CRC-32, los tres modos de congelación, los subconjuntos anidados de la curva y los checkpoints que saben qué llevan dentro.
Lección 6 de 11 del módulo «El encoder». Viene de «Masked move modeling» y sigue en «Evaluar el encoder».
Qué vas a construir
Lo que convierte un encoder preentrenado en un modelo que responde tres preguntas: la tabla
supervisada (data/labels.py), el afinado por etapas con su curva (train/heads.py), las dos
configuraciones que comparan los esquemas de entrada y los cargadores de checkpoint que hacen falta
cuando el repositorio pasa a tener tres clases de modelo.
Aquí es donde una etiqueta mal construida o un reparto mal hecho producen números buenos que no significan nada. Lo que se decide —cómo se etiqueta, cómo se reparte y qué se congela— es lo mismo que decidirías al afinar un BERT para clasificar tickets de soporte.
labels.py: las 438 093 posiciones etiquetadas
"""The supervised dataset of M3: value, blunder and result labels from the P1 evaluations.
Everything here comes out of ``data/evals/positions-eval.parquet``, the table P1 built bycrossing the sampled positions with ``Lichess/chess-position-evaluations`` (see``rukh.data.evals``): one row per distinct four-field FEN, with the game it first came from(``game_id``, ``ply``, ``last_move``, ``result``) and Stockfish's verdict on it (``best_move``,``cp``, ``mate``, and every line in ``pvs``). No column is invented here; the mate conventionand the sign of a score are ``rukh.data.scoring``, shared with the DPO pairs.
Three labels:
``value`` ``tanh(score / 400)`` from **White's** point of view, so the number is a position's value and not a player's, and a mate is ``±1`` rather than an outlier of ten thousand. Also published as five buckets (``< -200``, ``-200..-50``, ``-50..50``, ``50..200``, ``> 200`` centipawns) so a regression and a classification can be compared on the same rows.
``blunder`` whether ``last_move`` — the move that *led to* this position — threw away at least ``blunder_cp`` centipawns. It needs the previous position of the same game, and it is measured from the point of view of the side that moved: its best line there against what it actually got here, which is minus the value of this position for the opponent. A position whose predecessor is not in the table (the first ply, or a FEN that was deduplicated into another game) has no blunder label at all, ``null``, never ``0``.
``result`` how the game the position came from ended: 0 White, 1 draw, 2 Black.
``game_moves`` is the one thing here that leaves ``positions-eval.parquet``: it reads the P1games back so the ``moves`` scheme of the encoder can be fine-tuned on the line that led to aposition instead of on the position itself. It is deliberately **not** part of ``build_labels``,because the ``squares`` path must not pay for a join it never uses.
The split is by ``game_id`` and never by position. Two positions of the same game are notindependent: the second one is the first one plus a move, and a model that memorised the gamewould score well on both. With a per-position split that leak is invisible in the metrics andfatal in the demo, so ``test_labels`` proves no ``game_id`` is ever on both sides."""«No column is invented here»: la convención del mate y el signo de una puntuación se importan de
rukh.data.scoring en vez de reescribirse, así que la etiqueta de valor de M3 y las parejas de
preferencia de M5 no pueden acabar con dos convenciones distintas para lo mismo.
El último párrafo es la fuga de datosFuga de datosCualquier camino por el que información del conjunto de validación llega al de entrenamiento. No falla: los números mejoran, y se descubre cuando el modelo sale al mundo. En Rukh se evita partiendo por mes (enero entrena; de febrero, desde M2 parte 4, validan las primeras 100 000 partidas y el resto del mes entrena), por PuzzleId con semilla en los puzles, entrenando el BPE solo con datos de enero y, en M3, repartiendo la tabla de posiciones por game_id con un CRC-32 de la semilla y el id, nunca por posición: dos posiciones consecutivas de la misma partida se diferencian en una jugada. El umbral del detector se elige además en una mitad tune y se mide en otra score, partidas también por partida. del módulo, escrita donde está
el código que la evita. Dos posiciones de la misma partida no son independientes: la segunda es la
primera más una jugada. Si el plyPly (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. 30 cae en entrenamiento y el 31 en
validación, un modelo que memoriza partidas puntúa igual de bien que uno que entiende ajedrez, y
ninguna cifra del informe lo delata. Es la misma trampa que repartir por fragmentos los trozos de
un mismo documento al evaluar un RAG.
from __future__ import annotations
import zlibfrom pathlib import Path
import polars as plfrom pydantic import Field
from rukh.config import BaseConfigfrom rukh.data.manifest import FileHash, Manifestfrom rukh.data.scoring import SCORE_WHITE_SQLfrom rukh.data.uci import sha256_filefrom rukh.paths import resolve
LABELS_FILE = "positions-labels.parquet"GAMES_GLOB = "year=*/month=*/games.parquet""""The partition layout ``rukh.data.uci`` writes; one scan reads every month at once."""RESULT_CLASSES: dict[str, int] = {"1-0": 0, "1/2-1/2": 1, "0-1": 2}"""Result of the game the position came from: White wins, draw, Black wins."""VALUE_EDGES: tuple[int, int, int, int] = (-200, -50, 50, 200)"""Centipawn edges of the five value buckets, from White's point of view."""SPLITS = ("train", "val")SOURCE_COLUMNS = ["fen", "game_id", "ply", "last_move", "result", "phase", "cp", "mate"]SOURCE_COLUMNS es una lista explícita porque un select de ocho columnas sobre un parquet de
catorce lee solo esas ocho del disco, la gracia del formato columnar. Y RESULT_CLASSES fija en un
solo sitio qué índice es cada resultado: un 0 que significa blancas aquí y tablas en el informe es
un bug que solo se nota mirando una matriz de confusión.
class LabelsConfig(BaseConfig): """Where the evaluations are, how a blunder is defined and how the split is drawn."""
positions_eval: str = "data/evals/positions-eval.parquet" games_dir: str = "data/uci" """Where ``game_moves`` looks for the P1 games, ``year=*/month=*/games.parquet``.
Only the ``moves`` scheme reads it: the ``squares`` path never touches ``data/uci``.""" out_dir: str = "data/labels" value_scale: float = Field(default=400.0, gt=0.0) """``tanh(cp / value_scale)``: 400 centipawns (four pawns, a bit less than a rook) is 0.76.""" blunder_cp: int = Field(default=100, ge=1) val_fraction: float = Field(default=0.1, gt=0.0, lt=1.0) seed: int = 42value_scale, blunder_cp y val_fraction definen el dataset: cambiarlos cambia lo que las
métricas significan, así que viajan en la configuración y en el manifiesto, no en el código.
value_scale = 400.0: los centipeonesCentipeón (cp)Unidad de la evaluación de un motor: 100 centipeones equivalen a un peón de ventaja. Es la etiqueta que Rukh cruzó en M1 desde las evaluaciones de Lichess y la que el encoder aprende a predecir, acotada con tanh(cp/400) para que un mate valga ±1 en vez de diez mil y no domine la pérdida. Un error se define como perder ≥ 100 cp respecto a la mejor línea de la posición anterior. no están acotados y un mate vale
±10 000, así que un puñado de mates dominaría un error cuadrático y el modelo gastaría su capacidad
clavando posiciones ya decididas. El tanh comprime, y 400 deja la zona donde una partida todavía se
juega, de −300 a +300, en la parte de la curva que tiene pendiente. val_fraction exige gt=0.0
porque una validación vacía dejaría al bucle publicando nan tan feliz.
def game_split(game_id: int, val_fraction: float, seed: int) -> str: """Which side of the split a game falls on: a pure function of its id and the seed.
CRC-32 rather than ``hash()`` or a dataframe hash: it is the same number in every process, every polars version and every future run, which is what makes a split reproducible. """ digest = zlib.crc32(f"{seed}:{game_id}".encode()) return "val" if (digest % 1_000_000) / 1_000_000 < val_fraction else "train"
def row_order(game_id: int, ply: int, seed: int) -> int: """A stable per-row key: the order the label-count curve grows its nested subsets in.""" return zlib.crc32(f"{seed}:{game_id}:{ply}".encode())El reparto es una función pura del id de la partida y de la semilla: no hay estado, ni orden de
filas, ni barajado. ¿Por qué CRC-32 y no hash()? Python aleatoriza el hash de las cadenas en cada
proceso (PYTHONHASHSEED), así que un reparto con hash() se vuelve a sortear cada vez que
arrancas el script, y la validación de hoy contiene partidas con las que entrenaste ayer. CRC-32 no
es criptográfico ni falta que hace: solo se le pide ser determinista y repartir uniforme. Es un bombo
grabado en vídeo: cada vez que pones la cinta salen las mismas bolas, en cualquier máquina y dentro
de un año.
row_order añade el ply a la clave, así que ordena filas en vez de partidas. Es lo que usa la curva
para crecer un único conjunto en vez de sortear cuatro.
def _score_white() -> pl.Expr: """Centipawns from White's point of view, with a mate in ``n`` worth ``±(10000 - n)``.
The convention is not restated here: ``rukh.data.scoring.SCORE_WHITE_SQL`` is the one the DPO pairs and the evaluation consolidation already run, and ``pl.sql_expr`` turns it into a polars expression, so a change to the mate convention cannot reach one consumer and miss this one. """ return pl.sql_expr(SCORE_WHITE_SQL).cast(pl.Int64)
def _value_bucket(score: pl.Expr) -> pl.Expr: """Index of the five-bucket classification variant of ``value``.""" bucket = pl.lit(0, dtype=pl.Int8) for index, edge in enumerate(VALUE_EDGES, start=1): bucket = pl.when(score >= edge).then(pl.lit(index, dtype=pl.Int8)).otherwise(bucket) return bucketpl.sql_expr(SCORE_WHITE_SQL) es una idea que vale la pena robar: la convención de puntuación vive
escrita una vez, en SQL, y se ejecuta tal cual en DuckDB (donde se construyó la tabla de
evaluaciones) y en polars. No hay dos implementaciones que puedan divergir. _value_bucket es el
equivalente vectorizado de una cadena de elif, y existe para responder con datos a «¿y si esto
fuera mejor como clasificación que como regresión?».
def build_labels(cfg: LabelsConfig, frame: pl.DataFrame | None = None) -> pl.DataFrame: """The labelled table; ``frame`` overrides reading ``cfg.positions_eval`` (used by tests).""" source = frame if frame is not None else pl.read_parquet(resolve(cfg.positions_eval)) rows = ( source.select(SOURCE_COLUMNS) .with_columns( score_white=_score_white(), mover=pl.when(pl.col("fen").str.split(" ").list.get(1) == "w").then(1).otherwise(-1), ) .drop_nulls("score_white") .with_columns(score_mover=pl.col("mover") * pl.col("score_white")) ) # The move into this position is judged against the best line of the position before it, and # from the mover's side: what it could have had there, minus what it actually got here # (which is minus the value of this position for the opponent, now to move). previous = rows.select( pl.col("game_id"), (pl.col("ply") + 1).alias("ply"), pl.col("score_mover").alias("before_best"), ) joined = rows.join(previous, on=["game_id", "ply"], how="left").with_columns( loss_cp=(pl.col("before_best") + pl.col("score_mover")).cast(pl.Int64) )La parte difícil del fichero, y es toda de signos. score_white es la evaluación desde el lado de
las blancas, siempre; score_mover la pone del lado del que mueve en esta posición. El previous es
un auto-join desplazado: las mismas filas con el ply sumado en 1, de modo que cada fila recibe el
score_mover de la posición anterior de su partida, cuando movía el bando que hizo la jugada que
llevó aquí.
Y la línea que parece un error: loss_cp = before_best + score_mover, una suma donde uno
esperaría una resta. score_mover de la posición actual está del lado del rival, que es quien mueve
ahora, así que lo que consiguió el que movió es -score_mover, y lo que tiró es
before_best - (-score_mover). Con números: las blancas tenían +50 antes de mover; tras su jugada,
con las negras a mover, el score_mover es +250. loss_cp = 50 + 250 = 300: tiraron tres peones.
El how="left" es la otra decisión. Una posición cuyo predecesor no está en la tabla —el primer ply,
o un FEN que se dedupó en otra partida— acaba con un blunder nulo, nunca con un cero.
games = joined.select("game_id").unique().sort("game_id") split = pl.DataFrame( { "game_id": games["game_id"], "split": [game_split(int(g), cfg.val_fraction, cfg.seed) for g in games["game_id"]], } ) labelled = ( joined.join(split, on="game_id", how="left") .with_columns( value=(pl.col("score_white") / cfg.value_scale).tanh().cast(pl.Float32), value_bucket=_value_bucket(pl.col("score_white")), result_class=pl.col("result") .replace_strict(RESULT_CLASSES, default=None) .cast(pl.Int8), blunder=pl.when(pl.col("loss_cp").is_null()) .then(None) .otherwise((pl.col("loss_cp") >= cfg.blunder_cp).cast(pl.Int8)), ) .drop_nulls("result_class") ) order = [ row_order(int(game), int(ply), cfg.seed) for game, ply in zip(labelled["game_id"], labelled["ply"], strict=True) ] return labelled.with_columns(order=pl.Series("order", order, dtype=pl.UInt32)).select( "game_id", "ply", "fen", "last_move", "phase", "split", "value", "value_bucket", "blunder", "result_class", pl.col("score_white").alias("cp"), "loss_cp", "order", )El reparto se calcula una vez por partida y se une, así que es imposible que dos filas de la misma
partida reciban lados distintos. Las partidas con resultado * (sin resultado registrado) se tiran
con drop_nulls en vez de adivinarse: un default=1 habría metido partidas sin terminar en la
clase de las tablas.
GAME_COLUMNS = ("game_id", "uci", "white_elo", "black_elo")
def game_moves(frame: pl.DataFrame, games_dir: str = "data/uci") -> pl.DataFrame: """The move prefix source of ``frame``: one row per **game**, never per position.
``build_labels`` deliberately knows nothing about this: the ``squares`` scheme reads a FEN and nothing else, and joining two months of games into it would make the cheap path pay for the expensive one. The ``moves`` scheme of ``rukh.train.heads`` calls this instead, and it scans ``games.parquet`` lazily and semi-joins on the labels' distinct ``game_id``s, so only the games that actually carry a label are ever read.
The caveat that travels with the result: the supervised table is deduplicated by ``fen4``, so the row's ``game_id`` is the game that *first* reached that position. The prefix built from it is **a** line reaching the position, not necessarily the one the labelled game played — the position is the same, its history may not be. """ wanted = frame.select(pl.col("game_id").unique()).lazy() pattern = (resolve(games_dir) / GAMES_GLOB).as_posix() return ( pl.scan_parquet(pattern) .select(*GAME_COLUMNS) .join(wanted, on="game_id", how="semi") .unique(subset=["game_id"], keep="first") .collect() )Esta función, la que permite comparar los dos esquemas, es un ejercicio de 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.: pl.scan_parquet es perezoso, el semi
join contra los game_id de las etiquetas se resuelve antes de materializar nada, y de dos meses de
partidas solo se leen las que llevan alguna etiqueta. Un read_parquet seguido de un filtro leería
los dos meses enteros a memoria. Y es semi y no inner porque no duplica filas: con un inner,
una partida con tres posiciones etiquetadas aparecería tres veces.
def label_subsets(frame: pl.DataFrame, fractions: list[float]) -> dict[float, pl.DataFrame]: """Nested subsets for the label-count curve: 10 % is inside 25 %, inside 50 %, inside 100 %.
Nested on purpose. With independent samples, a dip at 50 % could just be an unlucky draw and the curve would measure sampling noise instead of the value of more labels; growing one set means every point is the previous one plus new rows. """ if any(not 0.0 < fraction <= 1.0 for fraction in fractions): raise ValueError(f"every fraction must be in (0, 1], got {fractions}") ordered = frame.sort(["order", "game_id", "ply"]) total = ordered.height return { fraction: ordered.head(max(1, round(total * fraction))) for fraction in sorted(set(fractions)) }
def counts(frame: pl.DataFrame) -> dict[str, int]: """Row counts worth recording: total, per split and how many carry a blunder label.""" per_split = {name: int(frame.filter(pl.col("split") == name).height) for name in SPLITS} return { "positions": int(frame.height), **per_split, "blunder_labelled": int(frame.drop_nulls("blunder").height), "blunders": int(frame.filter(pl.col("blunder") == 1).height), }Los subconjuntos anidados de la lección 1, en código: un sort por la clave estable y un head(n).
Ordenar por un CRC-32 es barajar de forma reproducible, y quedarse con los primeros n de un orden
fijo garantiza el anidamiento por construcción. counts publica tres denominadores distintos
(posiciones, posiciones con etiqueta de error y errores), porque una métrica calculada sobre el
denominador equivocado es otra métrica.
def run(cfg: LabelsConfig) -> Manifest: """Write ``out_dir/positions-labels.parquet`` plus the manifest, and return the manifest.""" frame = build_labels(cfg) out_dir = resolve(cfg.out_dir) out_dir.mkdir(parents=True, exist_ok=True) target = out_dir / LABELS_FILE frame.write_parquet(target, compression="zstd") source = resolve(cfg.positions_eval) manifest = Manifest( dataset="rukh-positions-eval", months=[], filters={ "positions_eval": Path(cfg.positions_eval).as_posix(), # The labels are a pure function of this file and of the settings below, so its # digest is what says whether two label tables are the same table. "positions_eval_sha256": sha256_file(source) if source.is_file() else None, "value_scale": cfg.value_scale, "value_edges": list(VALUE_EDGES), "blunder_cp": cfg.blunder_cp, "split": {"by": "game_id", "val_fraction": cfg.val_fraction, "seed": cfg.seed}, }, counts=counts(frame), files=[FileHash(path=LABELS_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 manifiestoManifiesto de datosFichero JSON que acompaña a cada recorte con los filtros exactos, los meses, los conteos y el hash de cada fichero. Sin manifiesto no hay reproducibilidad: es lo que permite decir qué datos vio un modelo. de M0 aplicado a la tabla supervisada. Como las etiquetas son una función pura del fichero de evaluaciones y de estas claves, dos tablas con el mismo manifiesto son la misma tabla. El reparto también está ahí, para que «el 10 % de validación» sea un hecho registrado y no una afirmación.
heads.py: los tres modos y la curva
"""Staged fine-tuning of the three heads, and the curve that asks how many labels are needed.
Three modes, in the order the lesson walks through them:
``probe`` the encoder is frozen and only the three linear heads move. It answers the only question that matters about the pretraining: *is the information already in the representation?* The encoder's weights come out of a probe run bit for bit identical, and a test proves it.``last-n`` the last ``last_n`` blocks and the final norm are unfrozen. The early layers keep the general features, the late ones specialise.``full`` everything moves. Best numbers, most labels needed, easiest to overfit.
On top of that, ``label_curve`` trains the same recipe on 10 %, 25 %, 50 % and 100 % of thetraining labels, on **nested** subsets (see ``rukh.data.labels.label_subsets``), so the curvemeasures the value of more labels and not the luck of a draw. That curve is the module's lesson:labelling is the expensive part of this project, and the curve says where it stops paying.
Both input schemes can be fine-tuned here, which is what makes the spec's comparison possible:
``squares`` ``data/evals/positions-eval.parquet`` holds a FEN per position, and 69 fixed tokens come straight out of it.``moves`` the position is the *line that reached it*: ``rukh.data.labels.game_moves`` joins the P1 games back in and the item is ``[<bos>, <wXXXX>, <bXXXX>] + uci.split()[:ply]``, cropped header-first by ``rukh.infer.sampler.prompt_ids`` and padded per batch. This is the scheme the masked-move pretraining runs on, so it is the only one an MMM checkpoint can be loaded into — before, the pretrained encoder had nowhere to go.
The caveat of that path: the supervised table is deduplicated by ``fen4``, so the prefix is **a** line reaching the position, not necessarily the labelled game's. The position, the value and the blunder verdict are the same either way; the history may not be."""«full: best numbers» es la expectativa habitual, escrita antes de medir; la lección 11 la pone a
prueba.
from __future__ import annotations
import loggingimport mathimport timefrom collections.abc import Callable, Sequencefrom pathlib import Pathfrom typing import Literal
import polars as plimport torchfrom pydantic import Fieldfrom torch import Tensor, nnfrom torch.utils.data import DataLoader, Datasetfrom rukh.config import BaseConfigfrom rukh.data.labels import LabelsConfig, build_labels, game_moves, label_subsetsfrom rukh.infer.sampler import HEADER_TOKENS, prompt_idsfrom rukh.models import EncoderConfig, PositionEncoderfrom rukh.models.encoder import PAD_IDfrom rukh.models.heads import HEADS, HeadWeights, MultiHeadfrom rukh.models.squares import fen_to_tokensfrom rukh.tokenize.uci_vocab import UciTokenizer, elo_tokenfrom rukh.train.checkpoint import BEST_NAME, CURVE_KEY, attach_payload, load_encoder, step_namefrom rukh.train.common import ( RunConfig, autocast_for, forever, load_resume, log_metrics, maybe_compile, param_groups, pick_device, run_dir, set_lr, write_checkpoint,)
log = logging.getLogger(__name__)
Mode = Literal["probe", "last-n", "full"]Scheme = Literal["squares", "moves"]Item = dict[str, Tensor]CURVE = (0.1, 0.25, 0.5, 1.0)El afinado reutiliza el recorte del muestreador de M2 (prompt_ids): recortado de otra manera, el
modelo se afinaría sobre secuencias con una forma que el preentrenamiento y la demo no producen. Es
el equivalente a afinar un LLM con la misma plantilla de chat que usará en producción.
class LabelledPositions(Dataset[Item]): """The labelled rows as tensors; one item is a position and its three labels.
The tokens are built on demand rather than up front: a handful of small integers per row is cheap to build and expensive to keep for millions of rows. On ``squares`` that is the 69 tokens of the FEN; on ``moves`` it is the header plus the first ``ply`` moves of the game, which is why ``moves`` needs the ``(game_id, uci)`` frame of ``rukh.data.labels.game_moves`` — stored per **game**, never copied per row. """
def __init__( self, frame: pl.DataFrame, scheme: Scheme = "squares", moves: pl.DataFrame | None = None, block: int = 200, ): self.scheme = scheme self.block = block self.value = frame["value"].to_numpy().astype("float32") self.result = frame["result_class"].to_numpy().astype("int64") self.blunder_mask = frame["blunder"].is_not_null().to_numpy() self.blunder = frame["blunder"].fill_null(0).to_numpy().astype("float32") self.fens: list[str] = frame["fen"].to_list() self.game_ids: list[int] = [int(value) for value in frame["game_id"].to_list()] self.plies: list[int] = [int(value) for value in frame["ply"].to_list()] self.prefixes: dict[int, list[int]] = {} if scheme == "moves": if moves is None: raise ValueError( "the 'moves' scheme needs the games of the labelled rows: pass the frame " "of rukh.data.labels.game_moves(frame, cfg.labels.games_dir)" ) self.prefixes = _move_prefixes(moves) unknown = {game for game in self.game_ids if game not in self.prefixes} if unknown: raise ValueError( f"{len(unknown)} labelled games are missing from the games frame " f"(first: {sorted(unknown)[:3]}); check labels.games_dir" )Las dos líneas que hay que mirar juntas son estas:
self.blunder_mask = frame["blunder"].is_not_null().to_numpy()self.blunder = frame["blunder"].fill_null(0).to_numpy().astype("float32")El null de la etiqueta de error se desdobla en una máscara y un relleno: la máscara dice si
hay etiqueta, y el 0 del relleno nunca se mira, porque la pérdida de la lección 4 selecciona con la
máscara antes de calcular nada. Es la forma estándar de meter un valor ausente en un tensor, que no
admite nulos, sin que la ausencia se convierta en una clase. Por eso is_not_null() va antes del
fill_null(0); al revés, la máscara saldría toda True.
Los tokens se construyen bajo demanda en tokens(): precalcular 438 093 filas de hasta 200 enteros
serían unos 350 MB de listas de Python. Y la comprobación de unknown adelanta el fallo al
construir el dataset, con cuántas partidas faltan; si no, llegaría como un KeyError dentro de un
trabajador del DataLoader, en una traza de multiproceso que no menciona games_dir.
def __len__(self) -> int: return len(self.fens)
def tokens(self, index: int) -> list[int]: """The input ids of one row, in the scheme this dataset was built for.""" if self.scheme == "squares": return fen_to_tokens(self.fens[index]) prefix = self.prefixes[self.game_ids[index]] # ``ply`` counts the move that *led to* this position, so the prefix includes it. ids = prefix[: HEADER_TOKENS + self.plies[index]] # A prefix longer than the context keeps <bos> and the two Elo tokens and drops the # oldest moves: the same header-first crop the sampler uses, so the fine-tuned model # sees the shape the pretraining and the demo do. return prompt_ids(ids, self.block)
def __getitem__(self, index: int) -> Item: return { "idx": torch.tensor(self.tokens(index), dtype=torch.long), "value": torch.tensor(float(self.value[index])), "blunder": torch.tensor(float(self.blunder[index])), "blunder_mask": torch.tensor(bool(self.blunder_mask[index])), "result": torch.tensor(int(self.result[index])), }Un ply - 1 en prefix[: HEADER_TOKENS + ply] daría la posición anterior, y el modelo predeciría
el valor de una posición distinta de la etiquetada: un error sin más síntoma que unas métricas
mediocres. El recorte por cabecera conserva <bos> y los dos tokens de Elo y tira las jugadas más
antiguas; recortar por el otro extremo perdería el condicionamiento de fuerza que el encoder hereda
de M2.
def _move_prefixes(moves: pl.DataFrame) -> dict[int, list[int]]: """``game_id -> [<bos>, elo(white), elo(black), *every move]``, one entry per game.""" tok = UciTokenizer() out: dict[int, list[int]] = {} for game_id, uci, white_elo, black_elo in moves.select( "game_id", "uci", "white_elo", "black_elo" ).rows(): ids = [ tok.bos_id, tok.vocab[elo_token(int(white_elo), "w")], tok.vocab[elo_token(int(black_elo), "b")], ] ids.extend(tok.vocab.get(move, tok.unk_id) for move in str(uci).split()) out[int(game_id)] = ids return outUn diccionario por partida con la secuencia completa: las posiciones etiquetadas de la misma
partida comparten la lista y tokens() corta un prefijo distinto para cada una. Y vocab.get(move, unk_id) tolera una jugada fuera del vocabulario: como el de M1 cubre las 1 968 jugadas posibles,
eso solo puede ser un dato corrupto suelto, y perder una fila es mejor que perder la tirada.
def collate(items: list[Item]) -> Item: """Pad a batch to its longest sequence and say which tokens are real.
``squares`` items are all 69 tokens long and the mask is all ``True``; ``moves`` items are as long as the game was, so the short ones are padded with ``<pad>`` and the mask keeps the encoder's attention off them. The padding is never silent: ``attention_mask`` travels with the batch and ``PositionEncoder.pool`` averages the real tokens only. """ width = max(int(item["idx"].shape[0]) for item in items) idx = torch.full((len(items), width), PAD_ID, dtype=torch.long) mask = torch.zeros((len(items), width), dtype=torch.bool) for row, item in enumerate(items): tokens = item["idx"] idx[row, : tokens.shape[0]] = tokens mask[row, : tokens.shape[0]] = True batch: Item = {"idx": idx, "attention_mask": mask} for key in ("value", "blunder", "blunder_mask", "result"): # The labels are optional so that inference (``rukh eval encoder``) can pad a batch of # bare token sequences with this very function instead of a second copy of it. if key in items[0]: batch[key] = torch.stack([item[key] for item in items]) return batchAquí se cierra el círculo del relleno que abrió la lección 3. El lote se rellena hasta la más larga
de sus secuencias, no hasta el block de 200, así que la cantidad de relleno de cada secuencia
depende de con quién le haya tocado viajar. Por eso la máscara viaja con el lote y pool promedia
solo lo real. Las etiquetas son opcionales para que la evaluación de la lección 7 use esta misma
función; una segunda copia para inferencia acabaría desincronizándose.
def make_label_loader( dataset: LabelledPositions, batch_size: int, seed: int = 0, workers: int = 0, shuffle: bool = True,) -> DataLoader[Item]: """A ``DataLoader`` whose shuffling is fully determined by ``seed``.""" generator = torch.Generator() generator.manual_seed(seed) return DataLoader( dataset, batch_size=batch_size, shuffle=shuffle, drop_last=False, num_workers=workers, generator=generator, persistent_workers=workers > 0, collate_fn=collate, )El generator explícito hace que el barajado dependa de la semilla de la configuración y no del
estado global de torch. persistent_workers=workers > 0 es una condición porque PyTorch lanza si se
pide persistencia sin trabajadores; con ellos, evita rearrancar procesos en cada época, que en
Windows cuesta segundos.
class HeadsConfig(RunConfig): """One fine-tuning run of the three heads; unknown keys in the YAML are an error."""
labels: LabelsConfig = Field(default_factory=LabelsConfig) encoder_ckpt: str | None = None """The masked-move checkpoint to start from; ``None`` trains the encoder from scratch, which is the honest baseline the pretraining has to beat.""" model: EncoderConfig | None = None # only used when there is no checkpoint to load input: Scheme = "squares" """The representation the heads are fine-tuned on; it must match ``encoder_ckpt``'s.""" pooling: Literal["cls", "mean"] = "mean" mode: Mode = "probe" last_n: int = Field(default=2, ge=1) fraction: float = Field(default=1.0, gt=0.0, le=1.0) curve: list[float] = list(CURVE) weights: HeadWeights = Field(default_factory=HeadWeights)
@property def default_name(self) -> str: return f"heads-{self.mode}"
def encoder(self) -> EncoderConfig: """The encoder config used when no checkpoint is loaded.""" base = self.model if self.model is not None else EncoderConfig() return base.model_copy(update={"input": self.input, "block": self.block})mode: Mode = "probe" por defecto es una postura: la tirada que sale sin pedir nada responde la
pregunta incómoda, «¿de verdad hacía falta todo esto?», en vez de dar los mejores números. Y sin
encoder_ckpt el encoder arranca de pesos aleatorios, que es el baseline honesto que el
preentrenamiento tiene que batir (y la configuración de casillas, que no tiene preentrenamiento
posible).
class HeadsResult(BaseConfig): """What one fine-tuning run produced: where the weights are and how they scored."""
checkpoint: str mode: Mode fraction: float train_labels: int val_labels: int metrics: dict[str, float]Cada resultado lleva la fracción y los conteos junto a las métricas: así cada punto de la curva sabe cuántas etiquetas vio.
def freeze_encoder(model: MultiHead, mode: Mode, last_n: int = 2) -> int: """Apply a fine-tuning mode and return how many encoder tensors stay trainable.""" for param in model.encoder.parameters(): param.requires_grad = mode == "full" if mode == "last-n": blocks = list(model.encoder.blocks) for block in blocks[max(0, len(blocks) - last_n) :]: for param in block.parameters(): param.requires_grad = True for param in model.encoder.ln_f.parameters(): param.requires_grad = True return sum(param.requires_grad for param in model.encoder.parameters())
def set_training_mode(model: MultiHead, mode: Mode, last_n: int = 2) -> None: """Training mode for the whole model, with every **frozen** block kept in ``eval``.
A frozen block in ``train`` mode would still apply dropout, so the same position would give the heads a different vector every epoch: noise the heads cannot learn away and the block cannot absorb, because it is not learning at all. Under ``last-n`` that applies to the frozen prefix too — the early blocks and the embeddings go to ``eval`` and only the last ``last_n`` blocks and the final norm keep their dropout, which is the regularisation of the part that is actually being trained. """ model.train() if mode == "probe": model.encoder.eval() elif mode == "last-n": encoder = model.encoder encoder.eval() for block in list(encoder.blocks)[max(0, len(encoder.blocks) - last_n) :]: block.train() encoder.ln_f.train()Estas dos funciones son el corazón del afinado, y la segunda es la que casi nadie escribe.
freeze_encoder pone requires_grad según el modo y devuelve cuántos tensores quedan entrenables,
un número que se registra en MLflow y que en un probe tiene que ser 0: un probe con 148 tensores
entrenables es un bug visible. El max(0, …) protege del caso last_n > n_layer, donde un índice
negativo daría un rebanado arbitrario en vez de «todos».
set_training_mode es la parte que se olvida: congelar es más que requires_grad = False. Un
módulo que se queda en modo train sigue aplicando dropout, así que la misma posición daría a las
cabezas un vector distinto en cada época, un ruido que nadie puede absorber porque el encoder no
está aprendiendo. Es como estudiar con unos apuntes que alguien reescribe un poco cada vez que los
abres. En last-n, el encoder entero pasa a eval y solo los bloques que se entrenan vuelven a
train: el prefijo congelado da siempre el mismo vector y la parte que aprende conserva su
regularización.
def build_frames(cfg: HeadsConfig) -> tuple[pl.DataFrame, pl.DataFrame]: """``(train, val)`` label frames, split by ``game_id`` and cut to ``cfg.fraction``.""" frame = build_labels(cfg.labels) train = frame.filter(pl.col("split") == "train") if cfg.fraction < 1.0: train = label_subsets(train, [cfg.fraction])[cfg.fraction] return train, frame.filter(pl.col("split") == "val")
def build_datasets( cfg: HeadsConfig, train_frame: pl.DataFrame, val_frame: pl.DataFrame) -> tuple[LabelledPositions, LabelledPositions]: """The two datasets of a run; on ``moves`` the games are read once and shared.""" moves = None if cfg.input == "moves": moves = game_moves(pl.concat([train_frame, val_frame]), cfg.labels.games_dir) log.info("%d games joined back for the 'moves' scheme", moves.height) return ( LabelledPositions(train_frame, cfg.input, moves, cfg.block), LabelledPositions(val_frame, cfg.input, moves, cfg.block), )build_frames recorta solo el entrenamiento: la validación es la misma, entera, en los cuatro
puntos de la curva, porque el instrumento de medida no cambia entre medidas. build_datasets lee
las partidas una vez para los dos conjuntos y comparte el diccionario de prefijos.
def load_encoder_for(cfg: HeadsConfig, where: torch.device) -> PositionEncoder: """The pretrained encoder of ``encoder_ckpt``, or a fresh one when there is none.""" if cfg.encoder_ckpt is None: return PositionEncoder(cfg.encoder()) from rukh import paths
encoder, _payload = load_encoder(paths.resolve(cfg.encoder_ckpt), map_location=where) if encoder.cfg.input != cfg.input: raise ValueError( f"{cfg.encoder_ckpt} was trained on the {encoder.cfg.input!r} scheme, " f"the run asks for {cfg.input!r}" ) return encoderUn checkpoint entrenado sobre jugadas no se puede afinar sobre casillas. Con vocabularios (2 030 y 47) y longitudes (200 y 69) distintos, cargar uno en el otro fallaría casi siempre al comparar formas; el caso peor es que cuadren por casualidad y se cargue un embedding que describe otra cosa. Por eso el error es explícito y nombra los dos esquemas.
def to_device(batch: Item, where: torch.device) -> Item: return {key: value.to(where) for key, value in batch.items()}
def _compile_probe( model: MultiHead, dataset: LabelledPositions, where: torch.device, batch_size: int) -> Callable[[nn.Module], None]: """One real step, so ``torch.compile`` runs here and not in the middle of the loop.
The probe uses the dataset's own items, mask included: probing with ``attention_mask=None`` while the loop always passes one would compile a graph the loop never runs and pay for a recompilation at step 1. """
def probe(compiled: nn.Module) -> None: rows = [dataset[index] for index in range(min(batch_size, len(dataset)))] batch = to_device(collate(rows), where) outputs = compiled(batch["idx"], batch["attention_mask"]) total, _ = model.loss(outputs, batch) total.backward()
return probeEl sondeo de compilación de la lección 5, ahora con las primeras filas del propio dataset. Aun así,
las dos configuraciones del hito traen compile: false: en casillas no hay trabajo suficiente para
amortizar la compilación, y en jugadas cada longitud de lote nueva provoca una recompilación.
@torch.no_grad()def evaluate_heads( model: MultiHead, loader: DataLoader[Item], batches: int, where: torch.device) -> dict[str, float]: """Loss and one metric per head over at most ``batches`` validation batches.""" was_training = model.training model.eval() totals = {f"val/{name}_loss": 0.0 for name in HEADS} seen = 0 value_error = 0.0 blunder_hits = blunder_seen = 0 result_hits = 0 for index, raw in enumerate(loader): if index >= batches: break batch = to_device(raw, where) outputs = model(batch["idx"], batch.get("attention_mask")) total, parts = model.loss(outputs, batch) rows = int(batch["idx"].shape[0]) seen += rows totals["val/loss"] = totals.get("val/loss", 0.0) + float(total) * rows for name in HEADS: totals[f"val/{name}_loss"] += float(parts[name]) * rows value_error += float((outputs["value"] - batch["value"]).abs().sum()) mask = batch["blunder_mask"].bool() if bool(mask.any()): predicted = (outputs["blunder"][mask] > 0).float() blunder_hits += int((predicted == batch["blunder"][mask]).sum()) blunder_seen += int(mask.sum()) result_hits += int((outputs["result"].argmax(dim=-1) == batch["result"]).sum()) if was_training: model.train() if not seen: return {"val/loss": math.nan} metrics = {key: value / seen for key, value in totals.items()} metrics["val/value_mae"] = value_error / seen metrics["val/blunder_acc"] = blunder_hits / blunder_seen if blunder_seen else math.nan metrics["val/result_acc"] = result_hits / seen return metricsval/value_mae es el error absoluto medio en la escala acotada, no en centipeones: un 0,12 son unos
48 centipeones solo cerca del cero, donde el tanh es casi lineal. Sirve para ver si algo se mueve,
no para publicar. val/blunder_acc divide entre las filas con etiqueta, y es la cifra más engañosa
del módulo: con los errores al 3,72 %, un detector que conteste siempre «no hay error» saca 96,3 %.
La lección 8 la desmonta. Y > 0 sobre el logit es umbralizar en 0,5 sin pasar por el sigmoide.
def train_heads( cfg: HeadsConfig, resume: Path | None = None, device: str | None = None) -> HeadsResult: """Fine-tune the three heads over a (possibly frozen) encoder and return the run's result.""" cfg.check() torch.manual_seed(cfg.seed) where = torch.device(device or pick_device()) train_frame, val_frame = build_frames(cfg) if not train_frame.height: raise ValueError("the training split is empty; check labels.val_fraction and the source") train_set, val_set = build_datasets(cfg, train_frame, val_frame) train_loader = make_label_loader(train_set, cfg.batch_size, cfg.seed, cfg.workers) val_loader = make_label_loader(val_set, cfg.batch_size, cfg.seed, 0, shuffle=False)
encoder = load_encoder_for(cfg, where) model = MultiHead(encoder, cfg.weights, cfg.pooling).to(where) trainable = freeze_encoder(model, cfg.mode, cfg.last_n) optimizer = torch.optim.AdamW(param_groups(model, cfg.weight_decay), lr=cfg.lr, betas=cfg.betas) start_step = 0 best_val = math.inf run_id: str | None = None if resume is not None: start_step, best_val, run_id = load_resume(resume, model, optimizer, where) autocast, _ = autocast_for(cfg, where) with autocast: # compile under the same precision the loop will use probe = _compile_probe(model, train_set, where, cfg.batch_size) runnable = maybe_compile(model, cfg.compile, probe=probe) set_training_mode(model, cfg.mode, cfg.last_n) out_dir = run_dir(cfg, resume) out_dir.mkdir(parents=True, exist_ok=True) batches = forever(train_loader) final = out_dir / step_name(cfg.max_steps) metrics: dict[str, float] = {}
from rukh.tracking import start_runEn el bloque anterior, el orden de las líneas es el contenido. freeze_encoder va antes de
construir el optimizador, para que param_groups deje fuera el tronco congelado. Y
set_training_mode va después de maybe_compile, porque la compilación ejecuta un paso de sondeo y
el modo tiene que quedar fijado al final. El shuffle=False de validación hace que los primeros
lotes sean los mismos en todas las evaluaciones de la tirada.
params = { # ``tokens_dir`` is the one inherited knob this loop has no use for: the labels are a # parquet of positions, not a packed token stream. **cfg.model_dump(mode="json", exclude={"tokens_dir"}), "device": str(where), "train_labels": train_frame.height, "val_labels": val_frame.height, "trainable_encoder_tensors": trainable, "num_params": sum(p.numel() for p in model.parameters()), } tags = {"objective": "heads", "mode": cfg.mode, "fraction": str(cfg.fraction)} with start_run(out_dir.name, params, tags=tags, run_id=run_id) as run: log.info("run %s in %s on %s (%s)", run.info.run_id, out_dir, where, cfg.mode) this_run = str(run.info.run_id)
def save(path: Path, step: int) -> Path: return write_checkpoint( path, step=step, model=model, optimizer=optimizer, cfg=cfg, model_cfg=encoder.cfg, vocab_hash=None, manifest_sha=None, best_val=best_val, run_id=this_run, )model_cfg=encoder.cfg, porque el MultiHead no tiene configuración propia: es lo que load_heads
necesitará para reconstruir el modelo. vocab_hash y manifest_sha van a None porque este bucle
no lee un pack de tokens ni un manifiesto, y escribir null es mejor que inventarlos. Y
trainable_encoder_tensors en los parámetros convierte «congelé el tronco» en un dato consultable
en MLflow.
clock = time.perf_counter() for step in range(start_step, cfg.max_steps): lr = set_lr(optimizer, step, cfg) optimizer.zero_grad(set_to_none=True) step_losses = {name: 0.0 for name in HEADS} step_total = 0.0 for _ in range(cfg.grad_accum): batch = to_device(next(batches), where) with autocast: outputs = runnable(batch["idx"], batch.get("attention_mask")) total, parts = model.loss(outputs, batch) (total / cfg.grad_accum).backward() step_total += float(total.detach()) / cfg.grad_accum for name in HEADS: step_losses[name] += float(parts[name].detach()) / cfg.grad_accum grad_norm = float( nn.utils.clip_grad_norm_( [p for p in model.parameters() if p.requires_grad], cfg.grad_clip ) ) optimizer.step() done = step + 1 last = done == cfg.max_steps if done % cfg.log_every == 0 or last: elapsed = max(time.perf_counter() - clock, 1e-9) steps = min(cfg.log_every, done - start_step) log_metrics( { "train/loss": step_total, **{f"train/{name}_loss": step_losses[name] for name in HEADS}, "lr": lr, "grad_norm": grad_norm, "positions_per_s": cfg.batch_size * cfg.grad_accum * steps / elapsed, }, step=done, ) clock = time.perf_counter()positions_per_s en vez de tokens_per_s, porque con 69 tokens frente a hasta 200 los tokens por
segundo no serían comparables entre esquemas. clip_grad_norm_ sobre los parámetros entrenables no
cambia el recorte (los congelados no tienen gradiente), pero hace que grad_norm registre la norma
de lo que de verdad se mueve. Y las tres pérdidas por cabeza se registran por separado, que es lo
que permitirá ver en la lección 11 cómo una sube mientras otra baja.
if done % cfg.eval_every == 0 or last: metrics = evaluate_heads(model, val_loader, cfg.eval_batches, where) set_training_mode(model, cfg.mode, cfg.last_n) log_metrics(metrics, step=done) val_loss = metrics.get("val/loss", math.nan) log.info("step %d val/loss %.4f", done, val_loss) if not math.isnan(val_loss) and val_loss < best_val: best_val = val_loss save(out_dir / BEST_NAME, done) clock = time.perf_counter() if done % cfg.ckpt_every == 0 or last: final = save(out_dir / step_name(done), done) clock = time.perf_counter() return HeadsResult( checkpoint=str(final), mode=cfg.mode, fraction=cfg.fraction, train_labels=train_frame.height, val_labels=val_frame.height, metrics=metrics, )El return va fuera del with start_run(...) para que cada ejecución de MLflow esté cerrada antes
de que label_curve lance la siguiente.
La llamada a set_training_mode que sigue a evaluate_heads arregla un bug real: evaluate_heads
termina con model.train(), y train() sobre el modelo entero despierta el encoder congelado.
Sin esa línea, desde la primera evaluación el tronco volvería a aplicar dropout. No lanza, no cambia
ninguna forma, y solo se nota en que el probe sale un poco peor de lo que debería.
def label_curve( cfg: HeadsConfig, fractions: list[float] | None = None, device: str | None = None) -> list[HeadsResult]: """Train the same recipe on nested subsets of the labels; one result per fraction.
The points are written back into the checkpoints of the **last** run (the one with the most labels, which is the checkpoint anybody would then evaluate or publish), so ``rukh eval encoder`` can render the "labels needed" table without being told where the other runs are. Without that write the curve would exist only in MLflow and in this return value, and the table — a ``GOAL.md`` deliverable — could never be filled. """ wanted = sorted(set(fractions if fractions is not None else cfg.curve)) results: list[HeadsResult] = [] for fraction in wanted: run = cfg.model_copy( update={ "fraction": fraction, "run_name": f"{cfg.run_name or cfg.default_name}-{int(fraction * 100)}", } ) results.append(train_heads(run, device=device)) if results: write_curve(Path(results[-1].checkpoint).parent, results) return results
def write_curve(run_dir: Path, results: Sequence[HeadsResult]) -> list[Path]: """Record the curve in every checkpoint of ``run_dir``; returns the files it touched.""" points = [result.model_dump(mode="json") for result in results] written: list[Path] = [] for path in sorted(run_dir.glob("*.pt")): attach_payload(path, **{CURVE_KEY: points}) written.append(path) log.info("label curve of %d points written into %d checkpoints", len(points), len(written)) return writtenEl sorted(set(...)) garantiza que la última tirada es la del conjunto más grande, y write_curve
mete los cuatro puntos dentro de todos sus checkpoints, para que dé igual cuál se evalúe o se
publique. La regla del docstring vale fuera de aquí: un número que solo ha existido en una línea
de registro es un número que nadie puede poner en una tabla. Si la curva viviera solo en MLflow,
publicar el modelo la dejaría atrás.
Los checkpoints, cuando hay tres clases de modelo
BEST_NAME = "best.pt"CURVE_KEY = "label_curve""""Where ``rukh.train.heads.label_curve`` writes its points and ``rukh eval encoder`` reads them.
The curve is the lesson of M3 ("how many labels does this actually take?") and a ``GOAL.md``deliverable, so it travels **inside** the checkpoint: a number that only ever existed in a logline is a number nobody can put in a table."""TIED_HEAD = "lm_head.weight""""Tied to ``tokens.weight``; a published state dict leaves it out and it is re-tied on load."""TIED_HEADS = frozenset({TIED_HEAD, "mlm_head.weight", "encoder.mlm_head.weight"})"""Every tied head in the project: the decoder's and the encoder's masked-move head.
The last name is the same head seen from a ``MultiHead``, which holds the encoder as a child."""TIED_SOURCES: dict[str, str] = { TIED_HEAD: "tokens.weight", "mlm_head.weight": "tokens.weight", "encoder.mlm_head.weight": "encoder.tokens.weight",}"""The embedding each tied head shares its storage with, when the tie is switched on."""TIED_HEAD era una cadena en M2 y aquí pasa a ser un conjunto de tres nombres: la cabeza atada del
decoder, la del encoder suelto y la del encoder visto desde un MultiHead, que le añade el prefijo
encoder. por la composición de la lección 4. Sin ese tercer nombre, cargar un checkpoint de
cabezas publicado sin su cabeza atada fallaría por un peso que falta.
def attach_payload(path: Path, **extra: Any) -> Path: """Add plain values to an existing checkpoint, rewriting it atomically.
Used for facts a run only knows once it is over — the label-count curve is the whole of it — so they do not need a second file next to the weights that can be lost or go stale. """ path = Path(path) payload = load_checkpoint(path) payload.update(extra) tmp = path.with_suffix(path.suffix + ".tmp") torch.save(payload, tmp) tmp.replace(path) return pathEscritura atómica, como en save_checkpoint de M2: un temporal y un Path.replace, así que un corte
de luz a mitad deja el checkpoint viejo intacto. Aquí importa más, porque se reescribe un fichero que
ya existe y es valioso.
def load_encoder( path: Path, map_location: str | torch.device = "cpu") -> tuple[PositionEncoder, dict[str, Any]]: """Rebuild the encoder a checkpoint describes, in eval mode, plus the whole payload.""" payload = load_checkpoint(path, map_location=map_location) model = PositionEncoder(EncoderConfig.model_validate(payload["model_cfg"])) load_state(model, payload["model_state"]) return model.eval(), payload
def build_heads(payload: Mapping[str, Any]) -> MultiHead: """A ``MultiHead`` shaped by a payload, with no weights loaded yet.""" run_cfg = payload.get("cfg") or {} encoder = PositionEncoder(EncoderConfig.model_validate(payload["model_cfg"])) pooling = run_cfg.get("pooling") or "mean" if pooling not in ("cls", "mean"): raise ValueError(f"unknown pooling {pooling!r} in the checkpoint's config") return MultiHead(encoder, HeadWeights.model_validate(run_cfg.get("weights") or {}), pooling)
def load_heads( path: Path, map_location: str | torch.device = "cpu") -> tuple[MultiHead, dict[str, Any]]: """Rebuild a fine-tuned ``MultiHead`` (encoder plus the three heads) from a checkpoint.
``rukh.train.heads`` saves the whole ``MultiHead`` state under the *encoder's* ``model_cfg``, because the heads have no shape of their own beyond ``d_model``; the pooling and the head weights come from the run's config, which travels in the same payload. """ payload = load_checkpoint(path, map_location=map_location) model = build_heads(payload) load_state(model, payload["model_state"]) return model.eval(), payloadTres funciones que reconstruyen un modelo a partir de lo que el checkpoint dice de sí mismo. El
pooling not in ("cls", "mean") parece redundante y no lo es: cfg en el payload es un diccionario
JSON que nadie ha validado al leerlo, así que aquí es donde una configuración antigua o manipulada
deja de ser un problema.
def checkpoint_kind(payload: Mapping[str, Any]) -> str: """``"encoder"`` or ``"decoder"``: what kind of model a checkpoint describes.
Read off the weights rather than off a flag nobody wrote: a ``MultiHead`` keeps the encoder as a child (``encoder.*``), a bare ``PositionEncoder`` is identified by the ``input`` field only its config has, and everything else is the decoder. """ state = payload.get("model_state") or {} if any(str(name).startswith("encoder.") for name in state): return "encoder" return "encoder" if "input" in (payload.get("model_cfg") or {}) else "decoder"
def load_any( path: Path, map_location: str | torch.device = "cpu") -> tuple[nn.Module, dict[str, Any], str]: """Rebuild whatever model a checkpoint describes: ``(model, payload, kind)``.
``rukh export`` and ``rukh publish`` take a checkpoint and have to work out what is in it; this is the one place that decides, so the two commands can never disagree. """ payload = load_checkpoint(path, map_location=map_location) kind = checkpoint_kind(payload) if kind == "decoder": model: nn.Module = MoveDecoder(DecoderConfig.model_validate(payload["model_cfg"])) elif any(str(name).startswith("encoder.") for name in payload["model_state"]): model = build_heads(payload) else: model = PositionEncoder(EncoderConfig.model_validate(payload["model_cfg"])) load_state(model, payload["model_state"]) return model.eval(), payload, kind«Read off the weights rather than off a flag nobody wrote» es la frase que hay que llevarse. Un
campo kind en el payload funcionaría para los checkpoints escritos a partir de hoy, pero los de M2
no lo tienen. Deducirlo de la estructura funciona hacia atrás y hacia delante. Y que la decisión
viva en un solo sitio impide que rukh export y rukh publish acaben discrepando sobre qué lleva
un mismo fichero.
from rukh.train.common import ( RunConfig, forever, maybe_compile, param_groups, pick_device, run_dir, skip_batches,)from rukh.train.heads import ( HeadsConfig, HeadsResult, evaluate_heads, freeze_encoder, label_curve, train_heads,)from rukh.train.loop import TrainConfig, evaluate, trainfrom rukh.train.mmm import MaskingConfig, MmmConfig, apply_masking, evaluate_mmm, train_mmmfrom rukh.train.schedule import lr_at
__all__ = [ "BEST_NAME", "TIED_HEAD", "TIED_HEADS", "TIED_SOURCES", "HeadsConfig", "HeadsResult", "MaskingConfig", "MmmConfig", "RunConfig", "TrainConfig", "apply_masking", "build_heads", "checkpoint_kind", "evaluate", "evaluate_heads", "evaluate_mmm", "forever", "freeze_encoder", "label_curve", "load_any", "load_checkpoint", "load_encoder", "load_heads", "load_model", "load_state", "lr_at", "maybe_compile", "param_groups", "pick_device", "read_manifest_sha", "read_vocab_hash", "restore", "run_dir", "save_checkpoint", "skip_batches", "step_name", "train", "train_heads", "train_mmm",]La superficie pública del paquete después del módulo: tres bucles donde había uno, y cinco cargadores de checkpoint.
Las dos configuraciones, y la única diferencia que importa
# Fine-tuning of the three heads (value, blunder, result) on the `squares` scheme: the position# itself, 69 fixed tokens. The default is `probe`: the encoder is frozen and only the three# linear heads learn, which is the run that answers whether the representation already carries# the information. `--mode last-n` and `--mode full` are the next two stages, and `--curve` runs# the whole thing on 10/25/50/100 % of the labels and writes the points into the checkpoint.## `encoder_ckpt` stays null here: masked move modeling only runs on `moves` (there is no packed# stream of board tokens in P1), so a `squares` encoder starts from random weights. That is the# baseline the pretrained `moves` run of `encoder-heads-moves.yaml` is compared against, and# pointing this file at an MMM checkpoint is refused rather than silently reinterpreted.encoder_ckpt: nullinput: squares # the labels are FENs, one row per position; see encoder-heads-moves.yamlpooling: meanmode: probelast_n: 2fraction: 1.0curve: [0.1, 0.25, 0.5, 1.0]weights: # The three tasks have different scales and different amounts of data; `result` is the noisiest # (one label per game, repeated over every position of it), so it counts for half. value: 1.0 blunder: 1.0 result: 0.5labels: positions_eval: data/evals/positions-eval.parquet out_dir: data/labels value_scale: 400.0 blunder_cp: 100 val_fraction: 0.1 seed: 42block: 200 # ignored by the squares scheme, which always uses its 69 positionsbatch_size: 256grad_accum: 1lr: 1.0e-3 # a frozen encoder with three linear heads takes a much larger rate than pretrainingmin_lr_ratio: 0.1warmup: 200max_steps: 4000weight_decay: 0.01betas: [0.9, 0.95]grad_clip: 1.0precision: bf16compile: false # three linear heads over 69 fixed positions: compiling costs more than it saveseval_every: 250eval_batches: 50ckpt_every: 1000out_dir: checkpointsseed: 42run_name: encoder-headsunique_run_name: trueworkers: 4log_every: 10configs/train/encoder-heads.yaml
Frente al preentrenamiento, lr es el doble (tres capas lineales toleran un paso mucho mayor que
quince millones de pesos) y weight_decay la décima parte (tres capas lineales con 438 093
etiquetas apenas pueden sobreajustar). Cuatro mil pasos de 256 son unas 2,3 épocas.
De la configuración del esquema de jugadas solo cambian cinco valores:
encoder_ckpt: checkpoints/encoder-mmm-20260919-093554//best.ptinput: movesconfigs/train/encoder-heads-moves.yaml
El checkpoint de masked move modeling y su esquema. La doble barra es una errata inofensiva (Path
la normaliza); lo que sí tienes que cambiar es la fecha de la carpeta por la de tu tirada
(ls -td checkpoints/encoder-mmm-*/ | head -1).
games_dir: data/uci # only this scheme reads it: the prefixes come from the P1 gamesconfigs/train/encoder-heads-moves.yaml
block: 200 # the decoder's context, so a pretrained encoder fits without reshaping its positionsbatch_size: 128 # half of `squares`: a prefix is up to 200 tokens, a board is always 69configs/train/encoder-heads-moves.yaml
El lote es la mitad porque una secuencia es hasta tres veces más larga: lo que cabe en la tarjeta es aproximadamente lote × longitud.
compile: false # variable-length prefixes recompile on every new shape; not worth it hereconfigs/train/encoder-heads-moves.yaml
Los tests
def position( game_id: int, ply: int, cp: int | None = 0, mate: int | None = None, result: str = "1-0",) -> dict[str, object]: """One row of ``positions-eval.parquet``; the side to move follows the ply, as in a game.""" return { # A ply-N position is the one *after* the Nth half-move, so White is to move on even # plies: the move that led to an even-ply position was Black's. "fen": WHITE_TO_MOVE if ply % 2 == 0 else BLACK_TO_MOVE, "game_id": game_id, "ply": ply, "last_move": "e2e4", "result": result, "phase": "middlegame", "cp": cp, "mate": mate, "n_seen": 1, "best_move": "e2e4", "depth": 20, }El comentario es la regla de paridad de la que depende todo el fichero: en plies pares mueven las blancas, así que la jugada que llevó a una posición de ply par fue de las negras.
def toy_frame() -> pl.DataFrame: """Two games whose evaluations swing by the same 300 cp, once for each side.""" return rows_to_frame( [ # Black moved into ply 2 and handed White 300 centipawns: a blunder. position(10, 1, cp=0), position(10, 2, cp=300), # White moved into ply 3 and gained the same 300: the very same swing, not a blunder. position(11, 2, cp=0), position(11, 3, cp=300), ] )
def test_a_blunder_is_measured_from_the_side_that_moved() -> None: frame = build_labels(LabelsConfig(), toy_frame()).sort(["game_id", "ply"]) by_key = {(int(r["game_id"]), int(r["ply"])): r for r in frame.to_dicts()}
black_blundered = by_key[(10, 2)] assert black_blundered["loss_cp"] == 300 assert black_blundered["blunder"] == 1
white_gained = by_key[(11, 3)] assert white_gained["loss_cp"] == -300 # the same swing, the other side to move assert white_gained["blunder"] == 0El test que justifica la suma. Las dos partidas de juguete tienen el mismo cambio de evaluación, trescientos centipeones a favor de las blancas, y la etiqueta sale opuesta, porque en una lo provocó el bando que movía y en la otra lo consiguió. Con el signo mal, una de las dos filas estaría mal etiquetada. Dos casos, uno de cada lado, valen más que veinte del mismo.
def test_a_position_without_its_predecessor_has_no_blunder_label() -> None: frame = build_labels(LabelsConfig(), toy_frame()) first = frame.filter((pl.col("game_id") == 10) & (pl.col("ply") == 1)).to_dicts()[0] assert first["blunder"] is None and first["loss_cp"] is None assert frame.filter(pl.col("blunder").is_null()).height == 2 # one per game # A null is not a zero: a position we cannot judge must never train the head as "fine". assert frame.drop_nulls("blunder").height == 2
def test_the_blunder_threshold_is_configurable() -> None: rows = rows_to_frame([position(1, 2, cp=0), position(1, 3, cp=-120)]) strict = build_labels(LabelsConfig(blunder_cp=100), rows) lenient = build_labels(LabelsConfig(blunder_cp=200), rows) assert strict.filter(pl.col("ply") == 3)["blunder"].item() == 1 assert lenient.filter(pl.col("ply") == 3)["blunder"].item() == 0«A null is not a zero», como comentario y como aserción: es la propiedad más fácil de romper (basta
un fill_null(0) una línea antes de donde toca) y la que más daño hace.
def test_the_split_is_by_game_and_never_shares_a_game_id() -> None: rows = rows_to_frame([position(game, ply) for game in range(200) for ply in (2, 3, 4)]) frame = build_labels(LabelsConfig(val_fraction=0.25), rows) train = set(frame.filter(pl.col("split") == "train")["game_id"].to_list()) val = set(frame.filter(pl.col("split") == "val")["game_id"].to_list()) assert train and val assert not train & val # the whole point: no game is on both sides assert len(train) + len(val) == 200 assert 0.15 < len(val) / 200 < 0.35 # Every position of a game goes with it. for game_id, split in frame.select("game_id", "split").unique().group_by("game_id"): assert len(split) == 1, game_id
def test_the_split_is_stable_and_depends_on_the_seed() -> None: assert game_split(17, 0.1, 42) == game_split(17, 0.1, 42) ids = range(500) one = [game_split(i, 0.2, 1) for i in ids] two = [game_split(i, 0.2, 2) for i in ids] assert one != two assert all(value in ("train", "val") for value in one)assert not train & val es el test de la fuga. La banda de 0.15 a 0.35 para una fracción del
25 % es ancha a propósito: con 200 partidas la proporción oscila unos tres puntos, y una banda
estrecha daría un test que falla de vez en cuando.
def test_the_label_curve_uses_nested_subsets() -> None: rows = rows_to_frame([position(game, ply) for game in range(100) for ply in (2, 3)]) frame = build_labels(LabelsConfig(), rows) subsets = label_subsets(frame, [0.1, 0.25, 0.5, 1.0]) keys = { fraction: {(r["game_id"], r["ply"]) for r in subset.to_dicts()} for fraction, subset in subsets.items() } assert keys[0.1] < keys[0.25] < keys[0.5] < keys[1.0] assert len(keys[1.0]) == frame.height for fraction in (0.1, 0.25, 0.5): assert abs(len(keys[fraction]) / frame.height - fraction) < 0.01 assert label_subsets(frame, [0.5, 0.5]).keys() == {0.5} with pytest.raises(ValueError, match="every fraction"): label_subsets(frame, [0.0])Entre conjuntos de Python, < es «subconjunto propio», así que una línea comprueba el anidamiento
entero.
@pytest.mark.parametrize("mode", ["probe", "last-n", "full"])def test_the_mode_decides_what_is_allowed_to_move(mode: str) -> None: model = MultiHead(toy_encoder()) trainable = freeze_encoder(model, mode, last_n=1) # type: ignore[arg-type] total = sum(1 for _ in model.encoder.parameters()) one_block = sum(1 for _ in model.encoder.blocks[-1].parameters()) final_norm = sum(1 for _ in model.encoder.ln_f.parameters()) expected = {"probe": 0, "last-n": one_block + final_norm, "full": total}[mode] assert trainable == expected assert all(param.requires_grad for param in model.head_parameters()) if mode == "last-n": # the last block and the final norm, nothing earlier assert all(not p.requires_grad for p in model.encoder.blocks[0].parameters()) assert all(p.requires_grad for p in model.encoder.blocks[-1].parameters()) assert all(p.requires_grad for p in model.encoder.ln_f.parameters())
def test_a_frozen_encoder_is_kept_in_eval_mode() -> None: model = MultiHead(toy_encoder()) set_training_mode(model, "probe") assert model.training and not model.encoder.training # no dropout on a frozen feature set_training_mode(model, "full") assert model.encoder.training # Under last-n the frozen prefix is frozen for dropout too: only what is learning is # regularised, and the early blocks give the same vector for the same position every epoch. set_training_mode(model, "last-n", last_n=1) assert model.training and not model.encoder.blocks[0].training assert model.encoder.blocks[-1].training and model.encoder.ln_f.trainingEl primero calcula los tensores entrenables esperados contándolos en vez de escribir un número, así que sigue siendo correcto si el bloque gana un parámetro. El segundo no mira ningún peso y, aun así, decide si el probe mide lo que dice medir.
La orden
@train_app.command("heads")def train_heads_cmd( config: Annotated[ Path, typer.Option( "--config", exists=True, dir_okay=False, readable=True, help="Training YAML config." ), ], mode: Annotated[ str | None, typer.Option("--mode", help="Override the mode: probe, last-n or full.") ] = None, fraction: Annotated[ float | None, typer.Option("--fraction", help="Train on this fraction of the labels.") ] = None, curve: Annotated[ bool, typer.Option("--curve", help="Run the whole label-count curve instead of one run.") ] = False, max_steps: Annotated[ int | None, typer.Option("--max-steps", help="Override max_steps from the config.") ] = None, as_json: Annotated[ bool, typer.Option("--json", help="Print the results as JSON only.") ] = False,) -> None: """Fine-tune the value, blunder and result heads over the pretrained encoder.""" import json
from rukh.config import load_yaml from rukh.train import HeadsConfig, label_curve, train_heads
cfg = load_yaml(config, HeadsConfig) if mode is not None: if mode not in ("probe", "last-n", "full"): typer.echo("error: --mode must be probe, last-n or full", err=True) raise typer.Exit(code=2) cfg = cfg.model_copy(update={"mode": mode}) if fraction is not None: cfg = cfg.model_copy(update={"fraction": fraction}) if max_steps is not None: cfg = cfg.model_copy(update={"max_steps": max_steps}) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") try: results = label_curve(cfg) if curve else [train_heads(cfg)] except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc if as_json: typer.echo(json.dumps([result.model_dump() for result in results], indent=2)) return typer.echo(f"mode: {cfg.mode}") for result in results: typer.echo(f" {result.fraction:>5.0%} of the labels ({result.train_labels} rows)") for key, value in result.metrics.items(): typer.echo(f" {key:<20} {value:.4f}") typer.echo(f" checkpoint {result.checkpoint}")La curva y la tirada suelta devuelven la misma forma, una lista de resultados, así que el código que
imprime es uno solo. Un --mode inválido sale con código 2, el que typer usa para un mal uso de la
orden, y un error de datos con 1: la distinción importa en un script que encadena órdenes.
// Ejercicio 01Comprueba el signo con una partida de verdad
tests/unit/helpers_labels.py construye una partida real con un error garrafal: 1.e4 e5 2.Dh5 Cc6
3.Dxf7+?? Rxf7. Escribe las filas de esa partida con evaluaciones plausibles —digamos 20, 10, 30,
15, −850, −900— y pásalas por build_labels. ¿Qué ply lleva la etiqueta de error, y de quién es?
¿Qué pasaría con loss_cp si la línea fuera una resta en vez de una suma?
// SoluciónVer la solución
El error lo comete el blanco en la jugada 5 de la lista (3.Dxf7+, ply 5), y la etiqueta cae en
la fila del ply 5, que es la posición resultante. Antes de esa jugada, el ply 4 valía +15 para
las blancas, que eran las que movían: before_best = +15. Después, con las negras a mover, la
posición vale −850 para las blancas, o sea +850 desde el lado negro, así que score_mover de la
fila del ply 5 es +850. loss_cp = 15 + 850 = 865, muy por encima de los 100 del umbral:
blunder = 1.
Con una resta, loss_cp saldría 15 - 850 = -835, un número negativo en la jugada que tira una
dama, y la etiqueta sería 0. Y lo peor: la partida entera saldría etiquetada al revés, con las
jugadas buenas marcadas como errores. Ninguna comprobación de forma lo detecta, la tasa base seguiría
siendo del 3-4 % y el modelo aprendería a detectar lo contrario de lo que dice la columna.
Qué has aprendido
Construir un conjunto supervisado es sobre todo cuidar el signo de una etiqueta que compara dos posiciones, no convertir una ausencia en un cero y repartir con una función pura de un identificador que agrupe lo que no es independiente. Y «congelar» en PyTorch toca el gradiente, el dropout y el decaimiento de pesos, y hay que escribir los tres. Todo vale igual para afinar un clasificador de texto. La curva, sobre subconjuntos anidados, viaja dentro del checkpoint.
Para comprobarlo: uv run pytest tests/unit/test_labels.py tests/unit/test_heads.py -q pasa los
treinta tests, y uv run rukh train heads --config configs/train/encoder-heads-moves.yaml --mode probe imprime las siete métricas de validación. Las cifras de las tiradas reales están en la
lección 11.
Lo siguiente es la evaluación, que es el fichero más grande del módulo y el que decide si todo esto ha servido de algo.