rukh · lab

// M2 · lección 03

La receta de entrenamiento

AdamW, warmup y coseno, clipping y bf16, con el bucle entero de rukh train delante: el planificador que fija la tasa de cada paso, los grupos de decaimiento, la acumulación de gradiente ponderada por tokens, los checkpoints con procedencia y las dos configuraciones del repositorio.

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

Qué vas a construir

uv run rukh train --config configs/train/small.yaml, y el código de src/rukh/train/ que hay detrás. Al terminar tendrás tiny entrenado en 189 segundos y small en 42 minutos, con sus veinte checkpoints, sus curvas en MLflow y una propiedad de la que depende todo lo demás: si el proceso se corta, --resume continúa donde estaba.

La receta (AdamW, warmup y coseno, recorte de gradiente y bf16) es, con otros números, la de cualquier LLM que se preentrena hoy. El objetivo es que cada número tenga un motivo que sepas defender.

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

LabRutaRelojDejaAtajo
tinyEntrenar tiny de principio a fin, para ver el bucle entero funcionarcuesta máquina3 min de entrenamiento + ~10 de suite rápidacheckpoints/tiny-<fecha>/best.pt, artifacts/eval/tiny/ (con --stage tiny)desde M6, rukh pull tiny (deja checkpoints/tiny/best.pt)
smallsmall entero: 99,4 % de legalidad y 1007 de Elo con la escalera de p2cuesta máquina42 min + ~30 por cada evaluación de 160 partidas (small-greedy y small)checkpoints/small-<fecha>/best.pt y sus filas small-greedy y small en results.jsondesde M6, rukh pull small baja el small publicado, que es la v3 de la lección 11
cuesta máquina
Tiempo real de GPU, red o motor. El reloj es el de la RTX 5090 de referencia.

Teoría justa: los cinco números de la receta

AdamWAdamWOptimizador 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. con β = 0,9 / 0,95. Adam guarda dos medias móviles por parámetro, la del gradiente (β₁, una especie de inercia) y la de su cuadrado (β₂), y divide una por la raíz de la otra: cada parámetro recibe un paso normalizado por lo ruidoso que es su gradiente. El β₂ por defecto de PyTorch, 0,999, promedia unos mil pasos; en modelos de lenguaje se baja a 0,95 (unos veinte) porque la escala de los gradientes cambia deprisa al principio. Es el valor de GPT-3 y de nanoGPT.

Weight decay 0,1, solo en las matrices. El decaimiento resta en cada paso una fracción del propio peso, así que solo se queda grande lo que el gradiente sostiene. En las matrices es regularización clásica. En los LayerNorm y los sesgos no, porque su tamaño es una escala o un desplazamiento, no capacidad.

WarmupWarmupArranque en el que la tasa de aprendizaje sube linealmente desde cero durante los primeros pasos (1 000 en small) antes de empezar a decaer. Evita que Adam dé pasos enormes mientras sus medias móviles todavía se estiman con un puñado de gradientes —en el paso 1, con uno solo—, que es cuando un modelo recién inicializado se rompe: no con un NaN, sino cayendo en predecir siempre las jugadas más frecuentes del corpus. de 1 000 pasos y después coseno. La tasa sube en línea recta de cero a 6e-4 en el paso 1 000 y desde ahí baja en medio coseno hasta el 10 % en el paso 20 000.

¿Por qué el warmup? En el paso 1, Adam estima el segundo momento con un único gradiente, y el cociente es casi arbitrario: como calcular tu velocidad media cuando llevas recorrido el primer metro. Con el paso normalizado, un parámetro puede moverse de golpe la tasa entera; sobre una red recién inicializada eso basta para que el softmax de la atención sature y el modelo caiga en un mínimo tonto, predecir siempre las jugadas más frecuentes. No hay un NaN espectacular: la curva se queda plana y no baja nunca. El warmup cuesta el 5 % de los pasos. El coseno es la lógica inversa: cerca del mínimo, pasos grandes solo te sacan de él, y el suelo del 10 % evita que los últimos miles de pasos no hagan nada.

Clippingrecorte de gradienteReescalar el vector de gradientes cuando su norma global supera un umbral (1,0 en Rukh), antes de cada paso del optimizador. Es un seguro contra un lote patológico: sin él, una secuencia rara puede mover los pesos lo suficiente para tirar horas de entrenamiento. El grad_norm registrado en MLflow pegado al umbral paso tras paso significa que la tasa de aprendizaje es alta. a 1,0. Si la norma global del gradiente pasa de 1,0, se reescala el vector entero. Es un seguro contra un lote raro que tiraría horas de entrenamiento. El grad_norm se registra en MLflow y es el mejor termómetro: debe bajar y estabilizarse. Si está pegado a 1,0 paso tras paso, el recorte salta siempre y el problema es otro: la tasa de aprendizaje es demasiado alta.

bf16bfloat16Formato de coma flotante de 16 bits con los mismos 8 bits de exponente que fp32 y solo 7 de mantisa: pierde precisión pero conserva el rango, así que un gradiente pequeño no se va a cero. Por eso entrena sin escalado de pérdida, al contrario que fp16, cuyo exponente de 5 bits desborda por abajo., y por qué no fp16. Los dos son de 16 bits y doblan el rendimiento, pero reparten los bits distinto. fp16 tiene 5 de exponente y 10 de mantisa: mucha precisión y poco rango, con el menor número normal hacia 6·10⁻⁵, justo donde viven los gradientes de una red profunda. Por eso entrenar en fp16 exige un GradScaler y te regala algún NaN a las tres de la mañana. bf16 tiene 8 de exponente, el mismo rango que fp32, y 7 de mantisa: nada se desborda por abajo. Son dos reglas del mismo precio: fp16 marca décimas de milímetro pero solo llega a 20 centímetros; bf16 marca milímetros y llega al metro. Para un gradiente, que puede ser minúsculo o enorme y casi nunca necesita la tercera cifra, sirve la larga. Los pesos maestros y el optimizador siguen en fp32; bf16 es solo la precisión dentro del autocast.

schedule.py: la tasa de aprendizaje de cada paso

El fichero más pequeño del módulo, y el que separa una ejecución que converge de una que se queda plana veinte mil pasos.

src/rukh/train/schedule.py
"""Learning-rate schedule: a linear warmup followed by a cosine decay to a floor."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover - only for type checkers
from rukh.train.loop import TrainConfig

src/rukh/train/schedule.pylíneas 1-9 · p2

El TYPE_CHECKING evita un import circular: loop.py importa lr_at y schedule.py solo necesita el tipo de TrainConfig para anotarse.

src/rukh/train/schedule.py
def lr_at(step: int, cfg: TrainConfig) -> float:
"""Learning rate for ``step`` (0-based).
It rises linearly from 0 to ``cfg.lr`` over the first ``cfg.warmup`` steps, then follows a
cosine down to ``cfg.min_lr_ratio * cfg.lr`` at ``cfg.max_steps`` and stays there for any
later step (so a resumed run past ``max_steps`` never gets a negative or rising rate).
"""
if step < 0:
raise ValueError(f"step must be non-negative, got {step}")
floor = cfg.lr * cfg.min_lr_ratio
if step < cfg.warmup:
return cfg.lr * step / cfg.warmup
span = cfg.max_steps - cfg.warmup
if span <= 0:
return floor
progress = min(1.0, (step - cfg.warmup) / span)
return floor + 0.5 * (1.0 + math.cos(math.pi * progress)) * (cfg.lr - floor)

src/rukh/train/schedule.pylíneas 12-28 · p2

Lo interesante son las guardas. step < 0 es un error, para enterarte ahí y no cuarenta minutos después. span <= 0 devuelve el suelo: pasa cuando alguien lanza --max-steps 300 sobre una configuración con warmup: 500 para probar algo rápido. Y el min(1.0, …) deja la tasa clavada en el suelo pasado max_steps, así que una ejecución reanudada nunca recibe una tasa creciente.

Los tests del planificador se pueden saltar en la primera lectura; fijan la forma de la curva de arriba. Si los saltas, tráelos: git checkout p2 -- tests/unit/test_schedule.py, porque el «Ejecútalo» de los checkpoints los ejecuta.

// Teststests/unit/test_schedule.py: warmup lineal, coseno hasta el suelo y los casos degenerados
tests/unit/test_schedule.py
"""Tests for rukh.train.schedule: linear warmup then cosine decay to a floor."""
from __future__ import annotations
import pytest
from rukh.train import TrainConfig, lr_at
pytestmark = pytest.mark.unit
CFG = TrainConfig(lr=6e-4, min_lr_ratio=0.1, warmup=100, max_steps=1000)
def test_warmup_is_linear_and_reaches_the_peak() -> None:
assert lr_at(0, CFG) == 0.0
assert lr_at(50, CFG) == pytest.approx(CFG.lr / 2)
assert lr_at(100, CFG) == pytest.approx(CFG.lr)
rising = [lr_at(step, CFG) for step in range(101)]
assert rising == sorted(rising)
deltas = {round(b - a, 12) for a, b in zip(rising, rising[1:], strict=False)}
assert len(deltas) == 1

tests/unit/test_schedule.pylíneas 1-21 · p2

El test del warmup comprueba que todos los incrementos son el mismo número (el conjunto de deltas tiene un solo elemento). Eso es «lineal»; tres puntos los cumple cualquier curva creciente.

tests/unit/test_schedule.py
def test_cosine_decays_to_the_floor_and_stays_there() -> None:
floor = CFG.lr * CFG.min_lr_ratio
assert lr_at(1000, CFG) == pytest.approx(floor)
assert lr_at(5000, CFG) == pytest.approx(floor)
falling = [lr_at(step, CFG) for step in range(100, 1001, 10)]
assert falling == sorted(falling, reverse=True)
assert all(value >= floor - 1e-12 for value in falling)
halfway = lr_at(550, CFG)
assert halfway == pytest.approx(floor + 0.5 * (CFG.lr - floor))
def test_without_warmup_the_first_step_is_the_peak() -> None:
cfg = CFG.model_copy(update={"warmup": 0})
assert lr_at(0, cfg) == pytest.approx(cfg.lr)
def test_degenerate_schedule_returns_the_floor() -> None:
cfg = CFG.model_copy(update={"warmup": 1000, "max_steps": 1000})
assert lr_at(1000, cfg) == pytest.approx(cfg.lr * cfg.min_lr_ratio)
with pytest.raises(ValueError, match="non-negative"):
lr_at(-1, CFG)
def test_config_rejects_unknown_keys() -> None:
with pytest.raises(ValueError):
TrainConfig(learning_rate=1e-3) # type: ignore[call-arg]

tests/unit/test_schedule.pylíneas 24-49 · p2

Los demás cubren el descenso monótono, el punto medio (que tiene que valer la media entre pico y suelo, la propiedad que confirma que la fórmula es la que crees) y los casos degenerados. El último es de la configuración: TrainConfig(learning_rate=…) tiene que explotar, porque un YAML con esa clave mal escrita entrenaría con 6e-4 sin decir nada.

loop.py: el bucle

Buena parte del fichero es contabilidad para que una ejecución sea reproducible seis meses después. La cabecera resume las decisiones.

src/rukh/train/loop.py
"""The training loop: AdamW with a cosine schedule, bf16, checkpoints and MLflow logging.
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.
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.
"""

src/rukh/train/loop.pylíneas 1-18 · p2

Fíjate en el párrafo de reanudar: «indistinguible de no haber parado nunca» es un requisito que el código cumple más abajo.

Las importaciones no tienen nada que decidir, pero sin ellas loop.py no importa: se leen por encima y se teclean igual (o trae el fichero entero, como dice el recuadro del principio).

// Fontaneríaloop.py: importaciones y logger
src/rukh/train/loop.py
from __future__ import annotations
import logging
import math
import os
import time
from collections.abc import 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.models import DecoderConfig, MoveDecoder, preset
from rukh.tokenize.loader import IGNORE_INDEX, PackedDataset, make_loader
from rukh.train.checkpoint import (
BEST_NAME,
load_checkpoint,
read_manifest_sha,
read_vocab_hash,
restore,
save_checkpoint,
step_name,
)
from rukh.train.schedule import lr_at
log = logging.getLogger(__name__)
Batch = tuple[torch.Tensor, torch.Tensor]

src/rukh/train/loop.pylíneas 20-53 · p2

src/rukh/train/loop.py
class TrainConfig(BaseConfig):
"""Everything one 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
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
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 56-89 · p2

TrainConfig es la receta entera, con los valores de small por defecto. Tres campos no obvios:

  • grad_accum: 4 con batch_size: 64 son 256 secuencias efectivas. La acumulación de gradienteacumulación de gradienteEn Rukh, partir el lote que no cabe en la GPU en varias pasadas que sí caben, acumulando sus gradientes antes de un solo paso del optimizador. grad_accum: 4 con batch_size: 64 son 256 secuencias efectivas; la pérdida de cada micro-lote se divide por grad_accum para que los gradientes sumados equivalgan a una media y el tamaño del paso no dependa de cuántos micro-lotes se acumulen. parte el lote que no cabe en la GPU en cuatro pasadas que sí caben.
  • unique_run_name añade una marca de tiempo para que una segunda ejecución no sobreescriba los step-*.pt de la primera, que son los que lee la isla TrainingReplay.
  • model permite describir un modelo a mano en el YAML sin crear un preset por experimento.

decoder() aplica el block de la ejecución al modelo: la ventana del modelo y la de las secuencias que empaqueta el cargador tienen que coincidir.

src/rukh/train/loop.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},
]

src/rukh/train/loop.pylíneas 92-107 · p2

RUKH_DEVICE permite a los tests forzar CPU en una máquina con GPU. param_groups aplica el decaimiento a los tensores de dos dimensiones o más: la ganancia de un LayerNorm empieza en 1, y empujarla hacia cero apaga el canal. Son unos pocos miles de parámetros, y meterlos en el grupo equivocado degrada el entrenamiento en silencio.

src/rukh/train/loop.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

src/rukh/train/loop.pylíneas 110-113 · p2

El bucle cuenta pasos, no épocas, así que forever reinicia el cargador (y su barajado) cada vez que se agota. En la tirada de small eso ocurre 4,3 veces, cifra que la última lección del módulo convierte en un diagnóstico.

Compilar el modelo es una optimización de velocidad, no parte de la receta; puedes saltarlo en la primera lectura, pero train lo llama: tecléalo igual.

// Fontaneríamaybe_compile: forzar torch.compile al principio y caer a eager si falla
src/rukh/train/loop.py
def maybe_compile(
model: MoveDecoder, enabled: bool, sample: torch.Tensor | 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) 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 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/loop.pylíneas 116-142 · p2

torch.compile es perezoso: compila la primera vez que una pasada llega a Inductor, y en Windows sin MSVC el fallo aparecería a mitad de la ejecución. Forzarlo aquí con un lote de prueba hace que falle donde todavía se puede caer a modo eager, con un aviso. La máquina de referencia entrena small en eager por esto, y lo dice.

Los dos zero_grad importan: el lote de prueba deja gradientes, y sin borrarlos el primer paso real sumaría los de un lote de unos. No revienta nada, y por eso se escapa: un paso envenenado que la curva no deja ver.

src/rukh/train/loop.py
@torch.no_grad()
def evaluate(
model: nn.Module,
loader: DataLoader[Batch],
batches: int,
device: torch.device,
autocast: Any = None,
) -> tuple[float, float]:
"""Validation loss and top-1 next-token accuracy over at most ``batches`` batches.
The loss is token-weighted: each batch's mean is weighted by the number of non-``<pad>``
targets it had, so a short last batch does not count as much as a full one.
"""
was_training = model.training
model.eval()
loss_sum = 0.0
weighted = 0
hits = 0
counted = 0
for index, (x, y) in enumerate(loader):
if index >= batches:
break
x, y = x.to(device), y.to(device)
with autocast if autocast is not None else nullcontext():
logits, loss = model(x, y)
mask = y != IGNORE_INDEX
tokens = int(mask.sum())
if loss is not None and torch.isfinite(loss) and tokens:
loss_sum += loss.float().item() * tokens
weighted += tokens
hits += int((logits.argmax(dim=-1) == y)[mask].sum())
counted += tokens
if was_training:
model.train()
return (loss_sum / weighted if weighted else math.nan, hits / counted if counted else 0.0)

src/rukh/train/loop.pylíneas 145-179 · p2

Cuatro decisiones en la evaluación:

  1. was_training restaura el estado que había, para que evaluar un checkpoint desde un script no lo deje en modo entrenamiento.
  2. Un número fijo de lotesLote (batch)Las muestras que el modelo ve de una vez antes de un paso del optimizador; en inglés, batch, que es como se llama en el código (batch_size). La GPU quiere tensores rectangulares, así que un lote es una matriz de forma fija: en Rukh, 64 secuencias de 200 tokens. Cuanto más grande, menos ruido tiene el gradiente y más memoria cuesta; cuando no cabe, la acumulación de gradiente simula uno mayor sumando varios lotes pequeños antes del paso.: 50 de 64 × 200 son 640 000 tokens, muy por debajo de un mes entero de validación. La última lección del módulo convierte esa observación en 238 millones de tokens más para entrenar.
  3. La pérdidaPérdida (loss)El número que el entrenamiento minimiza: cuánto se equivoca el modelo sobre un lote; en inglés, loss. En el decoder es la entropía cruzada entre los logits y el token que venía de verdad, y las posiciones de relleno no cuentan (ignore_index). No se lee sola: una pérdida de 2,1 no significa nada sin saber sobre qué vocabulario se calculó, y por eso el curso la convierte en perplejidad para compararla. ponderada por tokens, no como media de medias, para que un lote con más relleno no pese como uno lleno. torch.isfinite filtra el lote todo <pad> que devuelve NaN.
  4. El top-1 se cuenta solo sobre posiciones que no son relleno; acertar <pad> es gratis.

Lo siguiente es la contabilidad de reanudar una ejecución cortada; se puede saltar en la primera lectura, pero train usa las dos funciones: tecléalas igual.

// Fontaneríarun_dir y skip_batches: dónde se escribe una ejecución y cómo se reanuda sin repetir lotes
src/rukh/train/loop.py
def run_dir(cfg: TrainConfig, 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.preset
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/loop.pylíneas 182-210 · p2

skip_batches es la mitad menos obvia de reanudar. Sin ella, la ejecución empezaría por la primera ventana de la primera épocaÉpoca (epoch)Una pasada completa por el conjunto de entrenamiento; en inglés, epoch. Sirve para contar cuántas veces se le ha enseñado el mismo dato. Con corpus grandes Rukh no llega ni a una —el afinado por maestros son 194,6 M de tokens sobre el pack de élite, 0,16 de una época, así que cada muestra que ve es nueva—, y en ese régimen la unidad útil es el paso, no la época. y entrenaría dos veces sobre las mismas partidas mientras el planificador cree que va por la mitad. Adelantar es barato, porque las ventanas son rodajas de un memmap.

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."""
if min(cfg.max_steps, cfg.grad_accum, cfg.batch_size) < 1:
raise ValueError("max_steps, grad_accum and batch_size must be positive")
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")

src/rukh/train/loop.pylíneas 213-234 · p2

La comprobación que vale su peso es la del vocabulario: si el paquete dice 2 030 y el modelo espera otra cosa, el entrenamiento funcionaría igual (los índices caben) y produciría un modelo que habla otro idioma, un error que después no se detecta.

src/rukh/train/loop.py
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:
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 best_val
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")

src/rukh/train/loop.pylíneas 236-248 · p2

Antes del bucle, train compila y registra la ejecución en MLflow. Es fontanería: salta al bucle si es tu primera lectura, pero es el principio de la función que tecleas, así que no la dejes fuera del fichero.

// Fontaneríatrain, la preparación: el lote de calentamiento que compila y lo que se registra de cada ejecución
src/rukh/train/loop.py
use_bf16 = cfg.precision == "bf16" and where.type == "cuda"
autocast = (
torch.autocast(device_type="cuda", dtype=torch.bfloat16) if use_bf16 else nullcontext()
)
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)

src/rukh/train/loop.pylíneas 250-266 · p2

Este warmup no es el de la tasa: es el lote de unos que fuerza la compilación, dentro del mismo autocast que el bucle. Compilar en fp32 y ejecutar en bf16 obligaría a recompilar en la primera pasada real.

src/rukh/train/loop.py
from rukh.tracking import git_sha, 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 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=this_run,
)

src/rukh/train/loop.pylíneas 268-294 · p2

Todo lo que identifica una ejecución seis meses después: configuración, hash del vocabulario, SHA del manifiesto de datos, dispositivo y parámetros. save es una clausura porque necesita this_run, que solo existe dentro del with.

src/rukh/train/loop.py
clock = time.perf_counter()
for step in range(start_step, cfg.max_steps):
lr = lr_at(step, cfg)
for group in optimizer.param_groups:
group["lr"] = lr
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()

src/rukh/train/loop.pylíneas 296-317 · p2

El corazón, con tres detalles fáciles de equivocar:

  • (loss / cfg.grad_accum).backward() hace que los gradientes acumulados sean una media y no una suma. Sin la división, cambiar grad_accum para que quepa en otra GPU cambiaría la receta.
  • La pérdida se acumula en la GPU y solo se lee con .item() al registrar: una sincronización por paso en vez de una por micro-lote.
  • El recorte se pide sobre model.parameters(), no sobre el envoltorio compilado, para garantizar que se aplica a la norma global.
src/rukh/train/loop.py
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 319-350 · p2

El or last garantiza que la última iteración registre, evalúe y guarde aunque max_steps no sea múltiplo de nada. best.pt se escribe solo cuando la validación mejora; en la tirada de small coincide con el último checkpoint porque la validación seguía bajando en el paso 20 000. Y los clock = time.perf_counter() sacan de tokens_per_s el tiempo de evaluar o de escribir un checkpoint de 150 MB.

Y la última pieza, el registro de métricas, que se puede saltar al leer y hay que teclear, porque el bucle la llama:

// Fontaneríalog_metrics: registrar en MLflow sin que un fallo cueste la ejecución
src/rukh/train/loop.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/loop.pylíneas 353-363 · p2

Perder la traza nunca cuesta una ejecución: un MLflow caído o una métrica NaN (que MLflow rechaza) producen un aviso y el entrenamiento sigue.

checkpoint.py: qué es un checkpoint y qué no

Guardar y cargar checkpoints es entrada y salida de ficheros, no aprendizaje: puedes saltarlo en la primera lectura. Si lo saltas, tráelo: git checkout p2 -- src/rukh/train/checkpoint.py src/rukh/train/__init__.py tests/unit/test_checkpoint.py. Sin esos tres ficheros, el «Ejecútalo» de debajo no importa rukh.train.

// Fontaneríacheckpoint.py, el __init__ del paquete y sus tests: qué se guarda para reanudar, evaluar y publicar
src/rukh/train/checkpoint.py
"""Checkpoints: what a run writes so it can be resumed, evaluated and published.
A payload holds only plain Python values and tensors, so it can be read back with
``torch.load(..., weights_only=True)``. Provenance (``vocab_hash``, ``data_manifest_sha``,
``git_sha``) is recorded on purpose: weights whose data or vocabulary cannot be identified are
not reproducible.
"""
from __future__ import annotations
import hashlib
import json
import random
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import numpy as np
import torch
from torch import nn
from rukh.models import DecoderConfig, MoveDecoder
BEST_NAME = "best.pt"
TIED_HEAD = "lm_head.weight"
"""Tied to ``tokens.weight``; a published state dict leaves it out and it is re-tied on load."""

src/rukh/train/checkpoint.pylíneas 1-26 · p2

«Solo valores de Python y tensores» permite leerlo con torch.load(..., weights_only=True), el modo que no ejecuta código al deserializar. Un checkpoint es un fichero que la gente se descarga, y un pickle arbitrario es una ejecución remota esperando a ocurrir.

src/rukh/train/checkpoint.py
def step_name(step: int) -> str:
"""File name of the checkpoint written after ``step`` optimizer steps."""
return f"step-{step}.pt"
def read_vocab_hash(tokens_dir: Path) -> str | None:
"""``vocab_hash`` from a pack ``meta.json``, or None when it cannot be read."""
try:
meta = json.loads((Path(tokens_dir) / "meta.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
value = meta.get("vocab_hash")
return str(value) if isinstance(value, str) else None
def read_manifest_sha(manifest: Path) -> str | None:
"""SHA-256 of a data manifest, or None when the file is absent or unreadable.
Training must never depend on ``data/`` being present: a checkpoint trained from a pack
copied elsewhere simply records ``None``.
"""
try:
payload = Path(manifest).read_bytes()
except OSError:
return None
return hashlib.sha256(payload).hexdigest()

src/rukh/train/checkpoint.pylíneas 29-54 · p2

Las dos funciones de procedencia devuelven None en vez de fallar: entrenar con un paquete copiado a otra máquina tiene que funcionar, pero el checkpoint no puede afirmar una procedencia que no tiene.

src/rukh/train/checkpoint.py
def rng_state() -> dict[str, Any]:
"""Snapshot of the Python, NumPy and torch CPU generators."""
name, keys, pos, has_gauss, cached = np.random.get_state(legacy=True)
return {
"python": json.dumps(random.getstate()),
"numpy": json.dumps(
[name, np.asarray(keys).tolist(), int(pos), int(has_gauss), float(cached)]
),
"torch": torch.get_rng_state(),
}
def set_rng_state(state: Mapping[str, Any]) -> None:
"""Restore a snapshot taken by ``rng_state`` (missing entries are skipped)."""
python = state.get("python")
if isinstance(python, str):
version, keys, gauss = json.loads(python)
random.setstate((version, tuple(keys), gauss))
numpy = state.get("numpy")
if isinstance(numpy, str):
name, keys, pos, has_gauss, cached = json.loads(numpy)
np.random.set_state((name, np.array(keys, dtype=np.uint32), pos, has_gauss, cached))
torch_state = state.get("torch")
if isinstance(torch_state, torch.Tensor):
torch.set_rng_state(torch_state.to(torch.uint8).cpu())

src/rukh/train/checkpoint.pylíneas 57-81 · p2

El estado de NumPy se desarma a mano porque su tupla no son valores simples, y la cabecera exige que todo lo sea. set_rng_state se salta lo que falte, así que un checkpoint viejo se sigue cargando.

src/rukh/train/checkpoint.py
def save_checkpoint(
path: Path,
*,
step: int,
model: nn.Module,
optimizer: torch.optim.Optimizer | None,
cfg: Mapping[str, Any],
model_cfg: Mapping[str, Any],
vocab_hash: str | None = None,
data_manifest_sha: str | None = None,
git_sha: str | None = None,
best_val: float | None = None,
run_id: str | None = None,
) -> Path:
"""Write one checkpoint atomically (temporary file plus replace) and return its path.
``run_id`` is the MLflow run that wrote it, so ``--resume`` can carry on logging into the
same run and the loss curve stays one line instead of two.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
payload: dict[str, Any] = {
"step": int(step),
"model_state": {k: v.detach().cpu() for k, v in model.state_dict().items()},
"opt_state": optimizer.state_dict() if optimizer is not None else None,
"cfg": dict(cfg),
"model_cfg": dict(model_cfg),
"vocab_hash": vocab_hash,
"data_manifest_sha": data_manifest_sha,
"git_sha": git_sha,
"best_val": best_val,
"run_id": run_id,
"rng": rng_state(),
}
tmp = path.with_suffix(path.suffix + ".tmp")
torch.save(payload, tmp)
tmp.replace(path)
return path

src/rukh/train/checkpoint.pylíneas 84-121 · p2

Paso, pesos, optimizador y rng hacen que --resume continúe; los campos de procedencia dicen con qué partidas y qué código se entrenó. Y la escritura es atómica: un torch.save directo sobre best.pt que se corte a mitad deja un fichero corrupto donde había uno bueno; con el temporal y replace, el peor caso es un .tmp huérfano.

src/rukh/train/checkpoint.py
def load_checkpoint(path: Path, map_location: str | torch.device = "cpu") -> dict[str, Any]:
"""Read a checkpoint written by ``save_checkpoint``."""
payload = torch.load(Path(path), map_location=map_location, weights_only=True)
if not isinstance(payload, dict) or "model_state" not in payload:
raise ValueError(f"{path} is not a rukh checkpoint")
return payload
def load_state(model: nn.Module, state: Mapping[str, Any]) -> None:
"""Load weights, accepting a state dict whose tied head was left out when it was saved.
``MoveDecoder`` ties ``lm_head.weight`` to ``tokens.weight`` in its constructor, so the tied
head is already correct once the embedding is loaded: a state dict without it (the one
``rukh publish`` writes, because two names for one tensor is what makes ``safetensors``
refuse the file) loads cleanly and nothing else may be missing.
"""
missing, unexpected = model.load_state_dict(dict(state), strict=False)
absent = [name for name in missing if name != TIED_HEAD]
if absent or unexpected:
raise ValueError(
f"the weights do not match the model: missing {absent}, unexpected {list(unexpected)}"
)

src/rukh/train/checkpoint.pylíneas 124-145 · p2

Aquí vive la consecuencia de los tied embeddings. lm_head.weight y tokens.weight son el mismo tensor con dos nombres, y rukh publish deja fuera el atado porque safetensors rechaza dos nombres para un tensor. strict=True fallaría y strict=False a secas aceptaría cualquier cosa, así que se carga sin estricto y se comprueba a mano que solo falta el nombre que se sabe que falta.

src/rukh/train/checkpoint.py
def load_model(
path: Path, map_location: str | torch.device = "cpu"
) -> tuple[MoveDecoder, dict[str, Any]]:
"""Rebuild the decoder a checkpoint describes, in eval mode, plus the whole payload."""
payload = load_checkpoint(path, map_location=map_location)
model = MoveDecoder(DecoderConfig.model_validate(payload["model_cfg"]))
load_state(model, payload["model_state"])
return model.eval(), payload
def restore(
payload: Mapping[str, Any],
model: nn.Module,
optimizer: torch.optim.Optimizer | None = None,
with_rng: bool = True,
) -> int:
"""Load weights (and optionally the optimizer and RNG) and return the step reached."""
load_state(model, payload["model_state"])
opt_state = payload.get("opt_state")
if optimizer is not None and opt_state is not None:
optimizer.load_state_dict(opt_state)
rng = payload.get("rng")
if with_rng and isinstance(rng, Mapping):
set_rng_state(rng)
return int(payload["step"])

src/rukh/train/checkpoint.pylíneas 148-172 · p2

load_model reconstruye el modelo desde el model_cfg que viaja dentro del checkpoint, así que rukh eval, play, export y publish solo necesitan una ruta.

src/rukh/train/__init__.py
"""Training: the loop, the learning-rate schedule and checkpoint handling."""
from rukh.train.checkpoint import (
BEST_NAME,
TIED_HEAD,
load_checkpoint,
load_model,
load_state,
read_manifest_sha,
read_vocab_hash,
restore,
save_checkpoint,
step_name,
)
from rukh.train.loop import (
TrainConfig,
evaluate,
param_groups,
pick_device,
run_dir,
skip_batches,
train,
)
from rukh.train.schedule import lr_at
__all__ = [
"BEST_NAME",
"TIED_HEAD",
"TrainConfig",
"evaluate",
"load_checkpoint",
"load_model",
"load_state",
"lr_at",
"param_groups",
"pick_device",
"read_manifest_sha",
"read_vocab_hash",
"restore",
"run_dir",
"save_checkpoint",
"skip_batches",
"step_name",
"train",
]

src/rukh/train/__init__.pylíneas 1-45 · p2

El harness, el exportador, el publicador y los labs importan de rukh.train sin saber que hay tres ficheros detrás.

tests/unit/test_checkpoint.py
"""Tests for rukh.train.checkpoint: round-trip, provenance and RNG restoration."""
from __future__ import annotations
import json
import random
from pathlib import Path
import numpy as np
import pytest
import torch
from rukh.models import DecoderConfig, MoveDecoder
from rukh.train import (
TrainConfig,
load_checkpoint,
read_manifest_sha,
read_vocab_hash,
restore,
save_checkpoint,
step_name,
)
pytestmark = pytest.mark.unit
TOY = DecoderConfig(vocab_size=32, n_layer=2, n_head=2, d_model=32, block=16)
def make_pair() -> tuple[MoveDecoder, torch.optim.Optimizer]:
torch.manual_seed(0)
model = MoveDecoder(TOY)
return model, torch.optim.AdamW(model.parameters(), lr=1e-3)

tests/unit/test_checkpoint.pylíneas 1-32 · p2

tests/unit/test_checkpoint.py
def test_save_and_load_reproduce_weights_and_step(tmp_path: Path) -> None:
model, optimizer = make_pair()
idx = torch.randint(1, TOY.vocab_size, (2, 8))
_, loss = model(idx, idx)
assert loss is not None
loss.backward()
optimizer.step()
path = save_checkpoint(
tmp_path / step_name(7),
step=7,
model=model,
optimizer=optimizer,
cfg=TrainConfig(max_steps=7).model_dump(mode="json"),
model_cfg=TOY.model_dump(mode="json"),
vocab_hash="abc",
data_manifest_sha=None,
git_sha="deadbeef",
best_val=1.25,
)
assert path.name == "step-7.pt"
assert not list(tmp_path.glob("*.tmp"))
payload = load_checkpoint(path)
assert payload["step"] == 7
assert payload["vocab_hash"] == "abc"
assert payload["data_manifest_sha"] is None
assert payload["git_sha"] == "deadbeef"
assert payload["best_val"] == pytest.approx(1.25)
assert payload["cfg"]["max_steps"] == 7
assert payload["model_cfg"]["d_model"] == 32
fresh = MoveDecoder(TOY)
fresh_opt = torch.optim.AdamW(fresh.parameters(), lr=1e-3)
assert restore(payload, fresh, fresh_opt) == 7
for a, b in zip(model.state_dict().values(), fresh.state_dict().values(), strict=True):
assert torch.equal(a, b)
with torch.no_grad():
assert torch.allclose(model.eval()(idx)[0], fresh.eval()(idx)[0], atol=1e-6)

tests/unit/test_checkpoint.pylíneas 35-73 · p2

El test de ida y vuelta da un paso de optimizador antes de guardar: con pesos recién inicializados, dos modelos con la misma semilla son iguales aunque el checkpoint no guarde nada. Compara también los logits, y el glob("*.tmp") comprueba que la escritura atómica no deja el temporal.

tests/unit/test_checkpoint.py
def test_restoring_the_rng_repeats_the_same_draws(tmp_path: Path) -> None:
model, optimizer = make_pair()
random.seed(11)
np.random.seed(11)
torch.manual_seed(11)
path = save_checkpoint(
tmp_path / "rng.pt",
step=0,
model=model,
optimizer=optimizer,
cfg={},
model_cfg=TOY.model_dump(mode="json"),
)
expected = (random.random(), float(np.random.random()), torch.rand(3).tolist())
random.seed(99)
np.random.seed(99)
torch.manual_seed(99)
restore(load_checkpoint(path), MoveDecoder(TOY))
assert (random.random(), float(np.random.random()), torch.rand(3).tolist()) == expected

tests/unit/test_checkpoint.pylíneas 76-95 · p2

El de los generadores guarda, saca tres números de tres bibliotecas, cambia las semillas, restaura y exige los mismos tres números: la definición operativa de «reanudar es indistinguible de no haber parado».

tests/unit/test_checkpoint.py
def test_vocab_hash_and_manifest_sha_are_defensive(tmp_path: Path) -> None:
assert read_vocab_hash(tmp_path) is None
(tmp_path / "meta.json").write_text(json.dumps({"n_games": 1}), encoding="utf-8")
assert read_vocab_hash(tmp_path) is None
(tmp_path / "meta.json").write_text(json.dumps({"vocab_hash": "cafe"}), encoding="utf-8")
assert read_vocab_hash(tmp_path) == "cafe"
assert read_manifest_sha(tmp_path / "does-not-exist" / "manifest.json") is None
manifest = tmp_path / "manifest.json"
manifest.write_bytes(b"{}")
sha = read_manifest_sha(manifest)
assert sha is not None and len(sha) == 64
def test_a_foreign_file_is_not_a_checkpoint(tmp_path: Path) -> None:
path = tmp_path / "other.pt"
torch.save({"weights": torch.zeros(2)}, path)
with pytest.raises(ValueError, match="not a rukh checkpoint"):
load_checkpoint(path)

tests/unit/test_checkpoint.pylíneas 98-116 · p2

Los dos últimos cubren la procedencia ausente y un .pt cualquiera que no es un checkpoint de Rukh.

tracking.py: que una ejecución reanudada sea una sola curva

M0 dejó start_run escribiendo en la base SQLite local. M2 le añade lo necesario para que reanudar no parta la gráfica en dos.

Es contabilidad del registro, y se puede saltar en la primera lectura. Si lo saltas, tráelo: git checkout p2 -- src/rukh/tracking.py; train le pasa el run_id nuevo, y sin el cambio la ejecución de humo de más abajo falla con un TypeError.

// Fontaneríatracking.py: reabrir la misma ejecución de MLflow al reanudar
src/rukh/tracking.py
def start_run(
name: str,
config: Mapping[str, Any],
tags: Mapping[str, str] | None = None,
run_id: str | None = None,
) -> Iterator[Any]:
"""Start an MLflow run in the local store with ``config`` logged as flattened params.
Tags always include ``rukh_version`` and, when the project lives in a git repo, ``git_sha``.
With ``run_id`` the existing run is reopened instead of a new one being created, so a
training run that is resumed keeps one curve rather than starting a second one; a run id
that cannot be reopened (deleted, or from another store) falls back to a fresh run.
Yields the active ``mlflow.ActiveRun``.
"""

src/rukh/tracking.pylíneas 61-74 · p2

El parámetro nuevo, run_id, va al final y con valor por defecto: las llamadas de M0 y M1 siguen valiendo.

src/rukh/tracking.py
started = None
if run_id:
try:
started = mlflow.start_run(run_id=run_id, tags=run_tags)
except Exception as exc: # noqa: BLE001 - a lost run must never stop a training run
log.warning("could not reopen the MLflow run %s (%s); starting a new one", run_id, exc)
with started or mlflow.start_run(run_name=name, tags=run_tags) as run:
params = flatten(config)
if params:
# A resumed run already carries these; re-logging a changed value is an error there.
try:
mlflow.log_params(params)
except Exception as exc: # noqa: BLE001 - the params are already recorded
log.warning("could not log the run parameters: %s", exc)
yield run

src/rukh/tracking.pylíneas 85-99 · p2

Las dos guardas aplican la misma regla: un run_id que no se puede reabrir cae a una ejecución nueva con un aviso, y el error de MLflow al volver a registrar los parámetros tampoco puede costar el entrenamiento. Sin ellas, un MLflow borrado tiraría cuarenta minutos de GPU por un problema de contabilidad.

Las dos configuraciones

configs/train/small.yaml
# `small` (12 layers, d=512, 38,971,392 parameters): the course model. Measured at 440k tokens/s
# eager on a 5090 (D-024), so the 20,000 steps below are about 40 minutes, not the 4-8 hours the
# spec estimated before anyone timed it.
preset: small
tokens_dir: data/tokens/uci
block: 200
batch_size: 64
grad_accum: 4 # 256 effective sequences of 200 tokens
lr: 6.0e-4
min_lr_ratio: 0.1
warmup: 1000
max_steps: 20000
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: small
# A timestamp is appended: a second run must not overwrite the step-*.pt series.
unique_run_name: true
workers: 4
log_every: 10

configs/train/small.yamllíneas 1-27 · p2

El modelo del curso. Los números son los de la receta; el resto es operación: cuarenta evaluaciones, veinte checkpoints (los que recorre la isla de «Exportar a ONNX») y cuatro workers para que el cargador no sea el cuello de botella. El comentario de cabecera guarda la medida al lado de la estimación que corrigió, que es donde alguien la va a buscar.

configs/train/tiny.yaml
# `tiny` (6 layers, d=256, ~5M parameters): the model used to iterate, under 20 minutes on a 5090.
preset: tiny
tokens_dir: data/tokens/uci
block: 200
batch_size: 128
grad_accum: 2 # 256 effective sequences
lr: 1.0e-3
min_lr_ratio: 0.1
warmup: 500
max_steps: 6000
weight_decay: 0.1
betas: [0.9, 0.95]
grad_clip: 1.0
precision: bf16
compile: true
eval_every: 250
eval_batches: 50
ckpt_every: 1000 # every checkpoint feeds the TrainingReplay island of M2
out_dir: checkpoints
seed: 42
run_name: tiny
# A timestamp is appended: a second run must not overwrite the step-*.pt series.
unique_run_name: true
workers: 4
log_every: 10

configs/train/tiny.yamllíneas 1-25 · p2

El modelo con el que se itera. Lotes de 128 por 2 dan las mismas 256 secuencias efectivas, porque un modelo pequeño cabe con lotes del doble; y la tasa sube a 1e-3 porque tolera pasos mayores. El warmup ocupa el 8 % de la ejecución frente al 5 % de small: el riesgo que cubre depende de lo mal calibrado que está Adam en los primeros pasos, no de cuántos pasos haya en total. La tirada real tardó 189 segundos, no los veinte minutos que promete el comentario.

Lanzarlo

El subcomando es una cáscara fina sobre train; se puede saltar. Si lo saltas, tráelo: git checkout p2 -- src/rukh/cli.py. Trae el cli.py final del módulo, con los subcomandos play, eval, export y publish ya puestos (importan lo suyo solo al invocarlos, así que no estorban): en las lecciones 4 a 9, el subcomando de cada una se lee en vez de teclearse.

// Fontaneríacli.py: el subcomando rukh train, con --preset, --resume y --max-steps
src/rukh/cli.py
@app.command("train")
def train_cmd(
config: Annotated[
Path,
typer.Option(
"--config", exists=True, dir_okay=False, readable=True, help="Training YAML config."
),
],
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."""
from rukh.config import load_yaml
from rukh.models import PRESETS
from rukh.train import TrainConfig, train
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 324-366 · p2

Una cáscara fina sobre la biblioteca. --preset lanza la misma receta con otra talla (y pone model: None, o un model explícito del YAML ignoraría lo que pides), y --max-steps sirve para las pruebas de trescientos pasos del ejercicio de más abajo.

tiny, en tres minutos

Terminal
uv run rukh train --config configs/train/tiny.yaml

La consola de esta tirada no se capturó, pero el run quedó en MLflow: estas son las 24 evaluaciones de tiny-20260919-061533 tal como están en mlruns/mlflow.db (189 segundos en total):

step 250 val/loss 4.5869 val/top1 0.1903
step 500 val/loss 3.5244 val/top1 0.2544
step 750 val/loss 3.0194 val/top1 0.2953
step 1000 val/loss 2.7751 val/top1 0.3175
step 1250 val/loss 2.6150 val/top1 0.3326
step 1500 val/loss 2.5098 val/top1 0.3443
step 1750 val/loss 2.4252 val/top1 0.3537
step 2000 val/loss 2.3687 val/top1 0.3611
step 2250 val/loss 2.3210 val/top1 0.3665
step 2500 val/loss 2.2764 val/top1 0.3723
step 2750 val/loss 2.2394 val/top1 0.3776
step 3000 val/loss 2.2115 val/top1 0.3816
step 3250 val/loss 2.1809 val/top1 0.3858
step 3500 val/loss 2.1552 val/top1 0.3902
step 3750 val/loss 2.1312 val/top1 0.3934
step 4000 val/loss 2.1111 val/top1 0.3970
step 4250 val/loss 2.0932 val/top1 0.3993
step 4500 val/loss 2.0770 val/top1 0.4022
step 4750 val/loss 2.0622 val/top1 0.4048
step 5000 val/loss 2.0500 val/top1 0.4066
step 5250 val/loss 2.0398 val/top1 0.4080
step 5500 val/loss 2.0316 val/top1 0.4101
step 5750 val/loss 2.0251 val/top1 0.4106
step 6000 val/loss 2.0200 val/top1 0.4121

Mientras corre, abre MLflow (uv run rukh mlflow ui) y mira cuatro series. train/loss baja deprisa y luego despacio. val/loss la acompaña; cuando se separen, has llegado al sobreajuste y el best.pt ya está guardado. lr dibuja la rampa y el coseno. Y grad_norm se estabiliza; picos recurrentes significan una tasa demasiado alta para este modelo.

Para leer esas pérdidas: son entropía cruzada en nats, y su exponencial es la perplejidad, entre cuántas jugadas equiprobables duda el modelo. Sin entrenar, ln(2030) ≈ 7,6, perplejidad 2 030; el 2,02 final de tiny es una perplejidad de 7,5, y en una posición típica hay unas treinta jugadas legales.

small, el modelo del curso

20 000 pasos de 64 × 4 secuencias, 1 024 millones de tokens. El bucle sostuvo 422 000 tokens/s de mediana en esta 5090, en modo eager (sin Triton, torch.compile no arranca en este Windows): 2 498 segundos, 42 minutos, cuando el diseño estimaba entre cuatro y ocho horas antes de medir nada.

Terminal
uv run rukh train --config configs/train/small.yaml

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

2026-09-19 08:56:21,493 step 13000 val/loss 1.5787 val/top1 0.4981
2026-09-19 08:57:23,733 step 13500 val/loss 1.5695 val/top1 0.5006
2026-09-19 08:58:25,635 step 14000 val/loss 1.5641 val/top1 0.5019
2026-09-19 08:59:27,866 step 14500 val/loss 1.5586 val/top1 0.5034
2026-09-19 09:00:29,794 step 15000 val/loss 1.5528 val/top1 0.5045
2026-09-19 09:01:32,021 step 15500 val/loss 1.5475 val/top1 0.5060
2026-09-19 09:02:33,968 step 16000 val/loss 1.5424 val/top1 0.5069
2026-09-19 09:03:36,099 step 16500 val/loss 1.5378 val/top1 0.5077
2026-09-19 09:04:37,972 step 17000 val/loss 1.5332 val/top1 0.5087
2026-09-19 09:05:40,223 step 17500 val/loss 1.5308 val/top1 0.5092
2026-09-19 09:06:42,081 step 18000 val/loss 1.5275 val/top1 0.5101
2026-09-19 09:07:44,246 step 18500 val/loss 1.5253 val/top1 0.5110
2026-09-19 09:08:46,056 step 19000 val/loss 1.5226 val/top1 0.5113
2026-09-19 09:09:48,207 step 19500 val/loss 1.5201 val/top1 0.5116
2026-09-19 09:10:50,062 step 20000 val/loss 1.5197 val/top1 0.5120
preset: small
steps: 20000
checkpoint: …\rukh\checkpoints\small-20260919-062911\step-20000.pt

Sesenta y dos segundos entre evaluaciones, 500 pasos de 51 200 tokens: unos 413 000 tokens/s, algo por debajo de la mediana de 422 000 porque cada intervalo incluye también la evaluación. El 440k del comentario de small.yaml es la medida de antes de lanzar (una tirada corta, sin evaluaciones); la mediana de la ejecución entera es la de arriba. Si el proceso se corta, --resume checkpoints/small-<fecha>/step-12000.pt continúa donde estaba, con el optimizador, los generadores y la misma ejecución de MLflow.

// Ejercicio 01Tres entrenamientos de 300 pasos que explican la receta

Con --max-steps 300, lanza tiny tres veces, cada una con su copia de configs/train/tiny.yaml pasada por --config (el CLI no tiene opciones para el calentamiento ni la tasa): (a) con warmup: 100, porque el warmup: 500 de tiny no terminaría la rampa en 300 pasos y nunca llegaría a la tasa pico; (b) con warmup: 0; (c) con warmup: 100 y lr: 1.0e-2. Compara train/loss y grad_norm en MLflow. ¿Se distinguen las tres en los primeros diez pasos? ¿Por qué es eso peligroso?

// SoluciónVer la solución

Lo esperable: (a) baja suave desde ln(2030) ≈ 7,6. (b) pega un salto al principio: o sube por encima de 8 antes de volver, o se estanca hacia 6, la entropía de las jugadas más frecuentes (el modelo predice siempre lo común). (c) tiene el grad_norm pegado al techo del clipping, así que el paso lo decide el recorte y no el optimizador; la pérdida oscila o diverge.

Lo peligroso: las tres bajan al principio. Cualquier configuración razonable baja de 7,6 a 5 aprendiendo solo la frecuencia de cada jugada. La curva temprana no distingue un entrenamiento sano de uno roto; hay que esperar a que la pendiente se suavice o mirar otras métricas, y por eso tiny evalúa cada 250 pasos.

// Ejercicio 02¿Por qué la pérdida de validación va por debajo de la de entrenamiento?

Mira las primeras evaluaciones de tiny y de small: en los primeros checkpoints la pérdida de validación es menor que la de entrenamiento. Con lo que acabas de leer de loop.py, explica por qué, y di qué tendría que pasar para que eso fuera un problema.

// SoluciónVer la solución

Son dos medidas de momentos distintos. train/loss es la media de los micro-lotes de ese paso, calculada con los pesos de antes de actualizarlos; val/loss se mide con los pesos de después. Al principio el modelo mejora tan deprisa que la diferencia se nota.

Con dropout: 0.0 no hay otra explicación. Las curvas se cruzan en el paso 3 000 de small y desde ahí el hueco crece, que es lo normal. Sería un problema si la validación siguiera por debajo miles de pasos después: querría decir que el conjunto de validación es más fácil (partidas más cortas, jugadores más homogéneos) y que las dos cifras no se pueden restar.

Qué has aprendido

El bucle entero y las cinco decisiones de la receta, cada una con lo que se rompe si se cambia. Y tres propiedades de infraestructura que valen para cualquier entrenamiento largo, sea de ajedrez o de un LLM: reanudar es indistinguible de no haber parado, un checkpoint sabe con qué datos y qué código se escribió, y perder la traza nunca cuesta una ejecución.

Los nueve tests en verde, tiny en 189 segundos hasta una validación de 2,0200 y un top-1 del 41,21 %, y small en 42 minutos hasta 1,5197 y 51,20 %.

Lo siguiente es sacarle una jugada: convertir 2 030 logits en e2e4 es una decisión de diseño con tres perillas y un orden que no es libre.