// 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.
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
| Lab | Ruta | Reloj | Deja | Atajo |
|---|---|---|---|---|
| Lab 1Contar los parámetros del decoder contra la fórmula cerrada | imprescindible | segundos | el desglose por bloque, que cuadra con el total | no hay |
| Lab 2Dibujar la máscara causal y demostrar que el futuro no entra | imprescindible | segundos | una diferencia de cero exacto entre la salida completa y la truncada | no hay |
| Lab 3Sacar los 96 mapas de atención de una partida corta | imprescindible | segundos, con un checkpoint entrenado | artifacts/web/attention.json, que dibuja la isla de la lección 7 | no hay |
| Lab 4Exportar la repetición del entrenamiento checkpoint a checkpoint desde MLflow | imprescindible | segundos; minutos con --with-legality, que carga cada step-*.pt | artifacts/web/training-replay.json | no 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.
"""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 totalHacer 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.
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"El assert del final es el lab entero: si el modelo y la fórmula no coinciden, el script para.
uv run python labs/m2/params.pySalida 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,528Para 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.
"""Draw the causal mask and prove causality empirically on a real MoveDecoder."""
import torch
from rukh.models import DecoderConfig, MoveDecoder
T = 8mask = 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")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.
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"Para cada corte se cambian todos los tokens posteriores y se mide la diferencia máxima en los logits del pasado y del futuro.
uv run python labs/m2/causal_mask.pySalida 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-01La 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.
"""Export the attention weights of one short game to artifacts/web/attention.json."""
import jsonfrom datetime import UTC, datetimefrom pathlib import Path
import chessimport torchfrom torch.nn import functional as F
from rukh.models.decoder import apply_ropefrom rukh.tokenize.uci_vocab import UciTokenizerfrom 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)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:
ls -td checkpoints/small-*/ | head -1ls checkpoints/small-* | sort LastWriteTime | select -Last 1captured: 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()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.
cfg = model.cfgweights = []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()])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.
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)")La versión del esquema dice qué forma tiene el JSON, y meta.generated distingue una exportación
real de un marcador vacío.
uv run python labs/m2/attention_export.pySalida 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.
"""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 **oneentry per checkpoint**: the slider of the ``TrainingReplay`` island walks the ``step-*.pt`` filesthe run left behind, not the (much denser) MLflow logging steps, because every other number theisland 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 copiedfrom 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, notemperature, no top-k and no mask. It is the bar ``GOAL.md`` sets at 99 %, and the one that meanssomething 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"""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.
from __future__ import annotations
import argparseimport jsonimport loggingfrom collections.abc import Sequencefrom datetime import UTC, datetimefrom pathlib import Pathfrom typing import Any
import mlflow
from rukh import pathsfrom 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."""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]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ó.
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()))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.
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)El último paso registrado se conserva siempre, aunque su checkpoint ya no esté: es el modelo del que habla el resto de la tabla.
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).elodef 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 measuredLa 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.
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)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.
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()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.
# 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 400Salida 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.99320 checkpoints -> …\rukh\artifacts\web\training-replay.jsonVeinte 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.