rukh · lab

// M3 · lección 05

Masked move modeling: el preentrenamiento y la receta compartida

`train/common.py`, la mitad del bucle que los dos modelos comparten, y `train/mmm.py` entero: los dos sorteos del 80/10/10, los tokens de control que nunca se tapan, la validación con la semilla reiniciada y la configuración de las doce mil pasadas.

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

Lección 5 de 11 del módulo «El encoder». Viene de «Cabezas y casillas» y sigue en «Entrenar las cabezas».

Qué vas a construir

El preentrenamiento del encoder: dos ficheros y una configuración. train/common.py es la mitad del bucle de M2 sacada a un sitio donde los dos modelos puedan usarla. train/mmm.py es lo que queda cuando esa mitad ya está fuera: el enmascarado, la pérdida y el bucle de doce mil pasos.

El orden lo justifica el docstring del primero: una tirada solo es comparable con otra cuando la receta es la misma. Si el encoder tuviera su propia planificación de la tasa de aprendizaje y su propio guardado y reanudado, cualquier diferencia con el decoder podría ser la máscara o la receta. Por eso un equipo que compara dos modelos de lenguaje los entrena con el mismo script y solo cambia la pieza que quiere medir.

common.py: lo que toda tirada hace igual

src/rukh/train/common.py
"""What every training run in this project does the same way, whatever it is training.
``loop.py`` (next-move prediction) and ``mmm.py`` (masked move modeling) differ in exactly one
place: how a batch becomes a loss. Everything around that step — the optimizer's parameter
groups, the learning-rate schedule, resuming from a checkpoint, writing one, choosing the
device, probing ``torch.compile`` and sending metrics to MLflow — is identical, so it lives here
and both loops call it. Duplicating it would mean two recipes drifting apart, and a run is only
comparable with another one when the recipe is the same.
``RunConfig`` is the shared half of both YAML configs; each loop's config adds what only it
needs (the decoder preset, or the masking parameters).
"""
from __future__ import annotations
import logging
import math
import os
import time
from collections.abc import Callable, Iterator
from contextlib import nullcontext
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
import torch
from torch import nn
from torch.utils.data import DataLoader
from rukh import paths
from rukh.config import BaseConfig
from rukh.train.checkpoint import load_checkpoint, restore, save_checkpoint
from rukh.train.schedule import lr_at
log = logging.getLogger(__name__)
Batch = tuple[torch.Tensor, torch.Tensor]

src/rukh/train/common.pylíneas 1-37 · p3

«Differ in exactly one place: how a batch becomes a loss» es el criterio de lo que entra aquí: lo que tiene que ser igual para que dos números se puedan comparar.

src/rukh/train/common.py
class RunConfig(BaseConfig):
"""The half of a training config that does not depend on what is being trained."""
tokens_dir: str = "data/tokens/uci"
block: int = 200
batch_size: int = 64
grad_accum: int = 4 # 256 effective sequences
lr: float = 6e-4
min_lr_ratio: float = 0.1
warmup: int = 1000
max_steps: int = 20000
weight_decay: float = 0.1
betas: tuple[float, float] = (0.9, 0.95)
grad_clip: float = 1.0
precision: Literal["bf16", "fp32"] = "bf16"
compile: bool = True
eval_every: int = 500
eval_batches: int = 50
ckpt_every: int = 1000
out_dir: str = "checkpoints"
seed: int = 42
run_name: str | None = None
unique_run_name: bool = True
"""Append a timestamp to ``run_name``: a second run must not overwrite the ``step-*.pt``
series the ``TrainingReplay`` of the course reads."""
workers: int = 0 # DataLoader workers; 0 keeps everything in the main process
log_every: int = 10 # optimizer steps between training metrics
@property
def default_name(self) -> str:
"""Run name when the YAML gives none; each loop names its runs after what it trains."""
return "run"
def check(self) -> None:
"""Refuse a config no loop could honour."""
if min(self.max_steps, self.grad_accum, self.batch_size) < 1:
raise ValueError("max_steps, grad_accum and batch_size must be positive")

src/rukh/train/common.pylíneas 40-76 · p3

Los campos son los de TrainConfig en M2, movidos a la clase base. Lo nuevo son las dos últimas piezas.

default_name es una propiedad que las subclases redefinen: el decoder devuelve su preset (small) y el encoder, encoder-moves. Así run_dir, que vive aquí, no necesita saber qué se entrena; un if isinstance(cfg, TrainConfig) dentro haría que el módulo común conociera a sus clientes.

check() es un método y no un validador de pydantic porque tiene que mirar el valor final: los tests modifican configuraciones con model_copy y la CLI sobrescribe max_steps, y un validador solo corre al construir el objeto.

src/rukh/train/common.py
def pick_device() -> str:
"""``RUKH_DEVICE`` if set, else CUDA when available, else CPU."""
env = os.environ.get("RUKH_DEVICE")
if env:
return env
return "cuda" if torch.cuda.is_available() else "cpu"
def param_groups(model: nn.Module, weight_decay: float) -> list[dict[str, Any]]:
"""Decoupled weight decay on matrices only: norms and biases are left alone."""
decay = [p for p in model.parameters() if p.requires_grad and p.dim() >= 2]
no_decay = [p for p in model.parameters() if p.requires_grad and p.dim() < 2]
return [
{"params": decay, "weight_decay": weight_decay},
{"params": no_decay, "weight_decay": 0.0},
]
def set_lr(optimizer: torch.optim.Optimizer, step: int, cfg: RunConfig) -> float:
"""Apply the scheduled learning rate of ``step`` to every group and return it."""
lr = lr_at(step, cfg)
for group in optimizer.param_groups:
group["lr"] = lr
return lr

src/rukh/train/common.pylíneas 79-102 · p3

param_groups es de M2, y aquí su filtro p.requires_grad sostiene el afinado de la lección 6. En un probe el tronco tiene requires_grad = False, así que sus parámetros no entran en ningún grupo y el optimizador no reserva estado para ellos. Sin el filtro, AdamW guardaría dos momentos por cada peso congelado —ciento veinte megabytes de nada— y el decaimiento de pesosAdamWOptimizador Adam con el decaimiento de pesos desacoplado del gradiente: mantiene medias móviles del gradiente (β₁) y de su cuadrado (β₂) para dar a cada parámetro su propio paso, y resta aparte una fracción del peso. En Rukh: β = 0,9/0,95, weight decay 0,1 aplicado solo a las matrices. seguiría actuando: multiplica cada peso de su grupo por (1 - lr · wd) en cada paso, tenga gradiente o no, y tras cuatro mil pasos el tronco «congelado» ya no sería el preentrenado. Por eso el optimizador se construye después de congelar.

La división por dimensión es la de siempre: decaimiento en las matrices (dim >= 2), no en sesgos ni en ganancias de LayerNorm. Encoger una ganancia de normalización hacia cero apaga la capa.

src/rukh/train/common.py
def forever(loader: DataLoader[Batch]) -> Iterator[Batch]:
"""Repeat a loader for as many steps as the schedule asks for."""
while True:
yield from loader
def autocast_for(cfg: RunConfig, where: torch.device) -> tuple[Any, bool]:
"""``(context, enabled)``: bf16 autocast on CUDA when asked, a no-op context otherwise."""
use_bf16 = cfg.precision == "bf16" and where.type == "cuda"
if not use_bf16:
return nullcontext(), False
return torch.autocast(device_type="cuda", dtype=torch.bfloat16), True

src/rukh/train/common.pylíneas 105-116 · p3

forever existe porque el bucle cuenta pasos, no épocas. autocast_for devuelve un nullcontext cuando no hay bf16 para que el bucle escriba with autocast: sin preguntar y corra igual en CPU en los tests; el booleano del par lo usa la evaluación, que solo recibe el contexto cuando hay bf16.

src/rukh/train/common.py
def maybe_compile(
model: nn.Module,
enabled: bool,
sample: torch.Tensor | None = None,
probe: Callable[[nn.Module], None] | None = None,
) -> nn.Module:
"""``torch.compile`` the model when asked; a failure is a warning, never a stopped run.
``torch.compile`` is lazy: on Windows without MSVC it only fails when the first forward
reaches Inductor. ``sample`` (one batch of the training shape, used as input and target by
the decoder's ``forward``) or a ``probe`` that runs one step itself forces that compilation
here, where it can still fall back to eager, and Dynamo's own error suppression covers any
later recompilation for a different shape.
"""
if not enabled:
return model
try:
import torch._dynamo as dynamo
dynamo.config.suppress_errors = True
compiled = torch.compile(model)
if probe is not None:
probe(compiled)
model.zero_grad(set_to_none=True)
elif sample is not None:
_, loss = compiled(sample, sample)
if loss is not None:
loss.backward()
model.zero_grad(set_to_none=True)
return compiled
except Exception as exc: # noqa: BLE001 - compilation backends fail in many ways
log.warning("torch.compile is unavailable, training eagerly: %s", exc)
model.zero_grad(set_to_none=True)
return model

src/rukh/train/common.pylíneas 119-152 · p3

Lo nuevo al compartirla es el parámetro probe, un sondeoSondeo de compilación (compile probe)Una pasada de mentira que se hace a propósito antes de entrenar para que torch.compile compile ahí, y falle ahí si va a fallar; en inglés, probe. Paga por adelantado el minuto de compilación y convierte un error de forma dinámica en un error del primer segundo en vez de uno de la media hora. No es el probe lineal de M3, que es otra cosa con el mismo nombre en inglés. que paga la compilación por adelantado. En M2 bastaba con sample: el forward del decoder acepta (idx, targets) y, llamado con el mismo tensor dos veces, produce una pérdida y una compilación de ida y vuelta. El forward del encoder devuelve estados ocultos, no una pérdida, y la llamada real lleva una máscara de relleno. Así que mmm.py pasa una función que ejecuta el paso que el bucle va a ejecutar, con su máscara y su backward; compilar con attention_mask=None daría un grafo que el bucle nunca usa y una recompilación en el paso 1.

El except Exception convierte una compilación fallida en un aviso y una tirada en modo ansioso. En Windows sin MSVC pasa de verdad, y perder la tirada porque Inductor no encuentra un compilador de C++ sería absurdo.

src/rukh/train/common.py
def run_dir(cfg: RunConfig, resume: Path | None = None) -> Path:
"""Where this run writes: the resumed run's folder, or ``out_dir/<run name>``.
The name carries a timestamp unless ``unique_run_name`` is off, because two runs of the same
config would otherwise write the same ``step-*.pt`` files and the second would quietly
overwrite the checkpoint series of the first.
"""
if resume is not None:
return Path(resume).resolve().parent
name = cfg.run_name or cfg.default_name
if cfg.unique_run_name:
name = f"{name}-{datetime.now(UTC):%Y%m%d-%H%M%S}"
return paths.resolve(cfg.out_dir) / name
def skip_batches(batches: Iterator[Batch], count: int) -> int:
"""Wind the batch stream forward ``count`` batches and return how many were skipped.
A resumed run must not start again at the first window of the first epoch: it would train
twice on the same games while the schedule believes it is halfway. Windows are memmap slices,
so winding forward is cheap compared with a step, and it is logged because it is not free.
"""
if count <= 0:
return 0
started = time.perf_counter()
for _ in range(count):
next(batches)
log.info("skipped %d batches in %.1f s to resume", count, time.perf_counter() - started)
return count

src/rukh/train/common.pylíneas 155-183 · p3

Por run_dir todas las rutas de checkpoint del módulo llevan fecha (checkpoints/encoder-mmm-20260919-093554/): en las órdenes del curso aparece como <fecha>, y en las tuyas va la de tu tirada.

src/rukh/train/common.py
def load_resume(
resume: Path,
model: nn.Module,
optimizer: torch.optim.Optimizer,
where: torch.device,
) -> tuple[int, float, str | None]:
"""Restore weights, optimizer and RNG; return ``(start_step, best_val, mlflow run id)``."""
payload = load_checkpoint(resume, map_location=where)
start_step = restore(payload, model, optimizer)
recorded = payload.get("best_val")
best_val = float(recorded) if isinstance(recorded, int | float) else math.inf
previous = payload.get("run_id")
run_id = str(previous) if isinstance(previous, str) and previous else None
log.info("resumed %s at step %d (mlflow run %s)", resume, start_step, run_id or "new")
return start_step, best_val, run_id
def write_checkpoint(
path: Path,
*,
step: int,
model: nn.Module,
optimizer: torch.optim.Optimizer | None,
cfg: BaseConfig,
model_cfg: BaseConfig,
vocab_hash: str | None,
manifest_sha: str | None,
best_val: float,
run_id: str | None,
) -> Path:
"""One checkpoint with the provenance every ``rukh`` run records."""
from rukh.tracking import git_sha
return save_checkpoint(
path,
step=step,
model=model,
optimizer=optimizer,
cfg=cfg.model_dump(mode="json"),
model_cfg=model_cfg.model_dump(mode="json"),
vocab_hash=vocab_hash,
data_manifest_sha=manifest_sha,
git_sha=git_sha(),
best_val=None if math.isinf(best_val) else best_val,
run_id=run_id,
)

src/rukh/train/common.pylíneas 186-231 · p3

write_checkpoint añade la procedencia sobre save_checkpoint: el SHA de git, el hash del vocabulario y el del manifiesto de datos. Al estar aquí, ningún entrenamiento del proyecto puede producir un checkpoint sin decir con qué código y datos salió.

load_resume acepta un best_val ausente (los primeros checkpoints no lo tenían), y None if math.isinf(best_val) escribe null en vez de Infinity, que no es JSON válido. El asterisco de la firma obliga a pasar todo por nombre: con cuatro cadenas opcionales, un orden equivocado guardaría el hash del manifiesto en el campo del vocabulario sin que nada fallara.

src/rukh/train/common.py
def log_metrics(metrics: dict[str, float], step: int) -> None:
"""Send metrics to the active MLflow run; a tracking failure never stops training."""
import mlflow
clean = {key: value for key, value in metrics.items() if not math.isnan(value)}
if not clean:
return
try:
mlflow.log_metrics(clean, step=step)
except Exception as exc: # noqa: BLE001 - tracking is not worth a lost run
log.warning("could not log metrics at step %d: %s", step, exc)

src/rukh/train/common.pylíneas 234-244 · p3

MLflowMLflowRegistro de experimentos: cada entrenamiento guarda su configuración, sus métricas por paso y sus artefactos en una base SQLite local (rukh mlflow ui la abre en el navegador). Las model cards del curso se generan desde ahí para que ningún número se escriba a mano. acepta un nan y luego lo dibuja como hueco o como cero según el visor, y en este módulo un nan sale de algo tan normal como un lote donde el sorteo no tapó nada. De ahí el filtro.

Lo que queda en loop.py

El docstring gana una frase sobre rukh.train.common y los imports adelgazan: lo que usaban las funciones que se han ido a common.py ya no hace falta aquí.

src/rukh/train/loop.py
"""The decoder's training loop: next-token prediction on a packed stream of move tokens.
The recipe follows ``docs/spec/02`` (component 1): AdamW (0.9/0.95, weight decay 0.1 applied
only to matrices), learning rate 6e-4 with 1 000 warmup steps and a cosine decay, an effective
batch of ``batch_size * grad_accum`` sequences, bf16 autocast on CUDA, gradient clipping at 1.0
and optional ``torch.compile``. Everything the run needs to be reproducible (config, seed, git
SHA, vocabulary hash, data manifest hash) goes to MLflow and into every checkpoint. All of that
machinery lives in ``rukh.train.common``, shared with the encoder's masked-move loop; what is
left here is the part that is specific to predicting the next move.
Resuming is meant to be indistinguishable from never having stopped: the batch stream is wound
forward past the windows the first half of the run already saw, and the MLflow run id travels in
the checkpoint so the curve carries on in the same run instead of starting a second one.
Two throughput numbers are logged because they answer different questions: ``tokens_per_s``
counts every position in the window (what the GPU actually processed, comparable across runs)
and ``real_tokens_per_s`` counts only the non-``<pad>`` targets (what the model learned from).
The training loss is accumulated token-weighted rather than as a mean of means, so a
micro-batch with fewer real tokens does not count as much as a full one.
"""
from __future__ import annotations
import logging
import math
import time
from contextlib import nullcontext
from pathlib import Path
from typing import Any, Literal
import torch
from torch import nn
from torch.utils.data import DataLoader
from rukh import paths
from rukh.models import DecoderConfig, MoveDecoder, preset
from rukh.tokenize.loader import IGNORE_INDEX, PackedDataset, make_loader

src/rukh/train/loop.pylíneas 1-37 · p3

src/rukh/train/loop.py
from rukh.train.checkpoint import BEST_NAME, read_manifest_sha, read_vocab_hash, step_name
from rukh.train.common import (
Batch,
RunConfig,
autocast_for,
forever,
load_resume,
log_metrics,
maybe_compile,
param_groups,
pick_device,
run_dir,
set_lr,
skip_batches,
write_checkpoint,
)
log = logging.getLogger(__name__)
__all__ = [
"Batch",
"TrainConfig",
"evaluate",
"forever",
"log_metrics",
"maybe_compile",
"param_groups",
"pick_device",
"run_dir",
"skip_batches",
"train",
]
class TrainConfig(RunConfig):
"""Everything one decoder training run needs; unknown keys in the YAML are an error."""
preset: Literal["tiny", "small", "medium"] = "small"
model: DecoderConfig | None = None # overrides the preset when given
@property
def default_name(self) -> str:
return self.preset
def decoder(self) -> DecoderConfig:
"""The decoder config of this run: the preset (or ``model``) with ``block`` applied."""
base = self.model if self.model is not None else preset(self.preset)
return base.model_copy(update={"block": self.block})

src/rukh/train/loop.pylíneas 38-85 · p3

TrainConfig pasa de más de veinte campos a dos. Como en el refactor de la lección 2, el __all__ reexporta lo que se ha ido a common.py, porque los tests de M2 lo importan de aquí: un refactor interno no debería obligar a tocar el código que ya lo usaba.

evaluate no cambia. En train, el reanudado, el autocast y el guardado pasan a ser llamadas de una línea, y se queda lo que sabe de jugadas. Es la función entera, y sustituye a la de M2:

src/rukh/train/loop.py
def train(cfg: TrainConfig, resume: Path | None = None, device: str | None = None) -> Path:
"""Train a ``MoveDecoder`` and return the path of the final checkpoint."""
cfg.check()
torch.manual_seed(cfg.seed)
where = torch.device(device or pick_device())
tokens_dir = paths.resolve(cfg.tokens_dir)
model_cfg = cfg.decoder()
train_set = PackedDataset(tokens_dir / "train", block=cfg.block)
val_set = PackedDataset(tokens_dir / "val", block=cfg.block)
if train_set.info.vocab_size != model_cfg.vocab_size:
raise ValueError(
f"{tokens_dir / 'train'} has vocab_size {train_set.info.vocab_size}, "
f"the model expects {model_cfg.vocab_size}"
)
train_loader = make_loader(train_set, cfg.batch_size, seed=cfg.seed, workers=cfg.workers)
val_loader = make_loader(
val_set, cfg.batch_size, seed=cfg.seed, workers=0, shuffle=False, drop_last=False
)
if not len(train_loader):
raise ValueError(f"{tokens_dir / 'train'} has fewer than {cfg.batch_size} windows")
model = MoveDecoder(model_cfg).to(where)
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, use_bf16 = autocast_for(cfg, where)
warmup = torch.ones((cfg.batch_size, cfg.block), dtype=torch.long, device=where)
with autocast: # compile under the same precision the loop will use
runnable = maybe_compile(model, cfg.compile, warmup)
runnable.train()
out_dir = run_dir(cfg, resume)
out_dir.mkdir(parents=True, exist_ok=True)
vocab_hash = read_vocab_hash(tokens_dir / "train")
manifest_sha = read_manifest_sha(paths.data_dir() / "raw" / "manifest.json")
tokens_per_step = cfg.batch_size * cfg.grad_accum * cfg.block
batches = forever(train_loader)
skip_batches(batches, start_step * cfg.grad_accum)
final = out_dir / step_name(cfg.max_steps)
from rukh.tracking import start_run
params = {
**cfg.model_dump(mode="json"),
"vocab_hash": vocab_hash,
"data_manifest_sha": manifest_sha,
"device": str(where),
"num_params": model.num_params(),
}
with start_run(out_dir.name, params, tags={"preset": cfg.preset}, run_id=run_id) as run:
log.info("run %s in %s on %s", run.info.run_id, out_dir, where)
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=model_cfg,
vocab_hash=vocab_hash,
manifest_sha=manifest_sha,
best_val=best_val,
run_id=this_run,
)
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)
# Token-weighted, on the device: one synchronisation per step instead of one per
# micro-batch, and a micro-batch with fewer real tokens weighs less in the mean.
loss_sum = torch.zeros((), device=where)
real_tokens = torch.zeros((), device=where)
for _ in range(cfg.grad_accum):
x, y = next(batches)
x, y = x.to(where), y.to(where)
with autocast:
_, loss = runnable(x, y)
assert loss is not None
(loss / cfg.grad_accum).backward()
tokens = (y != IGNORE_INDEX).sum()
loss_sum += loss.detach().float() * tokens
real_tokens += tokens
grad_norm = float(nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip))
optimizer.step()
done = step + 1
last = done == cfg.max_steps
if done % cfg.log_every == 0 or last:
counted = float(real_tokens.item())
total = float(loss_sum.item()) / counted if counted else math.nan
elapsed = max(time.perf_counter() - clock, 1e-9)
steps = min(cfg.log_every, done - start_step)
log_metrics(
{
"train/loss": total,
"lr": lr,
"grad_norm": grad_norm,
"tokens_per_s": tokens_per_step * steps / elapsed,
"real_tokens_per_s": counted * steps / elapsed,
},
step=done,
)
clock = time.perf_counter()
if done % cfg.eval_every == 0 or last:
val_loss, top1 = evaluate(
runnable, val_loader, cfg.eval_batches, where, autocast if use_bf16 else None
)
log_metrics({"val/loss": val_loss, "val/top1": top1}, step=done)
log.info("step %d val/loss %.4f val/top1 %.4f", done, val_loss, top1)
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 final

src/rukh/train/loop.pylíneas 125-249 · p3

Léela buscando lo que ya no está. load_resume devuelve paso, mejor pérdida e id de MLflow y esconde la restauración de pesos, optimizador y generadores. save es un cierre sobre write_checkpoint que fija lo que no cambia entre guardados, así que las llamadas del final solo dicen dónde y en qué paso. Lo propio del decoder es comprobar el vocab_size del pack, construir un MoveDecoder y ponderar la pérdida por tokens reales. El bucle del encoder de más abajo tiene la misma forma con otra línea de pérdida: esa simetría es el objetivo del refactor.

mmm.py: el enmascarado

src/rukh/train/mmm.py
"""Masked move modeling: BERT's pretraining objective moved from words to chess moves.
A fraction of the moves of a game are hidden and the bidirectional encoder has to put them back
from both sides of the hole. The 80/10/10 recipe is BERT's and it is kept for the same reason:
if every hidden position were replaced by ``<mask>``, the model would only ever see ``<mask>``
during pretraining and never during fine-tuning, and it would learn a representation that only
works when a token is missing. Ten per cent of random tokens force it to distrust what it reads
(a move that is *there* may still be wrong) and ten per cent left untouched force it to build a
representation of every position, not only of the marked ones.
Two things differ from the decoder's loop, and only two:
* control tokens are never hidden. ``<bos>``, the two Elo tokens, the result token and
``<eos>`` are the *condition* of the game, not the signal; predicting the result from a full
game would be free and would teach nothing about chess.
* the ignored label is ``-100``, not the decoder's ``0``. ``0`` is ``<pad>`` in both schemes, so
it cannot double as "not predicted" without the loss losing the ability to tell the two apart;
"nothing to predict" needs a value outside every vocabulary. See
``rukh.models.encoder.MMM_IGNORE_INDEX``.
Everything else — optimizer, schedule, checkpoints, resume, MLflow — is ``rukh.train.common``,
the same machinery the decoder uses, so the two runs are comparable.
"""

src/rukh/train/mmm.pylíneas 1-23 · p3

src/rukh/train/mmm.py
from __future__ import annotations
import logging
import math
import time
from contextlib import nullcontext
from pathlib import Path
from typing import Any, Literal
import torch
from pydantic import Field, model_validator
from torch import Tensor, nn
from torch.nn import functional as F
from torch.utils.data import DataLoader
from rukh import paths
from rukh.config import BaseConfig
from rukh.models import EncoderConfig, PositionEncoder
from rukh.models.encoder import MMM_IGNORE_INDEX
from rukh.models.squares import CONTROL_IDS as SQUARE_CONTROL_IDS
from rukh.models.squares import MASK_ID as SQUARE_MASK_ID
from rukh.tokenize.loader import PackedDataset, make_loader
from rukh.tokenize.uci_vocab import MASK_ID as MOVE_MASK_ID
from rukh.tokenize.uci_vocab import SPECIALS, elo_tokens
from rukh.train.checkpoint import BEST_NAME, read_manifest_sha, read_vocab_hash, step_name
from rukh.train.common import (
Batch,
RunConfig,
autocast_for,
forever,
load_resume,
log_metrics,
maybe_compile,
param_groups,
pick_device,
run_dir,
set_lr,
skip_batches,
write_checkpoint,
)
log = logging.getLogger(__name__)
Scheme = Literal["moves", "squares"]

src/rukh/train/mmm.pylíneas 25-68 · p3

El docstring es la sección «Masked move modeling» de la lección 1 escrita en el código, con una frase más: son dos diferencias con el bucle del decoder, y solo dos. Los imports lo confirman: casi todo viene de rukh.train.common. Los import ... as evitan que choquen el MASK_ID del vocabulario UCI y el de casillas.

src/rukh/train/mmm.py
MOVE_CONTROL_IDS: frozenset[int] = frozenset(
range(len(SPECIALS) + len(elo_tokens("w")) + len(elo_tokens("b")))
)
"""The 62 ids of the UCI vocabulary that are not moves: specials, results and the Elo bins.
They are a prefix of the vocabulary by construction (see ``uci_vocab.build_vocab``), which is
what lets a random replacement simply draw above them.
"""
def control_ids(scheme: Scheme) -> frozenset[int]:
"""Token ids that are never hidden, for one input scheme."""
return MOVE_CONTROL_IDS if scheme == "moves" else SQUARE_CONTROL_IDS
def mask_id(scheme: Scheme) -> int:
"""The ``<mask>`` token of one input scheme (id 3 in the P1 vocabulary, 1 in squares)."""
return MOVE_MASK_ID if scheme == "moves" else SQUARE_MASK_ID

src/rukh/train/mmm.pylíneas 70-87 · p3

MOVE_CONTROL_IDS se calcula en vez de escribirse: da los 62 ids de control porque el vocabulario de M1 los pone al principio. Un frozenset(range(62)) a mano dejaría de funcionar en silencio el día que alguien añadiera un token especial, y el sorteo empezaría a producir Elos falsos. Que sean un prefijo del vocabulario permite sortear «una jugada cualquiera» por encima del último id de control: una decisión de enumeración de M1 que paga dos módulos después.

src/rukh/train/mmm.py
class MaskingConfig(BaseConfig):
"""How much to hide and how: BERT's 15 % at 80/10/10."""
prob: float = 0.15
mask_ratio: float = 0.8
random_ratio: float = 0.1
keep_ratio: float = 0.1
seed: int = 0
@model_validator(mode="after")
def _check(self) -> MaskingConfig:
if not 0.0 < self.prob <= 1.0:
raise ValueError(f"prob must be in (0, 1], got {self.prob}")
ratios = (self.mask_ratio, self.random_ratio, self.keep_ratio)
if any(ratio < 0.0 for ratio in ratios):
raise ValueError("mask_ratio, random_ratio and keep_ratio must be non-negative")
if abs(sum(ratios) - 1.0) > 1e-9:
raise ValueError(f"mask/random/keep must add up to 1, got {sum(ratios)}")
return self
def masking_generator(seed: int, device: torch.device | str = "cpu") -> torch.Generator:
"""A generator for ``apply_masking``: one per run, so the masking is seeded but varies."""
generator = torch.Generator(device=device)
generator.manual_seed(seed)
return generator

src/rukh/train/mmm.pylíneas 90-115 · p3

El validador que suma los tres porcentajes caza la errata más fácil: subir mask_ratio a 0,9 y olvidarse de bajar otro. Sin él, el tercer tramo, que se calcula por diferencia, absorbería el error y la receta cambiaría sin que constara en ninguna parte. La tolerancia de 1e-9 está porque 0.8 + 0.1 + 0.1 en coma flotante no da 1.

masking_generator devuelve un torch.Generator propio, y esa decisión hace reproducible el módulo. Con el RNG global, el enmascarado dependería de cuántos números hubieran consumido antes el DataLoader, la inicialización o el dropout: cambiar el número de workers cambiaría qué jugadas se tapan. Con un generador propio, el sorteo depende solo de la semilla y de cuántas veces se ha llamado. Es como darle a cada mesa su propia baraja en vez de repartir todas de un mismo mazo: lo que salga en tu mesa no depende de cuántas cartas pidieron las demás.

src/rukh/train/mmm.py
def apply_masking(
batch: Tensor,
cfg: MaskingConfig,
vocab: int,
scheme: Scheme = "moves",
generator: torch.Generator | None = None,
) -> tuple[Tensor, Tensor]:
"""Hide part of ``batch``; return ``(inputs, labels)`` of the same shape.
``labels`` is the original token where a position was selected and ``MMM_IGNORE_INDEX``
everywhere else, so the loss scores exactly the selected positions and nothing else. Of the
selected ones, ``mask_ratio`` become ``<mask>``, ``random_ratio`` become another *move*
(never a control token) and ``keep_ratio`` are left as they were.
``generator`` makes the draw reproducible; without one the global torch RNG is used, which
the training loop seeds once from ``RunConfig.seed``.
"""
control = control_ids(scheme)
if vocab <= max(control):
raise ValueError(f"a vocabulary of {vocab} tokens is all control tokens")
device = batch.device
is_control = torch.isin(batch, torch.tensor(sorted(control), device=device))
draw = torch.rand(batch.shape, generator=generator, device=device)
selected = (draw < cfg.prob) & ~is_control
labels = torch.where(selected, batch, torch.full_like(batch, MMM_IGNORE_INDEX))
decision = torch.rand(batch.shape, generator=generator, device=device)
inputs = batch.clone()
inputs[selected & (decision < cfg.mask_ratio)] = mask_id(scheme)
random_upto = cfg.mask_ratio + cfg.random_ratio
replace = selected & (decision >= cfg.mask_ratio) & (decision < random_upto)
if bool(replace.any()):
# Draw above the control block: a random *move*, never a fake Elo or result token.
low = max(control) + 1
noise = torch.randint(
low, vocab, (int(replace.sum()),), generator=generator, device=device, dtype=batch.dtype
)
inputs[replace] = noise
return inputs, labels

src/rukh/train/mmm.pylíneas 118-156 · p3

La función central del módulo, con dos sorteos independientes. El primero (draw) decide qué posiciones entran, el 15 %; el segundo (decision) reparte el 80/10/10 entre las elegidas. Con un solo sorteo y tres umbrales (draw < 0.12 para <mask>, 0.12 <= draw < 0.135 para ruido…), las posiciones con el número más bajo serían siempre las enmascaradas y las intactas, siempre las que rozaron el umbral. El reparto dejaría de ser aleatorio dentro de las elegidas, y ninguna métrica lo mostraría.

El keep_ratio no se calcula: es lo que queda por encima de mask_ratio + random_ratio, y por eso el validador exige que los tres sumen 1. torch.where construye las etiquetas en una operación: el token original donde se eligió, -100 en lo demás. Así la pérdida puntúa solo las posiciones elegidas por construcción, no por disciplina.

En la rama del ruido, if bool(replace.any()) evita un randint de tamaño cero, que algunas versiones de PyTorch rechazan, y dtype=batch.dtype evita que la asignación falle o promocione el tensor. El vocab <= max(control) de la entrada caza un error de configuración fácil: pasar el vocabulario de casillas (47) con el esquema de jugadas, cuyos ids de control llegan al 61.

La configuración y el bucle

src/rukh/train/mmm.py
class MmmConfig(RunConfig):
"""One masked-move pretraining run; unknown keys in the YAML are an error.
``tokens_dir`` is a packed *move* stream, the very one the decoder trains on, so the two
models see the same games. The ``squares`` scheme has no pack in P1 (positions live in a
parquet of FENs, not in a token stream), so it is fine-tuned from its labels in
``rukh.train.heads`` rather than pretrained here.
"""
input: Literal["moves"] = "moves"
model: EncoderConfig | None = None # overrides the defaults when given
masking: MaskingConfig = Field(default_factory=MaskingConfig)
@property
def default_name(self) -> str:
return f"encoder-{self.input}"
def encoder(self) -> EncoderConfig:
"""The encoder config of this run: ``model`` (or the defaults) with ``block`` applied."""
base = self.model if self.model is not None else EncoderConfig()
return base.model_copy(update={"block": self.block, "input": self.input})

src/rukh/train/mmm.pylíneas 159-179 · p3

input: Literal["moves"], con un solo valor, declara una restricción: M1 dejó un flujo empaquetado de partidas, no de tableros, así que solo hay preentrenamiento para el esquema de jugadas. input: squares en el YAML da un error de validación en vez de fallar al abrir el pack; por eso la línea de casillas arranca de pesos aleatorios.

Field(default_factory=MaskingConfig) evita que = MaskingConfig() cree una única instancia compartida por todas las configuraciones: el error del argumento por defecto mutable con otro disfraz.

src/rukh/train/mmm.py
def masked_step(
encoder: PositionEncoder,
body: nn.Module,
inputs: Tensor,
labels: Tensor,
attention_mask: Tensor | None = None,
) -> tuple[Tensor, Tensor]:
"""Logits and masked-move loss for one batch.
``body`` is the encoder or its compiled twin; the head and the loss stay eager because
``torch.compile`` wraps ``forward`` and nothing else.
"""
logits = encoder.mlm_head(body(inputs, attention_mask))
loss = F.cross_entropy(
logits.reshape(-1, logits.shape[-1]),
labels.reshape(-1),
ignore_index=MMM_IGNORE_INDEX,
)
return logits, loss

src/rukh/train/mmm.pylíneas 182-200 · p3

Aquí está «cómo un lote se convierte en una pérdida», lo único que este bucle no comparte con el del decoder. Lo que hay que entender es la firma con dos modelos, encoder y body.

src/rukh/train/mmm.py
@torch.no_grad()
def evaluate_mmm(
encoder: PositionEncoder,
body: nn.Module,
loader: DataLoader[Batch],
batches: int,
device: torch.device,
masking: MaskingConfig,
vocab: int,
scheme: Scheme = "moves",
autocast: Any = None,
) -> tuple[float, float]:
"""Validation loss and accuracy over the hidden positions of at most ``batches`` batches.
The validation masking is drawn from a generator reseeded here, so every evaluation of a run
(and of the next run) hides the same positions and the curve compares like with like.
"""
was_training = body.training
body.eval()
generator = masking_generator(masking.seed, device)
loss_sum = 0.0
weighted = 0
hits = 0
counted = 0
for index, (x, _) in enumerate(loader):
if index >= batches:
break
x = x.to(device)
inputs, labels = apply_masking(x, masking, vocab, scheme, generator)
with autocast if autocast is not None else nullcontext():
logits, loss = masked_step(encoder, body, inputs, labels, encoder.padding_mask(x))
scored = labels != MMM_IGNORE_INDEX
tokens = int(scored.sum())
if torch.isfinite(loss) and tokens:
loss_sum += loss.float().item() * tokens
weighted += tokens
hits += int((logits.argmax(dim=-1) == labels)[scored].sum())
counted += tokens
if was_training:
body.train()
return (loss_sum / weighted if weighted else math.nan, hits / counted if counted else 0.0)

src/rukh/train/mmm.pylíneas 203-243 · p3

La línea que hay que mirar es masking_generator(masking.seed, device): cada evaluación reconstruye el generador desde la semilla en vez de arrastrar el del entrenamiento. Si lo arrastrara, la validación del paso 500 y la del 1 000 se calcularían sobre huecos distintos, y la curva mezclaría la mejora del modelo con la dificultad del sorteo. Con la semilla reiniciada, todas las evaluaciones, también las de otra tirada con la misma masking.seed, tapan las mismas posiciones. La tarea de evaluación es parte del instrumento de medida: por eso un benchmark de LLM fija sus preguntas y su semilla de muestreo.

La pérdida se acumula ponderada por posiciones tapadas, no como media de medias, y was_training devuelve el modelo al modo en que estaba.

src/rukh/train/mmm.py
def train_mmm(cfg: MmmConfig, resume: Path | None = None, device: str | None = None) -> Path:
"""Pretrain a ``PositionEncoder`` with masked move modeling; return the last checkpoint."""
cfg.check()
torch.manual_seed(cfg.seed)
where = torch.device(device or pick_device())
tokens_dir = paths.resolve(cfg.tokens_dir)
model_cfg = cfg.encoder()
train_set = PackedDataset(tokens_dir / "train", block=cfg.block)
val_set = PackedDataset(tokens_dir / "val", block=cfg.block)
if train_set.info.vocab_size != model_cfg.tokens:
raise ValueError(
f"{tokens_dir / 'train'} has vocab_size {train_set.info.vocab_size}, "
f"the model expects {model_cfg.tokens}"
)
train_loader = make_loader(train_set, cfg.batch_size, seed=cfg.seed, workers=cfg.workers)
val_loader = make_loader(
val_set, cfg.batch_size, seed=cfg.seed, workers=0, shuffle=False, drop_last=False
)
if not len(train_loader):
raise ValueError(f"{tokens_dir / 'train'} has fewer than {cfg.batch_size} windows")
model = PositionEncoder(model_cfg).to(where)
vocab = model_cfg.tokens
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)

src/rukh/train/mmm.pylíneas 246-275 · p3

La comprobación del vocabulario evita el error más caro: entrenar un encoder de 2 030 tokens sobre un pack de otro tamaño. El meta.json del pack guarda su vocab_size, así que el fallo sale antes de crear el modelo y con los dos números en el mensaje. shuffle=False en validación hace que los primeros eval_batches lotes sean siempre los mismos: la otra mitad de la reproducibilidad que da la semilla del enmascarado. Y if not len(train_loader) evita que un pack más pequeño que un lote deje el bucle colgado esperando datos.

src/rukh/train/mmm.py
autocast, use_bf16 = autocast_for(cfg, where)
generator = masking_generator(cfg.masking.seed, where)
def probe(compiled: nn.Module) -> None:
"""One masked step of the training shape, to force compilation here and not mid-run.
With the padding mask the loop actually passes: probing with ``attention_mask=None``
compiles a graph the loop never runs and buys a recompilation at step 1, which is the
very cost this probe exists to pay up front.
"""
warm = torch.full((cfg.batch_size, cfg.block), max(MOVE_CONTROL_IDS) + 1, device=where)
labels = torch.full_like(warm, MMM_IGNORE_INDEX)
labels[:, 0] = warm[:, 0]
_, loss = masked_step(model, compiled, warm, labels, model.padding_mask(warm))
loss.backward()
with autocast: # compile under the same precision the loop will use
runnable = maybe_compile(model, cfg.compile, probe=probe)
runnable.train()

src/rukh/train/mmm.pylíneas 277-295 · p3

Es el sondeo del que hablaba maybe_compile. El lote falso se rellena con la primera jugada de verdad y no con ceros, que serían todo relleno y harían fallar _key_mask por algo ajeno a compilar. labels[:, 0] = warm[:, 0] deja una etiqueta puntuable, porque con todas a -100 la entropía cruzada sale nan. Y el with autocast: evita compilar en fp32 para ejecutar en bf16, que obligaría a recompilar en el primer paso.

src/rukh/train/mmm.py
out_dir = run_dir(cfg, resume)
out_dir.mkdir(parents=True, exist_ok=True)
vocab_hash = read_vocab_hash(tokens_dir / "train")
manifest_sha = read_manifest_sha(paths.data_dir() / "raw" / "manifest.json")
tokens_per_step = cfg.batch_size * cfg.grad_accum * cfg.block
batches = forever(train_loader)
skip_batches(batches, start_step * cfg.grad_accum)
final = out_dir / step_name(cfg.max_steps)
from rukh.tracking import start_run
params = {
**cfg.model_dump(mode="json"),
"vocab_hash": vocab_hash,
"data_manifest_sha": manifest_sha,
"device": str(where),
"num_params": model.num_params(),
}
tags = {"objective": "mmm", "input": cfg.input}
with start_run(out_dir.name, params, tags=tags, run_id=run_id) as run:
log.info("run %s in %s on %s", run.info.run_id, out_dir, where)
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=model_cfg,
vocab_hash=vocab_hash,
manifest_sha=manifest_sha,
best_val=best_val,
run_id=this_run,
)

src/rukh/train/mmm.pylíneas 297-332 · p3

skip_batches es el reanudado de M2: una tirada que sigue en el paso 6 000 adelanta el flujo 18 000 lotes para no repetir partidas. Las tags permiten filtrar en MLflow los preentrenamientos del encoder sin abrir ninguna configuración.

src/rukh/train/mmm.py
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)
# Weighted by hidden positions, on the device: a micro-batch where the draw hid
# fewer moves must not weigh as much as one where it hid many.
loss_sum = torch.zeros((), device=where)
hidden_tokens = torch.zeros((), device=where)
for _ in range(cfg.grad_accum):
x, _ = next(batches)
x = x.to(where)
inputs, labels = apply_masking(x, cfg.masking, vocab, cfg.input, generator)
with autocast:
_, loss = masked_step(model, runnable, inputs, labels, model.padding_mask(x))
(loss / cfg.grad_accum).backward()
tokens = (labels != MMM_IGNORE_INDEX).sum()
loss_sum += loss.detach().float() * tokens
hidden_tokens += tokens
grad_norm = float(nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip))
optimizer.step()

src/rukh/train/mmm.pylíneas 334-353 · p3

Es el paso del decoder con el enmascarado en medio. Los acumuladores son tensores en la GPU para no sincronizar con la CPU en cada +=; la pérdida se divide por grad_accum porque los gradientes de los micro-lotes se suman, y clip_grad_norm_ recorta sobre el modelo, no sobre el envoltorio compilado.

src/rukh/train/mmm.py
done = step + 1
last = done == cfg.max_steps
if done % cfg.log_every == 0 or last:
counted = float(hidden_tokens.item())
total = float(loss_sum.item()) / counted if counted else math.nan
elapsed = max(time.perf_counter() - clock, 1e-9)
steps = min(cfg.log_every, done - start_step)

src/rukh/train/mmm.pylíneas 355-361 · p3

Los dos .item() son la única lectura del dispositivo y están dentro del if: una sincronización cada diez pasos. El math.nan es el lote sin nada tapado, que log_metrics filtra.

src/rukh/train/mmm.py
log_metrics(
{
"train/loss": total,
"lr": lr,
"grad_norm": grad_norm,
"tokens_per_s": tokens_per_step * steps / elapsed,
"masked_tokens_per_s": counted * steps / elapsed,
},
step=done,
)

src/rukh/train/mmm.pylíneas 362-371 · p3

src/rukh/train/mmm.py
clock = time.perf_counter()
if done % cfg.eval_every == 0 or last:
val_loss, top1 = evaluate_mmm(
model,
runnable,
val_loader,
cfg.eval_batches,
where,
cfg.masking,
vocab,
cfg.input,
autocast if use_bf16 else None,
)
log_metrics({"val/loss": val_loss, "val/top1": top1}, step=done)
log.info("step %d val/loss %.4f val/top1 %.4f", done, val_loss, top1)
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 final

src/rukh/train/mmm.pylíneas 372-394 · p3

Los clock = time.perf_counter() reinician el cronómetro tras cada cosa que no es entrenar; sin ellos, el rendimiento del paso siguiente a una evaluación incluiría sus segundos. return final devuelve el último checkpoint, no el mejor: best.pt es para evaluar y publicar, step-N.pt para reanudar.

tokens_per_s cuenta todo lo que procesó la GPU y es comparable con el decoder; masked_tokens_per_s cuenta solo lo que puntúa. Entre las dos se ve que el 85 % del cómputo de este preentrenamiento no produce señal de aprendizaje: la diferencia de eficiencia entre el MLM y la predicción del siguiente token, en una cifra.

La configuración del hito

configs/train/encoder-mmm.yaml
# The bidirectional `PositionEncoder` (8 layers, d=384, 6 heads, 15,052,800 parameters)
# pretrained with masked move modeling on the very same packed UCI stream the decoder trains on,
# so the two models can be compared on the same games. Roughly a third of `small`'s size, so the
# 12 000 steps below are well under an hour on a 5090.
input: moves
tokens_dir: data/tokens/uci
block: 200
batch_size: 96
grad_accum: 3 # 288 effective sequences of 200 tokens
lr: 5.0e-4
min_lr_ratio: 0.1
warmup: 1000
max_steps: 12000
weight_decay: 0.1
betas: [0.9, 0.95]
grad_clip: 1.0
precision: bf16
compile: true
eval_every: 500
eval_batches: 50
ckpt_every: 1000
out_dir: checkpoints
seed: 42
run_name: encoder-mmm
# A timestamp is appended: a second run must not overwrite the step-*.pt series.
unique_run_name: true
workers: 4
log_every: 10
masking:
# BERT's recipe, unchanged: 15 % of the *moves* are hidden, and of those 80 % become <mask>,
# 10 % become another move and 10 % are left alone. Control tokens are never hidden.
prob: 0.15
mask_ratio: 0.8
random_ratio: 0.1
keep_ratio: 0.1
seed: 0

configs/train/encoder-mmm.yamllíneas 1-36 · p3

Casi todo son valores por defecto escritos otra vez, a propósito: una configuración completa es el registro de la tirada. Tres valores cambian:

  • batch_size: 96 y grad_accum: 3: 288 secuencias efectivas, que caben porque el encoder tiene un tercio de los parámetros y compensan en parte que solo puntúe el 15 % de las posiciones.
  • lr: 5.0e-4, algo más baja que la del decoder, lo habitual con una señal por paso más escasa y ruidosa.
  • max_steps: 12000: el presupuesto del módulo, dieciséis minutos, no un punto de convergencia.

Ojo a la forma del lr: en YAML 1.1 (el de PyYAML), 5e-4 sin punto decimal se lee como cadena. Pydantic la convertiría, pero con un cargador menos estricto la tasa de aprendizaje sería texto.

La orden

En M2, train era un comando suelto. Para colgar rukh train encoder, Typer pide convertirlo en un grupo: un typer.Typer propio montado con add_typer, al principio de cli.py, después de publish_app:

src/rukh/cli.py
train_app = typer.Typer(
help="Train a model: the decoder by default, a subcommand for the encoder.",
invoke_without_command=True,
)
app.add_typer(train_app, name="train")

src/rukh/cli.pylíneas 30-34 · p3

Un grupo de Typer exige por defecto un subcomando, y rukh train --config … se quedaría sin decoder; invoke_without_command=True lo evita. La otra mitad: el comando del decoder pasa a ser el callback del grupo, la función que Typer ejecuta siempre que se invoca rukh train:

src/rukh/cli.py
@train_app.callback(invoke_without_command=True)
def train_cmd(
ctx: typer.Context,
config: Annotated[
Path | None,
typer.Option(
"--config", exists=True, dir_okay=False, readable=True, help="Training YAML config."
),
] = None,
model_preset: Annotated[
str | None, typer.Option("--preset", help="Override the preset: tiny, small or medium.")
] = None,
resume: Annotated[
Path | None,
typer.Option(
"--resume", exists=True, dir_okay=False, readable=True, help="Checkpoint to continue."
),
] = None,
max_steps: Annotated[
int | None, typer.Option("--max-steps", help="Override max_steps from the config.")
] = None,
) -> None:
"""Train a MoveDecoder from a packed token stream, logging the run to MLflow.
``rukh train --config ...`` is the decoder, exactly as it always was; the subcommands train
the other models (``rukh train encoder``).
"""
from rukh.config import load_yaml
from rukh.models import PRESETS
from rukh.train import TrainConfig, train
if ctx.invoked_subcommand is not None:
return
if config is None:
typer.echo("error: --config is required (see rukh train --help)", err=True)
raise typer.Exit(code=2)
cfg = load_yaml(config, TrainConfig)
if model_preset is not None:
if model_preset not in PRESETS:
typer.echo(f"error: --preset must be one of {', '.join(PRESETS)}", err=True)
raise typer.Exit(code=2)
cfg = cfg.model_copy(update={"preset": model_preset, "model": None})
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:
checkpoint = train(cfg, resume=resume)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
typer.echo(f"preset: {cfg.preset}")
typer.echo(f"steps: {cfg.max_steps}")
typer.echo(f"checkpoint: {checkpoint}")

src/rukh/cli.pylíneas 336-388 · p3

Los cambios respecto a M2 salen de que ahora es un callback. Con rukh train encoder … se ejecuta primero y tiene que salir sin hacer nada, de ahí ctx.invoked_subcommand. --config pasa a ser opcional, porque si no, el grupo lo pediría también para el encoder; y como Typer ya no lo exige, lo exige el comando, con el mismo código de salida 2. El subcomando del encoder se cuelga de train_app:

src/rukh/cli.py
@train_app.command("encoder")
def train_encoder_cmd(
config: Annotated[
Path,
typer.Option(
"--config", exists=True, dir_okay=False, readable=True, help="Training YAML config."
),
],
resume: Annotated[
Path | None,
typer.Option(
"--resume", exists=True, dir_okay=False, readable=True, help="Checkpoint to continue."
),
] = None,
max_steps: Annotated[
int | None, typer.Option("--max-steps", help="Override max_steps from the config.")
] = None,
) -> None:
"""Pretrain the PositionEncoder with masked move modeling, logging the run to MLflow."""
from rukh.config import load_yaml
from rukh.train import MmmConfig, train_mmm
cfg = load_yaml(config, MmmConfig)
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:
checkpoint = train_mmm(cfg, resume=resume)
except (FileNotFoundError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
typer.echo(f"input: {cfg.input}")
typer.echo(
f"masking: {cfg.masking.prob:.0%} at "
f"{cfg.masking.mask_ratio:.0%}/{cfg.masking.random_ratio:.0%}/"
f"{cfg.masking.keep_ratio:.0%}"
)
typer.echo(f"steps: {cfg.max_steps}")
typer.echo(f"checkpoint: {checkpoint}")

src/rukh/cli.pylíneas 391-429 · p3

El decoder sigue siendo rukh train --config …. Un rukh train decoder habría sido más limpio, pero habría roto todas las órdenes de M2 del curso y del README. El resumen final repite la receta del enmascarado para que viaje con la salida cuando la pegues en un cuaderno.

Los tests del enmascarado

tests/unit/test_mmm.py
VOCAB = 128 # above the 62 control ids, so there are real moves to hide
BLOCK = 16
FIRST_MOVE = max(MOVE_CONTROL_IDS) + 1
TOY = EncoderConfig(vocab_size=VOCAB, n_layer=2, n_head=2, d_model=32, block=BLOCK, dropout=0.0)
START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
def moves_batch(rows: int = 50, cols: int = 200, seed: int = 0) -> torch.Tensor:
"""``rows * cols`` move tokens, none of them a control token."""
generator = torch.Generator().manual_seed(seed)
return torch.randint(FIRST_MOVE, 2030, (rows, cols), generator=generator)

tests/unit/test_mmm.pylíneas 24-34 · p3

moves_batch produce diez mil tokens, lo que hace falta para comprobar un 15 % con dos puntos de tolerancia: la desviación típica queda en unas 0,36 décimas de punto. Con cien tokens, el test fallaría una de cada diez veces.

tests/unit/test_mmm.py
def test_the_80_10_10_split_holds_over_ten_thousand_tokens() -> None:
batch = moves_batch()
assert batch.numel() == 10_000
cfg = MaskingConfig()
inputs, labels = apply_masking(batch, cfg, 2030, generator=masking_generator(7))
selected = labels != MMM_IGNORE_INDEX
picked = int(selected.sum())
assert abs(picked / batch.numel() - cfg.prob) < 0.02
masked = int((inputs == MASK_ID).sum())
kept = int((selected & (inputs == batch)).sum())
replaced = picked - masked - kept
assert abs(masked / picked - cfg.mask_ratio) < 0.04
assert abs(replaced / picked - cfg.random_ratio) < 0.04
assert abs(kept / picked - cfg.keep_ratio) < 0.04
# Nothing outside the selection was touched.
assert torch.equal(inputs[~selected], batch[~selected])
def test_labels_are_minus_100_exactly_where_nothing_was_hidden() -> None:
batch = moves_batch(rows=8, cols=32, seed=1)
inputs, labels = apply_masking(batch, MaskingConfig(), 2030, generator=masking_generator(3))
selected = labels != MMM_IGNORE_INDEX
assert torch.equal(labels[selected], batch[selected]) # the label is the original token
assert set(labels[~selected].tolist()) == {MMM_IGNORE_INDEX}
assert inputs.shape == labels.shape == batch.shape
# A kept 10 % looks untouched in the input but still carries a label: that is the point.
assert bool((selected & (inputs == batch)).any())

tests/unit/test_mmm.pylíneas 110-137 · p3

El primero es el test que cuenta en lugar de creerse el código, el que la lección 1 anunciaba para cazar el fallo que no da error. kept («elegida y sin cambiar») es una cota superior del 10 % real, porque una sustitución puede caer por azar en el mismo token (una entre dos mil, dentro de la tolerancia), y replaced se calcula por diferencia porque no se puede contar directamente. La última aserción caza el fallo más destructivo: sin el selected &, se taparía el 80 % de todas las posiciones y el test de proporciones seguiría pasando, porque mide sobre las etiquetadas.

El segundo fija el contrato de las etiquetas: el token original donde se eligió, solo -100 donde no, y al menos una posición elegida sin cambiar, que es lo que se rompe si alguien «simplifica» la receta a 90/10.

tests/unit/test_mmm.py
def test_control_tokens_are_never_hidden() -> None:
tok = UciTokenizer()
games = [
tok.encode_game("e2e4 c7c5 g1f3 d7d6 d2d4", 1800, 1900, "1-0"),
tok.encode_game("d2d4 g8f6 c2c4 e7e6", 2100, 2000, "0-1"),
]
width = max(len(game) for game in games)
batch = torch.tensor([game + [0] * (width - len(game)) for game in games])
inputs, labels = apply_masking(
batch, MaskingConfig(prob=1.0), len(tok), generator=masking_generator(5)
)
control = torch.isin(batch, torch.tensor(sorted(MOVE_CONTROL_IDS)))
assert torch.equal(inputs[control], batch[control]) # <bos>, Elo, result, <eos>, <pad>
assert set(labels[control].tolist()) == {MMM_IGNORE_INDEX}
assert torch.equal(labels[~control], batch[~control]) # every move is hidden at prob 1
assert len(MOVE_CONTROL_IDS) == 62 and sorted(MOVE_CONTROL_IDS) == list(range(62))
def test_a_random_replacement_is_always_a_real_move() -> None:
batch = moves_batch(rows=20, cols=100, seed=2)
only_random = MaskingConfig(prob=1.0, mask_ratio=0.0, random_ratio=1.0, keep_ratio=0.0)
inputs, _ = apply_masking(batch, only_random, 2030, generator=masking_generator(11))
assert int(inputs.min()) >= FIRST_MOVE # never a fake Elo, result or <mask>
assert int(inputs.max()) < 2030
assert not torch.equal(inputs, batch)

tests/unit/test_mmm.pylíneas 140-164 · p3

Los dos usan una técnica que merece copiarse: llevar un parámetro a su extremo para convertir una propiedad probabilística en una determinista. Con prob=1.0, «los tokens de control nunca se tapan» pasa a ser una igualdad que se comprueba con torch.equal; con random_ratio=1.0, todo es ruido y su rango se acota con min y max. La última línea del primero fija que los ids de control sean un prefijo contiguo, la propiedad de la que depende el sorteo del ruido.

tests/unit/test_mmm.py
def test_the_masking_is_reproducible_with_a_fixed_seed() -> None:
batch = moves_batch(rows=8, cols=64, seed=4)
cfg = MaskingConfig()
first = apply_masking(batch, cfg, 2030, generator=masking_generator(cfg.seed))
same = apply_masking(batch, cfg, 2030, generator=masking_generator(cfg.seed))
other = apply_masking(batch, cfg, 2030, generator=masking_generator(cfg.seed + 1))
assert torch.equal(first[0], same[0]) and torch.equal(first[1], same[1])
assert not torch.equal(first[0], other[0])
# One generator used twice keeps moving, so a run does not hide the same moves every step.
generator = masking_generator(cfg.seed)
step_one = apply_masking(batch, cfg, 2030, generator=generator)
step_two = apply_masking(batch, cfg, 2030, generator=generator)
assert torch.equal(step_one[0], first[0])
assert not torch.equal(step_two[0], step_one[0])

tests/unit/test_mmm.pylíneas 167-180 · p3

Este test comprueba dos propiedades del generador que se confunden con facilidad. Dos generadores con la misma semilla tapan lo mismo: eso es reproducibilidad, y es lo que la evaluación reinicia. Un mismo generador usado dos veces avanza y tapa otra cosa: eso es lo que el entrenamiento deja correr, porque con los mismos huecos en cada paso el modelo los memorizaría.

tests/unit/test_mmm.py
def test_the_squares_scheme_hides_squares_and_keeps_its_own_control_tokens() -> None:
batch = torch.tensor([fen_to_tokens(START), fen_to_tokens(f"{START} 30 40")])
inputs, labels = apply_masking(
batch,
MaskingConfig(prob=1.0),
SQUARE_VOCAB_SIZE,
scheme="squares",
generator=masking_generator(1),
)
control = torch.isin(batch, torch.tensor(sorted(SQUARE_CONTROL_IDS)))
assert control_ids("squares") == SQUARE_CONTROL_IDS
assert bool(control[:, 0].all()) # <cls> is the only control token a FEN produces
assert torch.equal(inputs[control], batch[control])
assert set(labels[control].tolist()) == {MMM_IGNORE_INDEX}
assert bool((inputs == SQUARE_MASK_ID).any())
def test_a_masking_config_must_add_up() -> None:
with pytest.raises(ValueError, match="add up to 1"):
MaskingConfig(mask_ratio=0.5, random_ratio=0.1, keep_ratio=0.1)
with pytest.raises(ValueError, match="prob must be"):
MaskingConfig(prob=0.0)
with pytest.raises(ValueError, match="non-negative"):
MaskingConfig(mask_ratio=1.2, random_ratio=-0.2, keep_ratio=0.0)
with pytest.raises(ValueError):
MaskingConfig(probability=0.15) # type: ignore[call-arg]
with pytest.raises(ValueError, match="all control tokens"):
apply_masking(torch.ones((2, 2), dtype=torch.long), MaskingConfig(), 10)

tests/unit/test_mmm.pylíneas 183-210 · p3

En el esquema de casillas no hay relleno (siempre 69 tokens) y el único token de control es el primero, <cls>. MaskingConfig(probability=0.15) prueba extra="forbid" con un nombre plausible: quien escriba probability en el YAML se lleva un error, no un 15 % por defecto que nunca pidió.

tests/unit/test_mmm.py
def test_masked_step_agrees_with_the_model_method() -> None:
torch.manual_seed(0)
model = PositionEncoder(TOY).eval()
batch = torch.randint(FIRST_MOVE, VOCAB, (3, BLOCK))
inputs, labels = apply_masking(batch, MaskingConfig(), VOCAB, generator=masking_generator(2))
with torch.no_grad():
logits, loss = masked_step(model, model, inputs, labels, model.padding_mask(inputs))
reference = model.masked_lm(inputs, labels, model.padding_mask(inputs))
assert torch.allclose(logits, reference[0], atol=1e-6)
assert torch.allclose(loss, reference[1], atol=1e-6)

tests/unit/test_mmm.pylíneas 213-222 · p3

Hay dos implementaciones de lo mismo: masked_lm, la cómoda, y masked_step, la que la compilación necesita. Dos caminos que deben dar el mismo número acaban divergiendo el día que alguien toca uno; este test convierte esa divergencia en un rojo.

tests/unit/test_mmm.py
def test_the_run_is_named_after_the_encoder_by_default() -> None:
assert run_dir(MmmConfig()).name.startswith("encoder-moves-")
assert run_dir(MmmConfig(run_name="mmm", unique_run_name=False)).name == "mmm"
def test_cli_train_encoder_reads_the_shipped_config(repo_root: Path) -> None:
from typer.testing import CliRunner
from rukh.cli import app
from rukh.config import load_yaml
cfg = load_yaml(repo_root / "configs" / "train" / "encoder-mmm.yaml", MmmConfig)
assert cfg.block == 200 and cfg.input == "moves"
assert cfg.encoder().n_layer == 8 and cfg.encoder().d_model == 384
assert cfg.masking.mask_ratio == 0.8
result = CliRunner().invoke(app, ["train", "encoder", "--help"])
assert result.exit_code == 0, result.output
for option in ("--config", "--resume", "--max-steps"):
assert option in result.output
# The decoder keeps the old spelling: `rukh train --config ...`, no subcommand.
plain = CliRunner().invoke(app, ["train", "--help"])
assert plain.exit_code == 0 and "--preset" in plain.output
assert CliRunner().invoke(app, ["train"]).exit_code == 2

tests/unit/test_mmm.pylíneas 270-292 · p3

El último vigila que el decoder conserve su grafía: rukh train --help sigue enseñando --preset y rukh train a secas sale con código 2. Además carga el YAML que se distribuye, no uno de juguete: un campo renombrado en MmmConfig y no en el YAML falla en la CI, no al lanzar el entrenamiento.

Otros tres tests ejercitan el bucle sobre un pack de juguete: una tirada corta que baja la pérdida y deja sus checkpoints, un reanudado que acaba en la misma ejecución de MLflow y un pack con otro vocab_size que se rechaza. El fichero entero se ejecuta en la lección siguiente, cuando llega el __init__.py que exporta MmmConfig y load_encoder.

// Ejercicio 01Un solo sorteo en vez de dos

Cambia apply_masking para usar un único tensor aleatorio: draw < 0.12 para <mask>, 0.12 <= draw < 0.135 para el ruido y 0.135 <= draw < 0.15 para el intacto. Ejecuta uv run pytest tests/unit/test_mmm.py -q. ¿Pasan los tests? ¿Qué propiedad se ha perdido y cómo escribirías un test que la detecte?

// SoluciónVer la solución

Pasan casi todos, y ese es el punto del ejercicio: las proporciones globales siguen siendo 15 % y 80/10/10, así que test_the_80_10_10_split_holds_over_ten_thousand_tokens no se entera. Lo que se ha perdido es la independencia entre las dos decisiones: la posición enmascarada es siempre la que sacó el número más bajo, así que el reparto dentro de las elegidas es un orden, no un azar.

Un test que lo detecte tiene que mirar la correlación en vez de las proporciones: con prob=1.0, y guardando para cada posición si fue tapada, cuál fue su rango dentro del lote. Con dos sorteos, la proporción de tapadas debería ser 80 % en cualquier subconjunto de posiciones que se elija; con uno, es 100 % en el primer 80 % del orden y 0 % en el resto. Basta con partir el lote por la mitad según el valor de un primer torch.rand reproducido aparte y comprobar que las dos mitades tienen la misma proporción de <mask>.

Qué has aprendido

Compartir la receta es la condición para comparar dos números, y el punto donde los dos bucles se separan cabe en una función, masked_step. El enmascarado usa dos sorteos independientes, no toca el bloque de control y valida con la semilla reiniciada, porque la tarea de evaluación es parte del instrumento de medida.

Cómo se mide: uv run pytest tests/unit/test_mmm.py -q pasa los trece tests, y uv run rukh train encoder --config configs/train/encoder-mmm.yaml deja checkpoints/encoder-mmm-<fecha>/ con su serie de checkpoints. Las cifras de esa tirada —dieciséis minutos, 75,2 % de acierto en las jugadas tapadas— están en la lección 11.

Lo siguiente es el afinado: la tabla supervisada que hay que construir antes, los tres modos de congelación y la curva por número de etiquetas.