// M6 · lección 06
int8 y el jugador ONNX: un grafo exportado en la escalera
El objeto más pequeño que puede jugar en lugar de un checkpoint, para responder por fin cuánto cuesta el int8 en Elo; los nombres de ejecución estables que hacen que un comando de una lección funcione tanto si descargaste el modelo como si lo entrenaste; y el muestreo de pares que mete el brazo de control de M5 dentro de su configuración.
Qué vas a construir
Tres cambios pequeños sin relación aparente, con algo en común: los tres convierten en código lo que hasta ahora era un script suelto o una pregunta abierta.
infer/onnx_player.pypone un grafo ONNXONNXFormato abierto para describir el grafo de una red y sus pesos, independiente del framework que la entrenó. Es lo que permite entrenar en PyTorch y ejecutar en el navegador cononnxruntime-web, sin Python ni backend. dentro del mismo jugador que usa un checkpoint, para que una exportación pueda subir a la escalera. La paridadParidadEn cuántas posiciones la jugada elegida por un modelo exportado coincide con la del modelo original. Mide si la exportación está bien hecha, no si el modelo exportado juega igual de bien: un 95,40 % (int8 desmall, M2) es un modelo que juega otra cosa una vez de cada veinte, y su Elo hay que medirlo jugando. de M2 dice que el int8 elige otra jugada una de cada veinte veces; esto permite medir cuánto cuesta eso en fuerza.resolve_runentrain/checkpoint.pyhace quecheckpoints/medium-v4/best.ptencuentre también elcheckpoints/medium-v4-20260919-174623/best.ptde un entrenamiento propio.limit_pairsentrain/dpo.pymete en la configuración el muestreo que M5 hacía con un script fuera del repositorio.
El objeto más pequeño que puede jugar
"""An exported ONNX graph standing in for the decoder, so an export can sit on the Elo ladder.
Parity (M2) says in how many positions the exported model picks the same move as the checkpoint.It does not say what the other positions cost: an int8 export that agrees 95 % of the time is adifferent player, and the only way to learn how different is to let it play the same ladder thecheckpoint played. ``OnnxDecoder`` is the smallest object that ``pick_move`` accepts in place of a``MoveDecoder`` -- ``next_logits``, ``parameters`` (for the device) and ``cfg.block`` -- so theexport goes through ``DecoderPlayer`` and the very same masking, sampling and rescue as everypublished number. A comparison whose two halves went through different code is not a comparison."""«El objeto más pequeño que pick_move acepta» es la frase de diseño. Nadie hereda de MoveDecoder
ni define una interfaz abstracta: se mira qué tres cosas usa el muestreador y se escribe un objeto
que las tenga. Es tipado patotipado patoAceptar un objeto por los métodos que tiene y no por la clase de la que hereda: «si grazna como un pato…». En Rukh es lo que permite que un grafo ONNX de 68 líneas (OnnxDecoder) juegue en la escalera de Stockfish por el mismo camino que un checkpoint, implementando solo las tres cosas que el muestreador mira: next_logits, parameters y cfg.block. (duck typing) a propósito, y la
exportación recorre el mismo código que el checkpoint: la misma máscara de legalidad, el mismo
muestreo con su semilla, el mismo rescate. Un segundo bucle de partida para ONNX habría dado dos
números que no se pueden restar. Es lo que harías para comparar un LLM servido en local con el mismo
modelo detrás de una API: un solo arnés y dos objetos que responden a la misma llamada.
METADATA_BLOCK = "rukh_block"
def _session(onnx_path: Path) -> Any: """An ONNX Runtime session on the CPU provider (tests monkeypatch this).""" import onnxruntime as ort
return ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
class _Config: """The one field of ``DecoderConfig`` the sampler reads."""
def __init__(self, block: int) -> None: self.block = block_session es una función aparte para que los tests la sustituyan; construida en línea, probar el
fichero exigiría un .onnx en disco y onnxruntime instalado. _Config solo tiene block, y su
docstring documenta de qué parte de DecoderConfig depende el muestreador. Y
providers=["CPUExecutionProvider"] fija la CPU: por eso el lab de la lección 9 usa el rival por
nodos, porque con jugador y motor en CPU un rival por reloj mediría además cuánto se estorban.
class OnnxDecoder: """``model.onnx`` (or its fp16/int8 sibling) with the surface ``pick_move`` needs.
``block`` comes from the metadata ``rukh export`` writes; pass it when the file has none. """
def __init__(self, onnx_path: Path, block: int | None = None) -> None: self.path = Path(onnx_path) self.session = _session(self.path) if block is None: meta = self.session.get_modelmeta().custom_metadata_map if METADATA_BLOCK not in meta: raise ValueError(f"{self.path.as_posix()} carries no {METADATA_BLOCK}; pass block=") block = int(meta[METADATA_BLOCK]) self.cfg = _Config(block) self._anchor = torch.zeros(1)
def parameters(self): # type: ignore[no-untyped-def] """One CPU tensor: the sampler asks where the weights live, and ORT runs on the CPU.""" yield self._anchor
def next_logits(self, idx: Tensor) -> Tensor: """Logits of the last step only, ``(B, vocab_size)``, cropped to the block size.""" cropped = idx[:, -self.cfg.block :].detach().cpu().numpy().astype(np.int64) logits = self.session.run(None, {INPUT_NAME: cropped})[0] return torch.from_numpy(np.asarray(logits, dtype=np.float32))
def eval(self) -> OnnxDecoder: return selfCinco decisiones:
- El
blockviaja dentro del.onnx:rukh exportescriberukh_blocken los metadatos del grafo. Un artefacto que lleva dentro lo que hace falta para usarlo se puede pasar a otra persona sin una nota. Si falta, el error dice qué hacer (carries no rukh_block; pass block=). parameters()devuelve un generador con un tensor de un elemento. El muestreador solo lo llama para preguntarnext(...).device, y la respuesta correcta es cualquier tensor en CPU;torch.zeros(1)es el más barato, guardado enself._anchorpara no asignar memoria en cada jugada.astype(np.int64)explícito: el grafo declara su entradaint64, y si le llegaint32ONNX Runtime lo rechaza con un error que habla de nombres de tensores y no de tipos.- El recorte a
block(idx[:, -self.cfg.block:]) está aquí, como en el decoder de PyTorch, para que el jugador no tenga que saber con cuál de los dos habla. eval()devuelveselfy no hace nada, porque quien carga un modelo escribemodel.eval()por costumbre y un grafo exportado no tiene modos.
Con eso, el int8 puede subir a la misma escalera que el checkpoint, que es lo que pedía la lección 1.
Los dos tests que hacen esto comprobable sin ONNX
Los tests comparan el jugador ONNX con el del checkpoint sin exportar nada. Llegaron con el
git checkout del principio: se leen, no se teclean.
// Teststests/unit/test_onnx_player.py: una sesión falsa con los logits del propio checkpoint
"""An ONNX export plays through the same player as the checkpoint it came from."""
from __future__ import annotations
from pathlib import Pathfrom typing import Any
import chessimport numpy as npimport pytestimport torch
from rukh.export.onnx import INPUT_NAME, LastStepLogitsfrom rukh.infer import DecoderPlayer, SampleConfigfrom rukh.infer import onnx_player as modulefrom rukh.infer.onnx_player import OnnxDecoderfrom rukh.models.decoder import DecoderConfig, MoveDecoderfrom rukh.tokenize.uci_vocab import UciTokenizer
pytestmark = pytest.mark.unit
TOY = DecoderConfig(vocab_size=2030, n_layer=2, n_head=2, d_model=32, block=64)
class FakeSession: """Stands in for ``onnxruntime.InferenceSession`` with the checkpoint's own logits."""
def __init__(self, model: MoveDecoder, block: int | None = TOY.block) -> None: self.wrapper = LastStepLogits(model).eval() self.block = block self.seen: list[int] = []
def get_modelmeta(self) -> Any: meta = {} if self.block is None else {module.METADATA_BLOCK: str(self.block)} return type("Meta", (), {"custom_metadata_map": meta})()
def run(self, _outputs: Any, feed: dict[str, np.ndarray]) -> list[np.ndarray]: idx = feed[INPUT_NAME] assert idx.dtype == np.int64 self.seen.append(idx.shape[1]) with torch.no_grad(): return [self.wrapper(torch.from_numpy(idx)).numpy()]tests/unit/test_onnx_player.py
FakeSession es la idea que lo hace comprobable: una sesión falsa que devuelve los logits del propio
checkpoint, envuelto en el mismo LastStepLogits que usa la exportación. Así el test comprueba que
DecoderPlayer sobre el modelo y sobre OnnxDecoder producen las mismas jugadas, sin exportar nada.
Dentro de run, el doble comprueba que le llega int64 y apunta la longitud de cada secuencia para
comprobar fuera que nunca se pasó del block.
def test_the_export_picks_the_checkpoints_move_greedily( model: MoveDecoder, monkeypatch: pytest.MonkeyPatch) -> None: session = FakeSession(model) monkeypatch.setattr(module, "_session", lambda _path: session) tok = UciTokenizer() cfg = SampleConfig(temperature=0, top_k=None, mask_illegal=False, seed=0)
reference = DecoderPlayer(model, tok, cfg) exported = DecoderPlayer(OnnxDecoder(Path("model.onnx")), tok, cfg) # type: ignore[arg-type] assert exported.limit == reference.limit
board = chess.Board() for player in (reference, exported): player.start(1800, 1800) for _ in range(6): a, legal_a = reference.choose(board) b, legal_b = exported.choose(board) assert a == b and legal_a == legal_b if a is None: break board.push(a) reference.observe(a) exported.observe(a) assert session.seen and max(session.seen) <= TOY.blocktests/unit/test_onnx_player.py
temperature=0 y mask_illegal=False, porque el test compara la elección del modelo: con máscara,
los dos jugadores coincidirían aunque sus logits difirieran en las jugadas ilegales. El bucle mueve
los dos jugadores sobre el mismo tablero, empujando la jugada de reference, para que el test señale
la primera discrepancia y no una partida que divergió veinte jugadas antes. La última aserción
comprueba que el límite de pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply. deducido del block es el mismo por los dos
caminos.
def test_the_block_comes_from_the_metadata_or_the_caller( model: MoveDecoder, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(module, "_session", lambda _path: FakeSession(model, block=None)) with pytest.raises(ValueError, match="rukh_block"): OnnxDecoder(Path("model.onnx")) assert OnnxDecoder(Path("model.onnx"), block=32).cfg.block == 32 monkeypatch.setattr(module, "_session", lambda _path: FakeSession(model, block=48)) assert OnnxDecoder(Path("model.onnx")).cfg.block == 48tests/unit/test_onnx_player.py
Los tres caminos del constructor: sin metadatos ni argumento, error; sin metadatos y con argumento,
el argumento; con metadatos, los metadatos. El tercero usa block=48 y no el valor por defecto, para
que un constructor que ignorara los metadatos no pasara.
Un nombre de ejecución que sirve a los dos lectores
Cuando entrenas, unique_run_name le pone una marca de tiempo a la carpeta:
medium-v4-20260919-174623. Así dos entrenamientos del mismo nombre no se pisan, pero las
configuraciones del repositorio llevaban dentro la marca de tiempo de la máquina de referencia.
init_from: checkpoints/medium-v4/best.ptconfigs/train/medium-masters.yaml
Esa línea decía antes checkpoints/medium-v4-20260919-174623/best.pt. Quien entrenara medium-v4 en
su máquina tendría otra fecha, y quien lo descargara con rukh pull lo tendría en
checkpoints/medium-v4/: la configuración solo funcionaba en la máquina donde se escribió. El mismo
cambio está en once ficheros (los dos DPO, GRPO, los dos LoRA, medium-elo, medium-masters, los
tres de cabezas del encoder y configs/data/onpolicy.yaml), y esta es la pieza que lo hace funcionar:
La resolución de nombres es fontanería del proyecto, no del modelo: en la primera lectura basta con
la idea del recuadro de abajo. El código, en cambio, no se puede saltar: sin él no pasa ningún
«Ejecútalo» de aquí en adelante. Si no lo tecleas, trae los tres ficheros de train/ que toca,
git checkout p6 -- src/rukh/train/checkpoint.py src/rukh/train/common.py src/rukh/train/__init__.py,
y copia las funciones _run_path y _run_exists de la salida de git show p6:src/rukh/cli.py a tu
cli.py.
// Fontaneríaresolve_run y sus tres usuarios: el cargador, init_from y las opciones de la CLI
RUN_STAMP = re.compile(r"^(?P<name>.+)-(?P<stamp>\d{8}-\d{6})$")"""What ``unique_run_name`` appends to a run folder: ``medium-v4`` becomes``medium-v4-20260919-174623``. The stamp sorts as text, so the newest run of a name is the last."""
def resolve_run(path: str | Path) -> Path: """The path as given when it exists; otherwise the same thing inside the newest stamped run.
Configs and lessons name a checkpoint by its run: ``checkpoints/medium-v4/best.pt``. That is the spelling ``rukh pull`` writes, and it is not the one a reader who trained the run has, because ``unique_run_name`` stamps every folder (``medium-v4-20260919-174623``). Both mean "the run called medium-v4", so when the stable folder is missing this looks for the stamped ones beside it and takes the newest **by name**, never by modification time: copying a folder must not change which run a config points at. A folder spec (``checkpoints/lora-e4``) resolves the same way. Anything with no candidate comes back unchanged, and whoever opens it reports the missing file with the path the user wrote. """ path = Path(path) if path.exists(): return path is_file = bool(path.suffix) run_dir = path.parent if is_file else path parent, name = run_dir.parent, run_dir.name if not parent.is_dir(): return path stamped = sorted( candidate for candidate in parent.iterdir() if candidate.is_dir() and (match := RUN_STAMP.match(candidate.name)) is not None and match.group("name") == name and (not is_file or (candidate / path.name).is_file()) ) if not stamped: return path found = stamped[-1] / path.name if is_file else stamped[-1] log.info("%s is not there; using the newest run of that name, %s", path, found) return foundCasi todas las decisiones son sobre no adivinar de más:
- Lo que existe gana. Una carpeta estable, porque la escribió
rukh pullo porque la renombraste, tiene prioridad sobre cualquier ejecución con marca; si no, un entrenamiento viejo taparía un modelo descargado. - El más nuevo por nombre, no por fecha de modificación. La marca
AAAAMMDD-HHMMSSordena igual como texto que como fecha, yst_mtimecambia al copiar una carpeta, restaurar una copia de seguridad o sincronizarla con la nube. bool(path.suffix)distingue un fichero de una carpeta de adaptador. Fallaría con una carpeta con un punto en el nombre; no hay ninguna.- El candidato tiene que contener el fichero: una ejecución que acaba de empezar tiene carpeta y aún
no tiene
best.pt. - El prefijo tiene que ser el nombre entero (
match.group("name") == name): constartswith,smallencontraríasmall-v3-20260921-000000, que es otra ejecución. - Lo que no se resuelve vuelve intacto, para que el error lo dé quien lo abra con el nombre que escribiste, no con una carpeta con marca que nunca escribiste.
El log.info cuando sí resuelve avisa de que el comando está usando otro fichero. Una resolución
silenciosa es magia, y la magia se paga el día que dos ejecuciones del mismo nombre dan números
distintos.
def load_checkpoint(path: Path, map_location: str | torch.device = "cpu") -> dict[str, Any]: """Read a checkpoint written by ``save_checkpoint`` (see ``resolve_run`` for the path).""" payload = torch.load(resolve_run(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 payloadUn solo resolve_run en el cargador central y lo heredan todos los caminos. El mensaje de error
sigue usando path, la ruta que pidieron: habla el lenguaje del usuario.
if init_from is None: return None path = resolve_run(paths.resolve(init_from)) if not path.is_file(): raise FileNotFoundError(f"init_from checkpoint not found: {path}")Aquí hace falta una segunda llamada, porque load_init_weights comprueba que el fichero existe antes
de abrirlo para dar un error con el nombre de la clave (init_from). Este mensaje sí usa la ruta
resuelta, porque lo que necesitas saber es qué fichero se buscó.
read_vocab_hash, resolve_run, restore,Y el reexporte, para importarla desde rukh.train.
def _run_path(value: Path | None) -> Path | None: """A checkpoint or run folder; a missing stable name resolves to the newest stamped run.
A path is validated here instead of with ``exists=True`` so that a command written against the stable spelling of a run (``checkpoints/small/best.pt``, what ``rukh pull`` writes) also works for the reader whose own training left ``checkpoints/small-20260919-062911/`` behind. """ if value is None: return None from rukh.train.checkpoint import resolve_run
found = resolve_run(value) if not found.exists(): run = value.parent.name if value.suffix else value.name raise typer.BadParameter(f"{value} does not exist, and no stamped run `{run}-*` holds it") return found
def _run_exists(spec: str) -> bool: """Whether ``spec`` names a checkpoint on disk, directly or through ``resolve_run``.""" from rukh.train.checkpoint import resolve_run
return resolve_run(Path(spec)).is_file()En la CLI, exists=True se sustituye por callback=_run_path en diez opciones (--resume, --ckpt,
--adapter, --model, --run…). exists=True comprueba la ruta literal antes de que nadie pueda
resolverla; un callback corre después y puede devolver otro valor. El BadParameter dice que la
ruta no existe y que tampoco hay ninguna ejecución con marca que la contenga
(no stamped run \medium-v4-*` holds it`).
Los tests de resolve_run también llegaron con el git checkout del principio; se leen.
// Teststests/unit/test_checkpoint.py: cuatro carpetas colocadas para que cada aserción falle con un error plausible
def test_a_stable_run_name_resolves_to_the_newest_stamped_run(tmp_path: Path) -> None: """``checkpoints/small/best.pt`` finds ``checkpoints/small-<newest stamp>/best.pt``.
This is what lets a config or a lesson name a run without its date: the reader who trained it has the stamped folder, the reader who pulled it from the Hub has the stable one, and the same spelling serves both. """ from rukh.train.checkpoint import resolve_run
root = tmp_path / "checkpoints" older = root / "small-20260919-062911" newer = root / "small-20260920-150000" decoy = root / "small-v3-20260921-000000" # another run whose name merely starts the same for folder in (older, newer, decoy): folder.mkdir(parents=True) (folder / "best.pt").write_bytes(b"x") (root / "small-20260921-000000").mkdir() # newest stamp, but it holds no best.pt yet
assert resolve_run(root / "small" / "best.pt") == newer / "best.pt" assert resolve_run(root / "small") == root / "small-20260921-000000" assert resolve_run(root / "small-v3" / "best.pt") == decoy / "best.pt" # A stable folder wins over any stamped one, and an unknown name comes back untouched. (root / "small").mkdir() (root / "small" / "best.pt").write_bytes(b"y") assert resolve_run(root / "small" / "best.pt") == root / "small" / "best.pt" assert resolve_run(root / "medium" / "best.pt") == root / "medium" / "best.pt" assert resolve_run(tmp_path / "nowhere" / "run" / "best.pt") == ( tmp_path / "nowhere" / "run" / "best.pt" )
def test_load_checkpoint_reads_through_the_stable_name(tmp_path: Path) -> None: model, optimizer = make_pair() run = tmp_path / "checkpoints" / "tiny-20260919-060101" save_checkpoint( run / "best.pt", step=5, model=model, optimizer=optimizer, cfg={}, model_cfg=TOY.model_dump(), ) payload = load_checkpoint(tmp_path / "checkpoints" / "tiny" / "best.pt") assert payload["step"] == 5El montaje del primer test es la mitad del valor: cuatro carpetas colocadas para que cada aserción falle con una implementación plausible pero equivocada.
- Si se ordenara mal, saldría
olderen vez denewer. - Si no se comprobara que el fichero está dentro, saldría
small-20260921-000000/best.pt, que no existe. Esa misma carpeta sí es la respuesta cuando se pregunta por la carpeta y no por el fichero: dos aserciones seguidas sobre la misma carpeta, con respuestas distintas y las dos correctas. - Si se usara
startswith,small-v3encontraríasmall-…o al revés. - Si lo estable no ganara, la penúltima aserción devolvería la carpeta con marca.
- La última comprueba que una ruta cuyo padre ni existe vuelve intacta.
El brazo de control, dentro de la configuración
El último cambio es de M5. Su comparación tenía dos brazos: DPO sobre los pares de las evaluaciones
de Lichess (fuera de política) y sobre los que propuso el propio modelo (en política). Para medir de
dónde salen los pares y no cuántos hay, los dos brazos tenían que ver la misma cantidad, y eso lo
hacía un script de una vez que escribía data/pairs-offpolicy-matched/ fuera del repositorio.
pairs: str = "data/pairs/dpo-prompts.parquet" max_pairs: int | None = Field(default=None, ge=1) """Subsample the pairs to this many, with ``seed``, before the split. Two arms compared on two pair sources must see the same amount of data, or the comparison measures the amount."""ge=1: cero pares es un error de escritura, y el test comprueba que no valida.
def limit_pairs(frame: Any, cfg: DpoConfig) -> Any: """At most ``cfg.max_pairs`` rows, drawn with ``cfg.seed`` from the whole file.
A sample and never the head: the pairs are balanced by phase in blocks, so the first rows of the file are two thirds openings against a third in the whole (D-114). With no limit the frame comes back untouched. """ if cfg.max_pairs is None or frame.height <= cfg.max_pairs: return frame LOGGER.info("dpo: %d pares muestreados de %d", cfg.max_pairs, frame.height) return frame.sample(n=cfg.max_pairs, seed=cfg.seed, shuffle=True)«Una muestra y nunca la cabeza» es toda la función. El fichero de pares está equilibrado por fase en bloques: aperturas, medio juego, finales. Las primeras 6 386 filas serían dos tercios de aperturas, y el brazo de control entrenaría sobre otra distribución sin que nada fallara. Es el mismo error de quien evalúa un clasificador con las primeras mil filas de un fichero ordenado por fecha o por clase. La semilla es la del entrenamiento, así que dos ejecuciones ven los mismos pares.
# DPO on the pairs Lichess's evaluations produced: off-policy, and the control arm of M5's# comparison. The only lines that differ from `dpo-onpolicy.yaml` are `pairs` and `max_pairs`.pairs: data/pairs/dpo-prompts.parquet# Matched to the on-policy pair count (6 386), as a fixed-seed sample of the whole file, so the# two arms see the same amount of data and the comparison measures the source of the pairs and# not their number. P5 did this with a one-off script that wrote `data/pairs-offpolicy-matched/`;# the config now says it, so the run can be rebuilt from the config alone.max_pairs: 6386checkpoint: checkpoints/medium-v4/best.ptconfigs/train/dpo-offpolicy.yaml
El comentario cierra la deuda: dice el número, de dónde sale y qué había antes. El criterio es que la ejecución se pueda reconstruir solo desde la configuración, sin pasos que estén en la cabeza de quien lo hizo.
El test que lo fija está en tests/unit/test_dpo.py, que ya trajiste; léelo:
def test_max_pairs_samples_the_whole_file_with_the_seed() -> None: """The off-policy arm is matched to the on-policy count here, not by a script outside git.""" from rukh.train.dpo import limit_pairs
frame = pl.DataFrame({"phase": ["opening"] * 6 + ["endgame"] * 6, "i": list(range(12))}) same = limit_pairs(frame, DpoConfig()) assert same.height == 12 cut = limit_pairs(frame, DpoConfig(max_pairs=4, seed=7)) assert cut.height == 4 assert cut["i"].to_list() == limit_pairs(frame, DpoConfig(max_pairs=4, seed=7))["i"].to_list() assert cut["i"].to_list() != [0, 1, 2, 3] # not the head of the file assert limit_pairs(frame, DpoConfig(max_pairs=50)).height == 12 with pytest.raises(ValueError): DpoConfig(max_pairs=0)El marco de juguete tiene seis aperturas seguidas de seis finales, los bloques del fichero real en
miniatura, y las dos aserciones centrales comprueban que la muestra se repite con la misma semilla y
que no es la cabeza. Como shuffle=True baraja también el orden, que salga [0, 1, 2, 3]
justo en ese orden por azar ocurre una vez de cada 11 880 (12 · 11 · 10 · 9), pero con la semilla 7
fija el test pasa siempre o no pasa nunca; sin semilla sería una bomba de relojería en la
CI.
// Ejercicio 01Qué le falta a `rukh eval match` para enfrentar dos exportaciones
OnnxDecoder ya entra en DecoderPlayer. Con eso delante, di qué le falta a
rukh eval match --a … --b … para poder enfrentar el fp16 contra el int8 del mismo modelo, y por
qué eso no estaba hecho en este hito.
// SoluciónVer la solución
Solo que --a y --b acepten un directorio ONNX con su precisión, y construyan OnnxDecoder
en vez de cargar un checkpoint. El jugador ya existe y pasa por el mismo DecoderPlayer que usa
el lab de la lección 9. No se hizo porque el enfrentamiento que respondería la pregunta necesita
miles de partidas —el lab 3 mide 11 puntos de diferencia, y separar 11 del cero pide unas 3 800
partidas por pareja— y ese gasto no cabía en el hito. El código está; la decisión de pagarlo,
no.
// Ejercicio 02Rompe `resolve_run` de la forma tentadora
Cambia sorted(...) por un orden según st_mtime y describe una secuencia de acciones normales
—ninguna de ellas un entrenamiento— que hagan que una configuración apunte a otra ejecución.
// SoluciónVer la solución
Copiar el directorio checkpoints/ a otro disco, restaurar una copia de seguridad, o
sincronizarlo con una carpeta en la nube: las tres reescriben las fechas de modificación, a
menudo en un orden que no es el original. Después de cualquiera de ellas,
checkpoints/medium-v4/best.pt podría resolverse a una ejecución de hace tres semanas, el
entrenamiento arrancaría sin protestar y la única pista sería la línea de log.info que nadie
mira. El nombre, en cambio, viaja con la carpeta.
Qué has aprendido
El adaptador más útil suele ser el más pequeño. OnnxDecoder implementa las tres cosas que mira el
muestreador, y basta para que una exportación juegue por el mismo camino que el checkpoint. Cuando
quieras comparar dos implementaciones de algo, empieza por preguntarte qué usa de verdad el
consumidor.
Las dos deudas se pagaron igual, sacando de la cabeza de quien lo hizo algo que solo estaba ahí: una ruta con la marca de tiempo de una máquina pasó a ser un nombre que se resuelve en todas, y un script de una vez, una clave de configuración con su número y su razón al lado.
La lección siguiente cambia de repositorio: la demo con la que se juega contra todo esto.