rukh · lab

// M2 · lección 10

Los labs del decoder

Los cuatro scripts de labs/m2/ enteros: contar los parámetros contra la fórmula cerrada, demostrar la causalidad con una diferencia de cero exacto, sacar los 96 mapas de atención de una partida y exportar la repetición del entrenamiento checkpoint a checkpoint.

  • tiempo de trabajo125 min
  • además, ejecución sin supervisión+ 10 min de GPU y red
  • nivel base
  • actualizado el23 de septiembre de 2026

Qué vas a construir

Cuatro scripts, y ninguno entrena nada. Dos son calculadoras que comprueban que entiendes tu propio modelo, el único punto del módulo en que una cifra del curso se verifica sin ejecutar el modelo; los otros dos producen los JSON de las dos islas de la lección 7.

Todo se ejecuta desde la raíz de rukh. params.py y causal_mask.py solo necesitan el paquete; attention_export.py necesita un checkpoint entrenado, y replay_export.py, una ejecución de MLflow con sus step-*.pt.

// Antes de empezarQué cuesta cada lab, y cuál puedes saltarte

LabRutaRelojDejaAtajo
Lab 1Contar los parámetros del decoder contra la fórmula cerradaimprescindiblesegundosel desglose por bloque, que cuadra con el totalno hay
Lab 2Dibujar la máscara causal y demostrar que el futuro no entraimprescindiblesegundosuna diferencia de cero exacto entre la salida completa y la truncadano hay
Lab 3Sacar los 96 mapas de atención de una partida cortaimprescindiblesegundos, con un checkpoint entrenadoartifacts/web/attention.json, que dibuja la isla de la lección 7no hay
Lab 4Exportar la repetición del entrenamiento checkpoint a checkpoint desde MLflowimprescindiblesegundos; minutos con --with-legality, que carga cada step-*.ptartifacts/web/training-replay.jsonno hay
imprescindible
Segundos o pocos minutos, y el lab siguiente da por hecho que lo corriste.

Lab 1 · Contar los parámetros, y comprobar que la cuenta cuadra

// Viene delección 2 · El decoder a mano: config, bloques y el modelo

Escribir el modelo es la mitad; la otra es saber qué has escrito. Este script suma los parámetros grupo a grupo y los compara con una fórmula escrita a mano.

labs/m2/params.py
"""Parameter count of a MoveDecoder, block by block, against the closed-form formula."""
from rukh.models import MoveDecoder, preset
def formula(cfg) -> int:
"""What the architecture says it should be, computed by hand."""
d, ff, v = cfg.d_model, cfg.ff, cfg.vocab_size
per_block = (
2 * d # ln1
+ (d * 3 * d + 3 * d) # qkv
+ (d * d + d) # attn out projection
+ 2 * d # ln2
+ (d * ff + ff) # mlp in
+ (ff * d + d) # mlp out
)
total = v * d + per_block * cfg.n_layer + 2 * d # tokens + blocks + final ln
if cfg.pos == "learned":
total += cfg.block * d
if not cfg.tie_embeddings:
total += v * d
return total

labs/m2/params.pylíneas 1-22 · p2

Hacer esta cuenta a mano una vez vale más que leer tres artículos. Por bloque: 2·d de cada LayerNorm, d·3d + 3d de la qkv, d·d + d de la proyección, d·ff + ff y ff·d + d del MLP. Fuera: la tabla de tokens, el LayerNorm final y, según la configuración, las posiciones y una cabeza sin atar. Esos dos condicionales leen la configuración, así que la fórmula comprueba algo en vez de estar clavada a small.

labs/m2/params.py
for name in ("tiny", "small", "medium"):
cfg = preset(name)
model = MoveDecoder(cfg)
counted = sum(p.numel() for p in model.parameters())
groups = {
"tokens": model.tokens.weight.numel(),
"positions": model.positions.weight.numel() if model.positions is not None else 0,
"blocks": sum(p.numel() for p in model.blocks.parameters()),
"ln_f": sum(p.numel() for p in model.ln_f.parameters()),
"lm_head (tied)": 0 if cfg.tie_embeddings else model.lm_head.weight.numel(),
}
print(f"== {name}: {cfg.n_layer} layers, d={cfg.d_model}, {cfg.n_head} heads ==")
for key, value in groups.items():
print(f" {key:<16} {value:>12,}")
print(f" {'total':<16} {counted:>12,} formula {formula(cfg):>12,}")
print(f" {'non-embedding':<16} {model.num_params():>12,}")
assert counted == formula(cfg), f"{name}: the formula does not match the model"

labs/m2/params.pylíneas 25-41 · p2

El assert del final es el lab entero: si el modelo y la fórmula no coinciden, el script para.

Terminal
uv run python labs/m2/params.py

Salida real de la ejecución de referencia (RTX 5090):

== tiny: 6 layers, d=256, 4 heads ==
tokens 519,680
positions 51,200
blocks 4,738,560
ln_f 512
lm_head (tied) 0
total 5,309,952 formula 5,309,952
non-embedding 5,258,752
== small: 12 layers, d=512, 8 heads ==
tokens 1,039,360
positions 102,400
blocks 37,828,608
ln_f 1,024
lm_head (tied) 0
total 38,971,392 formula 38,971,392
non-embedding 38,868,992
== medium: 16 layers, d=768, 12 heads ==
tokens 1,559,040
positions 153,600
blocks 113,405,952
ln_f 1,536
lm_head (tied) 0
total 115,120,128 formula 115,120,128
non-embedding 114,966,528

Para small, antes de ejecutarlo: embeddings 2 030 × 512 = 1 039 360; posiciones 200 × 512 = 102 400; por bloque 1 024 + 787 968 + 262 656 + 1 024 + 1 050 624 + 1 049 088 = 3 152 384, por 12 capas 37 828 608; LayerNorm final, 1 024. Total 38 971 392.

Lo que enseña el reparto: el 97 % de los parámetros está en los bloques, y dentro de cada bloque dos tercios son del MLP. La atención, que es la idea, es la minoría de los pesos.

// Ejercicio 01¿Dónde meterías el próximo millón de parámetros?

Tienes un millón de parámetros extra para small. Con la fórmula, calcula cuánto cuesta cada opción y cuál cabe: (a) una capa más, (b) d_ff de 2 048 a 2 304, (c) d_model de 512 a 528 (ajustando n_head). ¿Cuál elegirías?

// SoluciónVer la solución

(a) Una capa son 3 152 384: se pasa tres veces. (b) Subir d_ff en 256 añade 262 912 por bloque, 3 154 944 en total: tampoco. (c) d_model a 528 hace crecer el término d² un 6,3 % y sale en torno a 41,4 millones. No cabe ninguna.

En un Transformer el presupuesto no es continuo: los parámetros van en cuantos de una capa entera o de un incremento de anchura por n_layer, y por eso los modelos vienen en tallas. Y si hay que elegir, las leyes de escala (Kaplan, Chinchilla) dicen que profundidad y anchura crezcan juntas; nuestros tres presets siguen esa diagonal.

// Ejercicio 02Rompe el atado y mira quién se entera

En MoveDecoder.__init__, cambia self.lm_head.weight = self.tokens.weight por self.lm_head.weight = nn.Parameter(self.tokens.weight.detach().clone()): dos tablas iguales en vez de una compartida. Ejecuta uv run pytest -m unit -q tests/unit/test_decoder.py y uv run python labs/m2/params.py. ¿Qué comprobación se entera, y por qué? Vuelve a la línea original al terminar.

Un .clone() a secas no sirve para el experimento: PyTorch no deja asignar un tensor normal a un peso (TypeError: cannot assign 'torch.FloatTensor' as parameter 'weight'), el modelo ni se construye y fallan todos los tests a la vez. Por eso el nn.Parameter y el .detach().

// SoluciónVer la solución

Se enteran los dos, cada uno por su lado. En los tests falla test_embeddings_are_tied_and_can_be_untied y nada más (1 failed, 14 passed): el is ve dos tensores. test_preset_sizes pasa, porque su horquilla de 38 a 45 millones admite el millón de más.

params.py para en el primer preset: tiny cuenta 5,829,632 y la fórmula 5,309,952, y sale AssertionError: tiny: the formula does not match the model. La fórmula lee cfg.tie_embeddings, que sigue en True, y cuenta la tabla una vez; el modelo tiene dos de 2 030 × d_model (en small, 40 010 752 frente a 38 971 392). El bug no rompe ni la construcción ni el entrenamiento: produce otro modelo, con un millón de parámetros más que se entrenan por separado. Lo cazan dos comprobaciones independientes, una que mira los tensores y otra que compara el modelo con su configuración.

Lab 2 · Dibujar la máscara causal y demostrar que funciona

// Viene delección 1 · El decoder: escribirlo a mano y lección 2 · El decoder a mano: config, bloques y el modelo

Este script dibuja la máscara en la terminal y hace el experimento que importa: cambiar un token del futuro y comprobar que los logits del pasado no se mueven ni un bit.

labs/m2/causal_mask.py
"""Draw the causal mask and prove causality empirically on a real MoveDecoder."""
import torch
from rukh.models import DecoderConfig, MoveDecoder
T = 8
mask = torch.ones(T, T, dtype=torch.bool).tril()
print("Causal mask (row = query, column = key):")
print(" " + " ".join(f"{j:>2}" for j in range(T)))
for i in range(T):
cells = " ".join(" #" if mask[i, j] else " ." for j in range(T))
print(f" q{i:<3} {cells}")
print(f"\nVisible pairs: {int(mask.sum())} of {T * T} ({100 * mask.float().mean():.1f} %)\n")

labs/m2/causal_mask.pylíneas 1-14 · p2

tril() es la máscara causal entera, lo que is_causal=True construye por dentro. Dibujarla con # y . es la diferencia entre creerse una matriz triangular y haberla visto.

labs/m2/causal_mask.py
torch.manual_seed(0)
model = MoveDecoder(DecoderConfig(n_layer=2, n_head=2, d_model=32, vocab_size=64, block=T)).eval()
idx = torch.randint(1, 64, (1, T))
with torch.no_grad():
base, _ = model(idx)
for t in range(T - 1):
changed = idx.clone()
# Replace every token after t with a different one.
changed[:, t + 1 :] = (changed[:, t + 1 :] + 7) % 63 + 1
other, _ = model(changed)
past = (base[:, : t + 1] - other[:, : t + 1]).abs().max().item()
future = (base[:, t + 1 :] - other[:, t + 1 :]).abs().max().item()
print(f" cut after position {t}: max |delta| past {past:.3e} future {future:.3e}")
assert past == 0.0, "the past moved: the mask is not causal"

labs/m2/causal_mask.pylíneas 16-30 · p2

Para cada corte se cambian todos los tokens posteriores y se mide la diferencia máxima en los logits del pasado y del futuro.

Terminal
uv run python labs/m2/causal_mask.py

Salida real de la ejecución de referencia (RTX 5090):

Causal mask (row = query, column = key):
0 1 2 3 4 5 6 7
q0 # . . . . . . .
q1 # # . . . . . .
q2 # # # . . . . .
q3 # # # # . . . .
q4 # # # # # . . .
q5 # # # # # # . .
q6 # # # # # # # .
q7 # # # # # # # #
Visible pairs: 36 of 64 (56.2 %)
cut after position 0: max |delta| past 0.000e+00 future 5.934e-01
cut after position 1: max |delta| past 0.000e+00 future 5.969e-01
cut after position 2: max |delta| past 0.000e+00 future 5.884e-01
cut after position 3: max |delta| past 0.000e+00 future 5.925e-01
cut after position 4: max |delta| past 0.000e+00 future 5.863e-01
cut after position 5: max |delta| past 0.000e+00 future 4.818e-01
cut after position 6: max |delta| past 0.000e+00 future 3.524e-01

La diferencia en el pasado es 0,0 exacto, no 1e-7, porque no es una cuestión de precisión sino de grafo: los tokens futuros ni entran en el cálculo. Un 1e-7 ahí sería una fuga sutil, típicamente una normalización a lo largo del eje de tiempo. Y las 36 parejas visibles de 64 son T(T+1)/2 sobre T²: con 200 tokens, el 50,25 %. La máscara ahorra la mitad del cómputo de la atención.

// Ejercicio 03Rompe la causalidad a propósito

Cambia is_causal=True por is_causal=False en CausalSelfAttention y vuelve a ejecutar el script. Después entrena tiny 200 pasos con y sin máscara. ¿Cuál baja más rápido? ¿Cuál es mejor? ¿Qué medirías para no dejarte engañar?

Es una edición de src/rukh/models/decoder.py, no una opción: deshazla al terminar (git checkout -- src/rukh/models/), porque un checkpoint contaminado no se distingue mirando los pesos.

// SoluciónVer la solución

Sin máscara, el assert salta en la primera iteración. Entrenando, la curva sin máscara baja muchísimo más rápido, porque predecir t+1 se resuelve leyendo t+1: la pérdida tiende a cero y el top-1 al 100 %.

El modelo es basura y la curva no lo enseña. Lo delata generar, donde el futuro no existe: la legalidad sin máscara se hunde y rukh play da jugadas sin sentido desde 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. 2. Vale para cualquier proyecto de ML: una pérdida sospechosamente buena es casi siempre una fuga, y la defensa es una métrica calculada como se va a usar el modelo.

Lab 3 · Sacar los 96 mapas de atención

// Viene delección 1 · El decoder: escribirlo a mano y lección 7 · Exportar a ONNX

F.scaled_dot_product_attention no devuelve los pesos, así que hay que recalcularlos: un hook en la qkv de cada bloque, partir en Q, K y V, aplicar RoPE si toca y hacer el softmax a mano con la máscara.

labs/m2/attention_export.py
"""Export the attention weights of one short game to artifacts/web/attention.json."""
import json
from datetime import UTC, datetime
from pathlib import Path
import chess
import torch
from torch.nn import functional as F
from rukh.models.decoder import apply_rope
from rukh.tokenize.uci_vocab import UciTokenizer
from rukh.train import load_model
CKPT = Path("checkpoints/small/best.pt")
OUT = Path("artifacts/web/attention.json")
# Legal's mate: short, famous and every move is easy to follow in the heat map.
MOVES = "e2e4 e7e5 g1f3 b8c6 f1c4 d7d6 b1c3 c8g4 f3e5 g4d1 c4f7 e8e7 c3d5".split()
model, _ = load_model(CKPT)
tok = UciTokenizer()
board = chess.Board()
for uci in MOVES: # fail loudly if the line is not legal
board.push(chess.Move.from_uci(uci))
ids = [tok.bos_id, tok.vocab["<w1800>"], tok.vocab["<b1800>"]]
ids += [tok.vocab[uci] for uci in MOVES]
labels = ["<bos>", "<w1800>", "<b1800>", *MOVES]
idx = torch.tensor([ids], dtype=torch.long)

labs/m2/attention_export.pylíneas 1-29 · p2

La partida es el mate de Légal: trece jugadas fáciles de seguir en un mapa de calor. Se empuja en un tablero antes de nada para fallar ruidosamente si la línea no es legal: una transcripción mala daría un mapa precioso de una partida que no existe. Los labels incluyen los tres tokens de control.

CKPT nombra checkpoints/small/best.pt, que ningún entrenamiento escribe (rukh train deja checkpoints/small-<fecha>/). Antes de ejecutarlo, cambia esa línea por tu carpeta; la más reciente sale con:

Terminal window
ls -td checkpoints/small-*/ | head -1
Terminal window
ls checkpoints/small-* | sort LastWriteTime | select -Last 1
labs/m2/attention_export.py
captured: list[torch.Tensor] = []
def hook(module, args, output): # noqa: ARG001 - torch hook signature
captured.append(output.detach())
handles = [block.attn.qkv.register_forward_hook(hook) for block in model.blocks]
with torch.no_grad():
model(idx)
for handle in handles:
handle.remove()

labs/m2/attention_export.pylíneas 31-42 · p2

El hook va en block.attn.qkv y no en block.attn, para ver Q, K y V antes de que la atención los consuma. Y quitarlos no es opcional: un hook olvidado sigue acumulando tensores en cada pasada, una fuga de memoria con forma de lista que crece.

labs/m2/attention_export.py
cfg = model.cfg
weights = []
mask = torch.ones(len(ids), len(ids), dtype=torch.bool).tril()
for _layer, qkv in enumerate(captured):
q, k, _ = qkv.split(cfg.d_model, dim=2)
shape = (1, len(ids), cfg.n_head, cfg.head_dim)
q = q.view(shape).transpose(1, 2)
k = k.view(shape).transpose(1, 2)
if cfg.pos == "rope":
q = apply_rope(q, model.rope_cos, model.rope_sin)
k = apply_rope(k, model.rope_cos, model.rope_sin)
scores = (q @ k.transpose(-2, -1)) / (cfg.head_dim**0.5)
probs = F.softmax(scores.masked_fill(~mask, float("-inf")), dim=-1)[0]
weights.append([[[round(v, 4) for v in row] for row in head] for head in probs.tolist()])

labs/m2/attention_export.pylíneas 44-57 · p2

Es CausalSelfAttention.forward sin el paso final. Redondear a cuatro decimales deja el JSON en 211 KB en vez de un megabyte: la isla dibuja color, no necesita el séptimo decimal.

labs/m2/attention_export.py
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps(
{
"schema": "rukh-attention/1",
"game": {"moves": labels},
"layers": cfg.n_layer,
"heads": cfg.n_head,
"weights": weights,
"meta": {
"model": "rukh-small",
"checkpoint": str(CKPT),
"generated": datetime.now(UTC).isoformat(timespec="seconds"),
},
}
),
encoding="utf-8",
)
print(f"wrote {OUT} ({cfg.n_layer} layers x {cfg.n_head} heads x {len(ids)}^2)")

labs/m2/attention_export.pylíneas 59-77 · p2

La versión del esquema dice qué forma tiene el JSON, y meta.generated distingue una exportación real de un marcador vacío.

Terminal
uv run python labs/m2/attention_export.py

Salida real de la ejecución de referencia (RTX 5090):

wrote artifacts\web\attention.json (12 layers x 8 heads x 16^2)

16 × 16 celdas por cabeza (trece jugadas y tres tokens de control), 96 matrices: el fichero que dibuja AttentionMap.

Lab 4 · La repetición del entrenamiento

// Viene delección 3 · La receta de entrenamiento y lección 7 · Exportar a ONNX

El más largo, y el único con opciones, porque sus dos modos difieren en tres órdenes de magnitud de coste.

labs/m2/replay_export.py
"""Export the training curve of a run to ``artifacts/web/training-replay.json``.
Lab 3 of module M2. Reads the metrics MLflow stored during ``rukh train`` and writes **one
entry per checkpoint**: the slider of the ``TrainingReplay`` island walks the ``step-*.pt`` files
the run left behind, not the (much denser) MLflow logging steps, because every other number the
island can show — legality, Elo — only exists for a step whose weights are still on disk.
Only ``step`` is guaranteed in an entry. ``train_loss``, ``val_loss`` and ``val_top1`` are copied
from MLflow when that exact step was logged (with the shipped configs it always is: ``log_every``
and ``eval_every`` both divide ``ckpt_every``), ``legality`` needs ``--with-legality`` and ``elo``
needs ``--with-elo``.
``legality`` is the argmax rate of ``docs/spec/02`` (D-026): the single most likely token, no
temperature, no top-k and no mask. It is the bar ``GOAL.md`` sets at 99 %, and the one that means
something when it is plotted against the training step.
Usage (from the repository root):
uv run python labs/m2/replay_export.py --run-name small-20260919-013000
uv run python labs/m2/replay_export.py --run-id <mlflow run id> --with-legality
"""

labs/m2/replay_export.pylíneas 1-21 · p2

El docstring lo llama «Lab 3 of module M2», pero en este curso es el Lab 4: el número del docstring está mal y el bloque lo copia tal cual está en la etiqueta p2.

Una entrada por checkpoint, no por paso de MLflow: la legalidad o el Elo solo existen para pasos cuyos pesos siguen en disco. Solo step está garantizado; las métricas se copian si ese paso se registró, cosa que con las configuraciones del repositorio siempre ocurre.

labs/m2/replay_export.py
from __future__ import annotations
import argparse
import json
import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import mlflow
from rukh import paths
from rukh.tracking import tracking_uri
log = logging.getLogger("replay_export")
SCHEMA = "rukh-training-replay/1"
CKPT_GLOB = "step-*.pt"
"""What ``rukh train`` writes every ``ckpt_every`` steps (plus the last one)."""
METRICS = {"train_loss": "train/loss", "val_loss": "val/loss", "val_top1": "val/top1"}
ELO_GAMES = 10
"""Games per rung when ``--with-elo`` is given: enough for a shape, far too few for a number."""

labs/m2/replay_export.pylíneas 23-46 · p2

labs/m2/replay_export.py
def find_run(run_name: str | None, run_id: str | None) -> mlflow.entities.Run:
"""Return the requested run, or the most recent one when nothing is given."""
client = mlflow.tracking.MlflowClient(tracking_uri=tracking_uri(create=False))
if run_id:
return client.get_run(run_id)
experiment = client.get_experiment_by_name("rukh")
if experiment is None:
raise SystemExit("no 'rukh' experiment: run `rukh train` first")
filter_string = f"attributes.run_name = '{run_name}'" if run_name else ""
runs = client.search_runs(
[experiment.experiment_id],
filter_string=filter_string,
order_by=["attributes.start_time DESC"],
max_results=1,
)
if not runs:
raise SystemExit(f"no run matching {run_name or '<latest>'}")
return runs[0]

labs/m2/replay_export.pylíneas 49-66 · p2

Por id, por nombre o la más reciente, y con SystemExit y un mensaje en vez de una traza: el usuario necesita saber qué hacer, no dónde petó.

labs/m2/replay_export.py
def history(client: mlflow.tracking.MlflowClient, run_id: str, key: str) -> dict[int, float]:
"""Metric history as ``{step: value}`` (MLflow returns one record per logged point)."""
try:
return {m.step: m.value for m in client.get_metric_history(run_id, key)}
except mlflow.exceptions.MlflowException:
return {}
def run_checkpoints(directory: Path) -> dict[int, Path]:
"""``{step: path}`` of every ``step-*.pt`` in ``directory``, ordered by step."""
found: dict[int, Path] = {}
for path in Path(directory).glob(CKPT_GLOB):
try:
step = int(path.stem.split("-", 1)[1])
except (IndexError, ValueError):
log.debug("ignoring %s: not a step checkpoint", path)
continue
found[step] = path
return dict(sorted(found.items()))

labs/m2/replay_export.pylíneas 69-87 · p2

Una métrica que nunca se registró no es un error, es una serie vacía. Y run_checkpoints ignora lo que no encaja, como best.pt.

labs/m2/replay_export.py
def replay_steps(metric_steps: Sequence[int], checkpoints: dict[int, Path]) -> list[int]:
"""The steps the island scrubs through: the logged steps that kept a checkpoint.
The last logged step is always kept even when its checkpoint is gone (a run that was still
training when this ran, or whose weights were pruned after publication): it is the model the
rest of the table talks about, so leaving it out would be worse than leaving it thin.
"""
chosen = {step for step in metric_steps if step in checkpoints}
if metric_steps:
chosen.add(max(metric_steps))
return sorted(chosen)
def checkpoint_dir(run: mlflow.entities.Run, override: str | None) -> Path:
"""Where the run wrote its checkpoints: ``--checkpoints``, or ``out_dir/<run name>``."""
if override:
return Path(override)
out_dir = str(run.data.params.get("out_dir", "checkpoints"))
return paths.resolve(out_dir) / str(run.info.run_name)

labs/m2/replay_export.pylíneas 90-108 · p2

El último paso registrado se conserva siempre, aunque su checkpoint ya no esté: es el modelo del que habla el resto de la tabla.

labs/m2/replay_export.py
def elo_of(model: Any, tok: Any, cfg: Any, games: int) -> float | None:
"""Estimated Elo from a handful of games per rung, or None when Stockfish is not around."""
from rukh.engine import EngineNotFound
from rukh.eval.elo import estimate, play_rungs
try:
records = play_rungs(
model,
tok,
cfg.elo_rungs,
games,
cfg.sampling(),
move_time=cfg.elo_move_time,
max_plies=cfg.elo_max_plies,
)
except EngineNotFound as exc:
log.warning("Elo skipped: %s", exc)
return None
if not records:
return None
return estimate(records, samples=cfg.bootstrap, seed=cfg.seed).elo

labs/m2/replay_export.pylíneas 111-131 · p2

labs/m2/replay_export.py
def evaluate_checkpoints(
steps: Sequence[int],
checkpoints: dict[int, Path],
positions: int,
with_elo: bool,
elo_games: int,
) -> dict[int, dict[str, float]]:
"""Measure the legality (and optionally the Elo) of every checkpoint in ``steps``."""
from rukh.eval.legality import legality, sample_positions
from rukh.eval.suite import EvalConfig
from rukh.tokenize.uci_vocab import UciTokenizer
from rukh.train import load_model, pick_device
cfg = EvalConfig(legality_positions=positions, elo_games=elo_games)
games = paths.resolve(cfg.games)
if not games.is_file():
raise SystemExit(f"validation games not found at {games}: --with-legality needs the P1 cut")
tok = UciTokenizer()
prefixes = sample_positions(
games, positions, tok, seed=cfg.seed, pool=cfg.position_pool, block=cfg.block
)
where = cfg.device or pick_device()
measured: dict[int, dict[str, float]] = {}
for step in steps:
ckpt = checkpoints.get(step)
if ckpt is None:
log.warning("step %d has no checkpoint: it keeps its MLflow metrics only", step)
continue
model, _payload = load_model(ckpt, map_location=where)
model = model.to(where).eval()
# `mode="argmax"` on purpose: that is the definition the 99 % bar is written against.
result = legality(model, tok, prefixes, cfg.sampling(), mode="argmax")
values: dict[str, float] = {"legality": round(result.rate, 4)}
line = f" step {step:>6} legality {result.rate:.3f}"
if with_elo:
elo = elo_of(model, tok, cfg, elo_games)
if elo is not None:
values["elo"] = round(elo, 1)
line += f" elo {elo:.0f}"
measured[step] = values
print(line)
return measured

labs/m2/replay_export.pylíneas 134-175 · p2

La legalidad se mide en modo argmax, la definición del listón. Las posiciones se muestrean una sola vez, fuera del bucle, para que la serie sea comparable consigo misma. Y el import de rukh.eval va dentro de la función: sin --with-legality el script no carga torch.

labs/m2/replay_export.py
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--run-name", default=None, help="MLflow run name (default: latest run)")
parser.add_argument("--run-id", default=None, help="MLflow run id, wins over --run-name")
parser.add_argument(
"--checkpoints",
default=None,
help="directory holding the step-*.pt files (default: <out_dir>/<run name>)",
)
parser.add_argument(
"--out",
default=None,
help="output JSON (default: artifacts/web/training-replay.json)",
)
parser.add_argument(
"--with-legality",
action="store_true",
help="measure the unmasked argmax legality of every checkpoint (slow: one forward pass "
"per sampled position per checkpoint)",
)
parser.add_argument(
"--with-elo",
action="store_true",
help="also estimate the Elo of every checkpoint against Stockfish with --elo-games games "
"per rung. VERY SLOW (eight rungs of real games per checkpoint) and, with so few games, "
"only good for the shape of the curve: the published number comes from `rukh eval`. "
"Implies --with-legality.",
)
parser.add_argument(
"--elo-games",
type=int,
default=ELO_GAMES,
help=f"games per Stockfish rung when --with-elo is given (default: {ELO_GAMES})",
)
parser.add_argument("--positions", type=int, default=500, help="positions per legality check")
return parser.parse_args(argv)

labs/m2/replay_export.pylíneas 178-215 · p2

La ayuda de --with-elo avisa en mayúsculas de que es MUY LENTO y solo sirve para la forma de la curva. Una opción cuya ayuda explica cuándo no usarla está bien documentada.

labs/m2/replay_export.py
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
args = parse_args()
client = mlflow.tracking.MlflowClient(tracking_uri=tracking_uri(create=False))
run = find_run(args.run_name, args.run_id)
run_id = run.info.run_id
logged = {name: history(client, run_id, key) for name, key in METRICS.items()}
metric_steps = sorted(set().union(*(series.keys() for series in logged.values())))
if not metric_steps:
raise SystemExit(f"run {run_id} has no train/val metrics")
ckpt_dir = checkpoint_dir(run, args.checkpoints)
checkpoints = run_checkpoints(ckpt_dir)
steps = replay_steps(metric_steps, checkpoints)
if not checkpoints:
raise SystemExit(
f"no {CKPT_GLOB} under {ckpt_dir}: the replay is one entry per checkpoint, so point "
"--checkpoints at the directory the run wrote"
)
measured: dict[int, dict[str, float]] = {}
if args.with_legality or args.with_elo:
measured = evaluate_checkpoints(
steps, checkpoints, args.positions, args.with_elo, args.elo_games
)
entries: list[dict[str, float | int]] = []
for step in steps:
entry: dict[str, float | int] = {"step": int(step)}
for name, series in logged.items():
if step in series:
entry[name] = round(series[step], 4)
entry.update(measured.get(step, {}))
entries.append(entry)
default_out = paths.root() / "artifacts" / "web" / "training-replay.json"
out = Path(args.out) if args.out else default_out
out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema": SCHEMA,
"steps": entries,
"meta": {
"run": run.info.run_name,
"run_id": run_id,
"preset": run.data.params.get("preset"),
"max_steps": run.data.params.get("max_steps"),
"checkpoints": ckpt_dir.as_posix(),
"generated": datetime.now(UTC).isoformat(timespec="seconds"),
},
}
out.write_text(json.dumps(payload, indent=1) + "\n", encoding="utf-8")
print(f"{len(entries)} checkpoints -> {out}")
if __name__ == "__main__":
main()

labs/m2/replay_export.pylíneas 218-275 · p2

El SystemExit de los checkpoints ausentes lleva la instrucción dentro, y el JSON sale con indent=1: un tercio menos de fichero y se sigue leyendo.

Terminal
# Lo barato: solo las series que MLflow ya registró durante el entrenamiento.
uv run python labs/m2/replay_export.py
# Lo que se exportó de verdad: además, la legalidad sin máscara de cada checkpoint.
uv run python labs/m2/replay_export.py --with-legality --positions 400

Salida real de la segunda ejecución, la de referencia (RTX 5090):

step 1000 legality 0.880
step 2000 legality 0.940
step 3000 legality 0.950
step 4000 legality 0.978
step 5000 legality 0.970
step 6000 legality 0.970
step 7000 legality 0.985
step 8000 legality 0.990
step 9000 legality 0.988
step 10000 legality 0.993
step 11000 legality 0.995
step 12000 legality 0.993
step 13000 legality 0.990
step 14000 legality 0.993
step 15000 legality 0.998
step 16000 legality 0.993
step 17000 legality 0.995
step 18000 legality 0.993
step 19000 legality 0.993
step 20000 legality 0.993
20 checkpoints -> …\rukh\artifacts\web\training-replay.json

Veinte checkpoints y una sola bandera cara. --with-legality cuesta unos segundos por checkpoint con 400 posiciones y llena la fila de legalidad de la isla. --with-elo convertiría un par de minutos en una noche, así que el Elo sale como un guion.

Con 400 posiciones, una posición vale un cuarto de punto y el ruido ronda ±1: 0.993 y 0.998 son 397 y 399 aciertos, no dos niveles de competencia. --checkpoints cambia dónde se buscan los step-*.pt; por defecto, la carpeta que registró la ejecución.

// Ejercicio 04Exporta la atención de una partida tuya

Cambia MOVES de attention_export.py por la partida de rukh play de la lección 4 (con quince plies basta) y vuelve a ejecutarlo. Abre la isla de la lección 7 con tu JSON (pnpm sync:data en rukh-lab) y busca L9H4. ¿Sigue siendo la cabeza de «jugada anterior»?

// SoluciónVer la solución

Debería: una cabeza especializada es una propiedad de los pesos, no de la partida. Si L9H4 mantiene su media alta en otra partida, la afirmación de la lección 7 gana mucha fuerza; si se desmorona, era una casualidad de dieciséis tokens.

Los saltos de L5H5, en cambio, deberían no aguantar si la explicación era una columna enganchada. Dos partidas no zanjan nada, pero la segunda es barata y ya reparte las probabilidades.

Qué has aprendido

Los cuatro instrumentos del módulo. Dos calculadoras: una suma los parámetros contra tu fórmula y la otra demuestra la causalidad con un cero exacto en vez de fiarse de un test que pasa. Y dos exportadores que convierten una tirada en algo que se puede mirar: 96 matrices de atención y veinte checkpoints con su legalidad.

Lo siguiente cierra el módulo, y contradice lo que las lecciones anteriores daban por sentado: a small no le faltaba red, le faltaba material.