rukh · lab

// M2 · lección 07

Exportar a ONNX

El puente al navegador: el envoltorio que devuelve solo el último paso, el eje dinámico que se comprueba ejecutando el fichero, las dos cuantizaciones y la paridad de jugada que mide si el .onnx elige lo mismo que PyTorch. Con las dos islas que dejan mirar dentro del modelo.

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

Qué vas a construir

El fichero que descarga el navegador. src/rukh/export/ convierte un checkpoint de PyTorch en tres .onnx (fp32, fp16 e int8) y mide si los tres eligen la misma jugada que el original. Al final, las dos visualizaciones del módulo: el entrenamiento checkpoint a checkpoint y el mapa de atención de una partida.

El navegador no ejecuta PyTorch. El puente es 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 con onnxruntime-web, sin Python ni backend., un formato que describe el grafo y los pesos y que onnxruntime-web ejecuta con WebGPU o con WASM. Es el problema de cualquier despliegue fuera del entorno de entrenamiento (un móvil, un servidor sin Python, un LLM en GGUF para tu portátil), y las comprobaciones de esta lección valen para todos.

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

LabRutaRelojDejaAtajo
Exportar a ONNXLos tres ONNX con su comprobación de paridad (los JSON de las islas salen en la lección 10)cuesta máquina~12 min de exportaciónmodel{,-fp16,-int8}.onnx, con la paridad impresa en consolalos tres ONNX están publicados en chorcat/rukh-small
cuesta máquina
Tiempo real de GPU, red o motor. El reloj es el de la RTX 5090 de referencia.

Las dependencias nuevas

M2 añade cinco paquetes al pyproject.toml de M0, todos para esta lección y la siguiente:

pyproject.toml
"onnx>=1.23",
"onnxscript>=0.6",
"onnxruntime>=1.30",
"onnxconverter-common>=1.16",
"safetensors>=0.6",

pyproject.tomllíneas 25-29 · p2

onnx lee y escribe el fichero, onnxscript lo necesita el exportador moderno de PyTorch, onnxruntime ejecuta lo exportado (para la paridad y el eje dinámico), onnxconverter-common hace la conversión a fp16 y safetensors sirve para publicar los pesos en el Hub (lección 9). Ninguno es un framework de entrenamiento: el decoder sigue escrito a mano.

onnx.py: el grafo

src/rukh/export/onnx.py
"""Exporting a ``MoveDecoder`` to ONNX for the browser.
The demo only ever needs the distribution over the *next* move, so the exported graph is not the
model itself but a wrapper whose forward returns the last step only: ``(B, V)`` instead of
``(B, T, V)``, which divides the output tensor by the context length (200) and saves the browser
from slicing a megabyte of logits per move.
``torch.onnx.export`` is tried with the dynamo exporter first (the default since torch 2.9 and
what ``docs/spec/02`` asks for) and falls back to the legacy TorchScript tracer with a warning
when dynamo is unavailable or fails; which path produced the file is recorded in the metadata,
because the two exporters do not emit the same graph and a parity check is only meaningful when
it is known which one ran.
``dynamic_seq`` is not taken on trust. The legacy tracer happily bakes the traced length into
the graph while still being asked for a dynamic axis, and the demo feeds a sequence that grows
by one token per move, so the exported file is **run** at two different lengths and the flag
reports what actually worked. The model's context (``block``) travels with the file as ONNX
metadata, so the browser knows the limit without being told separately.
"""

src/rukh/export/onnx.pylíneas 1-19 · p2

Tres decisiones, y cada una se paga si se omite:

  • Se exporta un envoltorio, no el modelo. La demo solo necesita la distribución de la siguiente jugada, así que el forward exportado devuelve (B, V) en vez de (B, T, V): doscientas veces menos números por jugada.
  • Hay dos exportadores y no dan el mismo grafo. Dynamo es el moderno; TorchScript queda de respaldo. Cuál corrió se graba en el fichero, porque una paridad solo significa algo si sabes qué grafo comparas.
  • Un eje dinámico declarado es una promesa. El trazador antiguo hornea la longitud trazada en el grafo mientras dice que el eje es dinámico. Como la demo alarga la secuencia una jugada por turno, el fallo saltaría en la segunda jugada de la primera partida. Por eso se comprueba ejecutando.

Las importaciones y constantes no deciden nada y se pueden saltar al leer, pero son el principio de onnx.py: tecléalas igual.

// Fontaneríaonnx.py: importaciones y constantes
src/rukh/export/onnx.py
from __future__ import annotations
import contextlib
import logging
import sys
from collections.abc import Iterator
from pathlib import Path
from typing import Any, Literal
import torch
from pydantic import BaseModel, ConfigDict
from torch import Tensor, nn
from rukh import __version__
from rukh.models import MoveDecoder
log = logging.getLogger(__name__)
MODEL_NAME = "model.onnx"
INPUT_NAME = "idx"
OUTPUT_NAME = "logits"
BATCH_AXIS = "batch"
SEQUENCE_AXIS = "sequence"
DEFAULT_OPSET = 18
METADATA_PREFIX = "rukh_"

src/rukh/export/onnx.pylíneas 21-45 · p2

src/rukh/export/onnx.py
class LastStepLogits(nn.Module):
"""Wraps a decoder so that ``forward(idx)`` returns only the last step's logits."""
def __init__(self, model: MoveDecoder) -> None:
super().__init__()
self.model = model
def forward(self, idx: Tensor) -> Tensor:
logits, _ = self.model(idx)
return logits[:, -1, :]

src/rukh/export/onnx.pylíneas 48-57 · p2

Debajo de LastStepLogits está la mecánica de la exportación: elegir exportador, comprobar que el eje de la secuencia es dinámico de verdad y escribir los metadatos en el fichero. Es fontanería de formato y puedes saltarla en la primera lectura; las tres decisiones de arriba son lo que importa. Tecléala igual: export_onnx está ahí, y sin ella el fichero no exporta nada.

// Fontaneríaonnx.py, el resto: exportar con dynamo o TorchScript, comprobar el eje dinámico ejecutando, la consola de Windows y los metadatos rukh_*
src/rukh/export/onnx.py
class ExportResult(BaseModel):
"""What was written and how."""
model_config = ConfigDict(extra="forbid")
path: str
exporter: Literal["dynamo", "legacy"]
opset: int
seq_len: int
block: int
"""The model's context: the exported graph must never be fed more than this many tokens."""
dynamic_batch: bool
dynamic_seq: bool
"""What the file really accepts, not what was asked for: verified by running it."""
dynamic_seq_verified: bool = False
"""Whether ``dynamic_seq`` was checked by running the file (needs ``onnxruntime``)."""
metadata: dict[str, str] = {}
"""``metadata_props`` written into the file, empty when ``onnx`` is not installed."""
vocab_size: int
params: int
bytes: int
warning: str | None = None

src/rukh/export/onnx.pylíneas 60-81 · p2

En ExportResult, dynamic_seq es lo que el fichero acepta de verdad y dynamic_seq_verified dice si se pudo comprobar (hace falta onnxruntime). Un informe que no distingue «es verdad» de «no lo he podido mirar» miente por omisión.

src/rukh/export/onnx.py
def target_path(out: Path) -> Path:
"""``out`` as a file: a directory gets ``model.onnx`` inside it."""
out = Path(out)
return out if out.suffix == ".onnx" else out / MODEL_NAME

src/rukh/export/onnx.pylíneas 84-87 · p2

src/rukh/export/onnx.py
def _dynamic_shapes(dynamic_batch: bool, dynamic_seq: bool) -> dict[str, dict[int, str]] | None:
axes: dict[int, str] = {}
if dynamic_batch:
axes[0] = BATCH_AXIS
if dynamic_seq:
axes[1] = SEQUENCE_AXIS
return {INPUT_NAME: axes} if axes else None
def _dynamic_axes(dynamic_batch: bool, dynamic_seq: bool) -> dict[str, dict[int, str]] | None:
shapes = _dynamic_shapes(dynamic_batch, dynamic_seq)
if shapes is None:
return None
axes = dict(shapes)
if dynamic_batch:
axes[OUTPUT_NAME] = {0: BATCH_AXIS}
return axes

src/rukh/export/onnx.pylíneas 90-106 · p2

Dos funciones casi iguales porque cada exportador espera su formato; el antiguo necesita además que le declares el eje de lote de la salida, que dynamo deduce solo.

src/rukh/export/onnx.py
@contextlib.contextmanager
def _utf8_console() -> Iterator[None]:
"""Let the exporter print its progress ticks on a legacy Windows code page.
``torch.onnx.export(dynamo=True)`` writes check marks to stdout. On Windows the console
is cp1252 by default, so that raises ``UnicodeEncodeError`` inside the exporter and the
whole export fails for a reason that has nothing to do with the model. Reconfiguring the
streams to replace unencodable characters keeps the modern exporter usable; without it
every Windows export silently falls back to the deprecated TorchScript one, which in turn
produces an fp16 graph that onnxruntime refuses to load.
"""
streams = [s for s in (sys.stdout, sys.stderr) if hasattr(s, "reconfigure")]
previous = [(s, s.encoding, s.errors) for s in streams]
for stream in streams:
with contextlib.suppress(Exception):
stream.reconfigure(errors="replace")
try:
yield
finally:
for stream, encoding, errors in previous:
with contextlib.suppress(Exception):
stream.reconfigure(encoding=encoding, errors=errors)

src/rukh/export/onnx.pylíneas 109-130 · p2

Un arreglo de Windows con una cadena de consecuencias que enseña. El exportador dynamo imprime marcas Unicode en su barra de progreso; la consola de Windows es cp1252, así que salta un UnicodeEncodeError dentro del exportador, se cae al antiguo, y ese produce un fp16 que onnxruntime se niega a cargar. Una configuración regional de la consola acaba en un modelo que el navegador no puede ejecutar. Sustituir los caracteres que no se pueden codificar rompe la cadena en el primer eslabón.

src/rukh/export/onnx.py
def export_onnx(
ckpt: Path | MoveDecoder,
out: Path,
opset: int = DEFAULT_OPSET,
dynamic_batch: bool = True,
seq_len: int = 200,
dynamic_seq: bool = True,
) -> ExportResult:
"""Export the next-move head of a checkpoint to ONNX and report which exporter ran.
``ckpt`` may be a checkpoint path or an already-built model (the tests use the latter).
``seq_len`` is the length of the example the exporter traces; with ``dynamic_seq`` the graph
still accepts any length up to the model's block, which is what the demo feeds it as a game
grows move by move.
"""
model = ckpt if isinstance(ckpt, MoveDecoder) else _load(Path(ckpt))
model = model.eval()
if seq_len > model.cfg.block:
raise ValueError(f"seq_len={seq_len} is longer than the model's block {model.cfg.block}")
wrapper = LastStepLogits(model).eval()
example = torch.zeros((1, seq_len), dtype=torch.long)
path = target_path(out)
path.parent.mkdir(parents=True, exist_ok=True)

src/rukh/export/onnx.pylíneas 133-155 · p2

src/rukh/export/onnx.py
common: dict[str, Any] = {
"input_names": [INPUT_NAME],
"output_names": [OUTPUT_NAME],
"opset_version": opset,
}
warning: str | None = None
exporter: Literal["dynamo", "legacy"] = "dynamo"
try:
with torch.no_grad(), _utf8_console():
torch.onnx.export(
wrapper,
(example,),
str(path),
dynamo=True,
dynamic_shapes=_dynamic_shapes(dynamic_batch, dynamic_seq),
**common,
)
except Exception as exc: # noqa: BLE001 - any exporter failure must fall back, not stop
warning = f"the dynamo exporter failed ({type(exc).__name__}: {exc}); used the legacy one"
log.warning("%s", warning)
exporter = "legacy"
with torch.no_grad():
torch.onnx.export(
wrapper,
(example,),
str(path),
dynamo=False,
dynamic_axes=_dynamic_axes(dynamic_batch, dynamic_seq),
**common,
)

src/rukh/export/onnx.pylíneas 157-186 · p2

El except cae al exportador antiguo y guarda el motivo en warning para que acabe impreso.

src/rukh/export/onnx.py
works = verify_dynamic_seq(path, seq_len, model.cfg.block)
if dynamic_seq and works is False:
raise ValueError(
f"{path} was exported with a dynamic sequence axis but only runs at length "
f"{seq_len}: the {exporter} exporter baked the length in. Re-export with "
"dynamic_seq=False and pad the input, or fix the exporter."
)
really_dynamic = dynamic_seq if works is None else works
metadata = write_metadata(
path,
{
"block": model.cfg.block,
"vocab_size": model.cfg.vocab_size,
"seq_len": seq_len,
"dynamic_batch": dynamic_batch,
"dynamic_seq": really_dynamic,
"exporter": exporter,
"version": __version__,
},
)
return ExportResult(
path=path.as_posix(),
exporter=exporter,
opset=opset,
seq_len=seq_len,
block=model.cfg.block,
dynamic_batch=dynamic_batch,
dynamic_seq=really_dynamic,
dynamic_seq_verified=works is not None,
metadata=metadata,
vocab_size=model.cfg.vocab_size,
params=model.num_params(non_embedding=False),
bytes=path.stat().st_size,
warning=warning,
)

src/rukh/export/onnx.pylíneas 187-221 · p2

Si se pidió un eje dinámico y la ejecución a dos longitudes dice que no lo es, la exportación se para con instrucciones: mejor un error aquí que un fichero que falla en el navegador. Cuando no se pudo comprobar (sin onnxruntime), really_dynamic se queda con lo pedido y dynamic_seq_verified en False para que se sepa.

src/rukh/export/onnx.py
def sequence_lengths(seq_len: int, block: int) -> list[int]:
"""Two lengths to run the exported file at: the traced one and a different, legal one."""
other = seq_len + 1 if seq_len < block else max(1, seq_len - 1)
return sorted({seq_len, other})

src/rukh/export/onnx.pylíneas 224-227 · p2

src/rukh/export/onnx.py
def verify_dynamic_seq(path: Path, seq_len: int, block: int) -> bool | None:
"""Run the file at two sequence lengths; None when ``onnxruntime`` is not installed."""
import numpy as np
try:
import onnxruntime as ort
except ImportError:
log.warning("onnxruntime is not installed: the dynamic sequence axis was not verified")
return None
lengths = sequence_lengths(seq_len, block)
if len(lengths) < 2: # pragma: no cover - block of 1 is not a usable model
return None
session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
for length in lengths:
try:
session.run(None, {INPUT_NAME: np.zeros((1, length), dtype=np.int64)})
except Exception as exc: # noqa: BLE001 - any refusal means the axis is not dynamic
log.info("the exported graph refused a sequence of %d tokens: %s", length, exc)
return False
return True

src/rukh/export/onnx.pylíneas 230-249 · p2

La trazada y otra legal distinta: dos inferencias bastan para distinguir un eje dinámico de uno horneado.

src/rukh/export/onnx.py
def set_metadata(path: Path, entries: dict[str, str]) -> dict[str, str]:
"""Write ``metadata_props`` verbatim into a file; empty when ``onnx`` is not installed."""
try:
import onnx
except ImportError:
log.warning("onnx is not installed: the model metadata (block, vocab) was not written")
return {}
model = onnx.load(str(path))
onnx.helper.set_model_props(model, entries)
onnx.save(model, str(path))
# The dynamo exporter writes the weights next to the graph as `<name>.onnx.data` and points
# the initializers at it. `onnx.load` above pulled them into memory and `onnx.save` wrote
# them back inline, so the sidecar is now dead weight that would ship to the browser (or be
# published to the Hub) without ever being read. Drop it, but only once the file really is
# self-contained.
sidecar = path.with_suffix(path.suffix + ".data")
if sidecar.is_file() and not any(
tensor.data_location == onnx.TensorProto.EXTERNAL for tensor in model.graph.initializer
):
sidecar.unlink()
log.debug("removed the external-data sidecar %s", sidecar.name)
return entries

src/rukh/export/onnx.pylíneas 252-273 · p2

Los metadatos rukh_* viajan dentro del fichero: un .onnx suelto tiene que seguir sabiendo su ventana de contexto y su vocabulario. Un JSON al lado funciona hasta que alguien mueve el fichero, y alguien lo moverá.

Las últimas líneas borran el .onnx.data que dynamo deja al lado con los pesos. Tras el load/save los pesos ya están dentro, así que el satélite sería peso muerto camino del Hub o del navegador; se borra solo después de comprobar que ningún inicializador apunta fuera.

src/rukh/export/onnx.py
def write_metadata(path: Path, props: dict[str, Any]) -> dict[str, str]:
"""Write ``rukh_*`` metadata into the file; empty when ``onnx`` is not installed."""
return set_metadata(
path, {f"{METADATA_PREFIX}{key}": str(value) for key, value in props.items()}
)
def read_metadata(path: Path) -> dict[str, str]:
"""The ``rukh_*`` metadata of an exported file (needs ``onnx``)."""
import onnx
model = onnx.load(str(path))
return {entry.key: entry.value for entry in model.metadata_props}

src/rukh/export/onnx.pylíneas 276-288 · p2

src/rukh/export/onnx.py
def _load(ckpt: Path) -> MoveDecoder:
from rukh.train import load_model
model, _payload = load_model(ckpt)
return model

src/rukh/export/onnx.pylíneas 291-295 · p2

quantize.py: la mitad y la cuarta parte

src/rukh/export/quantize.py
"""Half precision and dynamic int8 for the two browser backends.
WebGPU runs the fp16 graph (``small`` is about 80 MB that way); the WASM fallback runs the int8
one (about 40 MB), which is the difference between a demo that loads on a phone and one that
does not. Quantization is restricted to ``MatMul`` and ``Gemm``: those are the weights that make
up almost the whole file, and quantizing the rest costs accuracy for nothing.
``onnxconverter_common`` does the fp16 conversion properly (it keeps the graph's inputs and
outputs in float32 and leaves the ops that overflow in fp32). When it is not installed there is
a smaller fallback here that casts the float initializers with ``onnx.numpy_helper`` and puts a
``Cast`` back to float32 in front of the output; ``to_fp16`` reports which of the two ran.
"""

src/rukh/export/quantize.pylíneas 1-12 · p2

WebGPU corre el fp16 (unos 80 MB) y el respaldo en WASM el int8 (unos 40), que es lo que separa una demo que carga en un móvil de una que no. La cuantizaciónCuantizaciónGuardar y calcular los pesos con menos bits de los que se entrenaron: fp16 (la mitad de tamaño, sin pérdida apreciable) o int8 dinámico (una cuarta parte, con algo de error). En Rukh se cuantizan solo las matrices (MatMul y Gemm) y cada fichero pasa una prueba de paridad contra PyTorch antes de subirse. A 4 bits, la receta de QLoRA en M4, cada peso es uno de 16 niveles: NF4 los coloca donde se amontonan los pesos de una red entrenada (una campana centrada en cero, con un cero exacto), cada bloque de 64 pesos guarda su escala, y esas escalas se cuantizan a su vez a 8 bits (doble cuantización). Para multiplicar, cada bloque se descuantiza al vuelo a bf16: ahorra memoria, no cálculo, y solo sirve para una base congelada; el adaptador que se entrena encima sigue en bf16. se limita a MatMul y Gemm: son casi todo el fichero, y cuantizar el resto cuesta exactitud a cambio de nada.

La conversión a fp16 tiene su mecánica (y un respaldo por si falla la conversión automática); se puede saltar al leer hasta quantize_int8, y se teclea igual, porque export_all la llama.

// Fontaneríaquantize.py, la conversión a fp16: el resultado, la ruta de salida y el respaldo a mano con Cast
src/rukh/export/quantize.py
from __future__ import annotations
import logging
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, ConfigDict
log = logging.getLogger(__name__)
FP16_NAME = "model-fp16.onnx"
INT8_NAME = "model-int8.onnx"
QUANTIZED_OPS = ["MatMul", "Gemm"]

src/rukh/export/quantize.pylíneas 14-26 · p2

src/rukh/export/quantize.py
class QuantizeResult(BaseModel):
"""One converted file."""
model_config = ConfigDict(extra="forbid")
path: str
kind: Literal["fp16", "int8"]
method: str
bytes: int
source_bytes: int
@property
def ratio(self) -> float:
return self.bytes / self.source_bytes if self.source_bytes else 0.0

src/rukh/export/quantize.pylíneas 29-42 · p2

src/rukh/export/quantize.py
def _sibling(path: Path, name: str, out: Path | None) -> Path:
if out is None:
return Path(path).with_name(name)
out = Path(out)
return out if out.suffix == ".onnx" else out / name

src/rukh/export/quantize.pylíneas 45-49 · p2

src/rukh/export/quantize.py
def to_fp16(path: Path, out: Path | None = None) -> QuantizeResult:
"""Convert an fp32 ONNX model to fp16, in place of ``model.onnx``'s sibling by default."""
import onnx
source = Path(path)
target = _sibling(source, FP16_NAME, out)
target.parent.mkdir(parents=True, exist_ok=True)
model = onnx.load(str(source))
try:
from onnxconverter_common import float16
converted = float16.convert_float_to_float16(model, keep_io_types=True)
method = "onnxconverter_common.float16"
except ImportError:
converted = _cast_initializers(model)
method = "onnx.numpy_helper fallback"
log.warning("onnxconverter_common is not installed; used the initializer-casting fallback")
onnx.save(converted, str(target))
return QuantizeResult(
path=target.as_posix(),
kind="fp16",
method=method,
bytes=target.stat().st_size,
source_bytes=source.stat().st_size,
)

src/rukh/export/quantize.pylíneas 52-76 · p2

to_fp16 anota en method qué conversión corrió, porque dos conversiones dan dos ficheros distintos y una paridad sin saber cuál se usó no es reproducible. keep_io_types=True deja las entradas y salidas en float32 y solo baja los pesos internos: quien llama al modelo no nota nada.

src/rukh/export/quantize.py
def _cast_initializers(model: object) -> object:
"""Fallback fp16 conversion: cast every float initializer and cast the outputs back.
Only the weights change type. Every float-typed value in the graph is retyped to fp16 and a
``Cast`` node is appended so the model still hands the caller float32 logits, which keeps the
demo's input and output contract identical to the fp32 file.
"""
import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
graph = model.graph # type: ignore[attr-defined]
for initializer in graph.initializer:
if initializer.data_type != TensorProto.FLOAT:
continue
array = numpy_helper.to_array(initializer).astype(np.float16)
initializer.CopyFrom(numpy_helper.from_array(array, initializer.name))
for value in list(graph.value_info) + list(graph.input):
if value.type.tensor_type.elem_type == TensorProto.FLOAT:
value.type.tensor_type.elem_type = TensorProto.FLOAT16
for node in graph.node:
for attribute in node.attribute:
if attribute.type == onnx.AttributeProto.TENSOR:
tensor = attribute.t
if tensor.data_type == TensorProto.FLOAT:
array = numpy_helper.to_array(tensor).astype(np.float16)
tensor.CopyFrom(numpy_helper.from_array(array, tensor.name))
for output in graph.output:
if output.type.tensor_type.elem_type != TensorProto.FLOAT:
continue
inner = f"{output.name}_fp16"
for node in graph.node:
node.output[:] = [inner if name == output.name else name for name in node.output]
graph.node.append(
helper.make_node("Cast", [inner], [output.name], to=int(TensorProto.FLOAT))
)
return model

src/rukh/export/quantize.pylíneas 79-115 · p2

El respaldo hace lo mismo a mano. El truco está en el último bucle: renombra cada salida a <nombre>_fp16 y cuelga detrás un Cast que escribe el nombre público en float32.

src/rukh/export/quantize.py
def quantize_int8(path: Path, out: Path | None = None) -> QuantizeResult:
"""Dynamically quantize the MatMul and Gemm weights of an ONNX model to int8."""
from onnxruntime.quantization import QuantType, quantize_dynamic
source = Path(path)
target = _sibling(source, INT8_NAME, out)
target.parent.mkdir(parents=True, exist_ok=True)
quantize_dynamic(
model_input=str(source),
model_output=str(target),
weight_type=QuantType.QInt8,
op_types_to_quantize=QUANTIZED_OPS,
)
return QuantizeResult(
path=target.as_posix(),
kind="int8",
method="onnxruntime.quantization.quantize_dynamic",
bytes=target.stat().st_size,
source_bytes=source.stat().st_size,
)

src/rukh/export/quantize.pylíneas 118-137 · p2

La cuantización dinámica a int8: 256 niveles y una escala por tensor. El error por peso ronda el 0,4 %, se acumula a lo largo de doce capas, y enseguida verás qué les hace a las decisiones.

parity.py: ¿elige la misma jugada?

src/rukh/export/parity.py
"""Parity between PyTorch and ONNX Runtime: the same move, and how far the logits drifted.
The acceptance criterion of ``docs/spec/02`` is the *move*, not the logits: the demo picks an
``argmax`` (or samples from a truncated distribution), so a file that disagrees with PyTorch on
0.1 % of positions is fine and one that agrees on the numbers but not on the move is not. Both
are reported: ``agreement`` is the share of positions where the chosen token matches, and
``max_abs_logit_delta`` is the worst absolute difference seen anywhere in the logits, which is
what tells fp32, fp16 and int8 apart.
The positions are the thousand **validation** prefixes of ``docs/spec/02`` §6, the same ones the
legality and accuracy metrics use: a random legal walk visits positions no human would reach, so
agreeing on them says little about the file the demo will load. ``random_prefixes`` stays for the
case where no validation parquet is around (and for the tests), and which of the two was used is
reported alongside the number.
"""

src/rukh/export/parity.pylíneas 1-15 · p2

El criterio es la jugada. Elegir depende del orden de los logits, no de su valor: un error numérico grande puede dejar casi todo intacto y uno pequeño voltear justo las decisiones ajustadas. Por eso se publican los dos números.

Las posiciones son las mil de validación, las mismas de la legalidad y la exactitud. Un paseo aleatorio visita posiciones a las que ningún humano llega; se queda como respaldo, y la fuente usada se publica al lado del número.

Lo que prepara la comparación (de dónde salen las posiciones y cómo se abre la sesión de ONNX) es fontanería; la comparación en sí está justo debajo. Es parte de parity.py: tecléalo igual.

// Fontaneríaparity.py, las piezas: el resultado, los prefijos de validación o aleatorios y la sesión de onnxruntime
src/rukh/export/parity.py
from __future__ import annotations
import logging
import random
from collections.abc import Sequence
from pathlib import Path
import chess
import numpy as np
import torch
from pydantic import BaseModel, ConfigDict
from rukh.export.onnx import INPUT_NAME, LastStepLogits
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
log = logging.getLogger(__name__)
DEFAULT_N = 1_000
MAX_MISMATCHES = 20
DEFAULT_GAMES = "data/uci/year=2025/month=02/games.parquet"
"""The validation month of P1: the parity positions come from here when it exists."""

src/rukh/export/parity.pylíneas 17-38 · p2

src/rukh/export/parity.py
class ParityResult(BaseModel):
"""How well an exported file reproduces the checkpoint."""
model_config = ConfigDict(extra="forbid")
positions: int
agreement: float
max_abs_logit_delta: float
mismatches: list[int]
"""Indices of the first few positions where the chosen token differed."""

src/rukh/export/parity.pylíneas 41-50 · p2

mismatches guarda las primeras veinte posiciones donde la jugada cambió, para ir a mirarlas cuando una paridad salga mal sin reejecutarlo todo con prints.

src/rukh/export/parity.py
def random_prefixes(
tok: UciTokenizer,
n: int,
seed: int = 0,
min_ply: int = 1,
max_ply: int = 40,
) -> list[list[int]]:
"""Token-id prefixes of random legal games, for a parity check without any dataset.
Every prefix is a real sequence of legal moves, so the model is asked the kind of question
the demo asks it, and the walk is seeded so a parity number can be reproduced exactly.
"""
rng = random.Random(seed)
prefixes: list[list[int]] = []
while len(prefixes) < n:
board = chess.Board()
ids = [
tok.bos_id,
tok.vocab["<w1800>"],
tok.vocab["<b1800>"],
]
plies = rng.randint(min_ply, max_ply)
for _ in range(plies):
moves = list(board.legal_moves)
if not moves:
break
move = rng.choice(moves)
ids.append(tok.vocab.get(move.uci(), tok.unk_id))
board.push(move)
prefixes.append(ids)
return prefixes

src/rukh/export/parity.pylíneas 53-83 · p2

src/rukh/export/parity.py
def validation_prefixes(
games: Path,
tok: UciTokenizer,
n: int = DEFAULT_N,
block: int = 200,
seed: int = 0,
) -> list[list[int]]:
"""Token-id prefixes of ``n`` validation positions, cropped exactly as the model sees them."""
from rukh.eval.legality import sample_positions
positions = sample_positions(games, n, tok, seed=seed, pool=max(n * 5, 10_000), block=block)
return [list(position.history) for position in positions]

src/rukh/export/parity.pylíneas 86-97 · p2

src/rukh/export/parity.py
def parity_positions(
tok: UciTokenizer,
n: int = DEFAULT_N,
block: int = 200,
seed: int = 0,
games: Path | None = None,
) -> tuple[list[list[int]], str, str | None]:
"""``(prefixes, source, warning)``: validation positions, or random walks with a warning."""
from rukh import paths
parquet = Path(games) if games is not None else paths.resolve(DEFAULT_GAMES)
if parquet.is_file():
prefixes = validation_prefixes(parquet, tok, n=n, block=block, seed=seed)
if prefixes:
return prefixes, "validation", None
warning = (
f"no validation games at {parquet}: parity was measured on random legal walks, which "
"visit positions no human would reach"
)
log.warning("%s", warning)
return random_prefixes(tok, n, seed=seed), "random-walk", warning

src/rukh/export/parity.pylíneas 100-120 · p2

validation_prefixes reutiliza sample_positions de eval.legality: la paridad se mide sobre los mismos prefijos que la legalidad, con el mismo recorte y la misma semilla.

src/rukh/export/parity.py
def _session(onnx_path: Path): # type: ignore[no-untyped-def]
"""An ONNX Runtime session on the CPU provider (tests monkeypatch this)."""
import onnxruntime as ort
return ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])

src/rukh/export/parity.pylíneas 123-127 · p2

src/rukh/export/parity.py
def parity(
ckpt: Path | MoveDecoder,
onnx_path: Path,
positions: Sequence[Sequence[int]],
n: int = DEFAULT_N,
) -> ParityResult:
"""Compare the argmax token and the logits of the checkpoint and the exported file."""
model = ckpt if isinstance(ckpt, MoveDecoder) else _load(Path(ckpt))
wrapper = LastStepLogits(model.eval()).eval()
session = _session(Path(onnx_path))
used = list(positions)[:n]
if not used:
raise ValueError("parity needs at least one position")
agreed = 0
worst = 0.0
mismatches: list[int] = []
for index, history in enumerate(used):
idx = np.asarray([list(history)], dtype=np.int64)
with torch.no_grad():
reference = wrapper(torch.from_numpy(idx)).numpy()[0].astype(np.float64)
exported = np.asarray(session.run(None, {INPUT_NAME: idx})[0])[0].astype(np.float64)
worst = max(worst, float(np.max(np.abs(reference - exported))))
if int(np.argmax(reference)) == int(np.argmax(exported)):
agreed += 1
elif len(mismatches) < MAX_MISMATCHES:
mismatches.append(index)
return ParityResult(
positions=len(used),
agreement=agreed / len(used),
max_abs_logit_delta=worst,
mismatches=mismatches,
)

src/rukh/export/parity.pylíneas 130-162 · p2

Los dos lados reciben el mismo array y sus salidas se suben a float64 antes de restarlas, para que la diferencia no dependa de la precisión de cada una. Del lado de PyTorch va LastStepLogits, el mismo envoltorio que se exportó; si no, compararías dos cosas distintas.

src/rukh/export/parity.py
def _load(ckpt: Path) -> MoveDecoder:
from rukh.train import load_model
model, _payload = load_model(ckpt)
return model

src/rukh/export/parity.pylíneas 165-169 · p2

__init__.py: rukh export en una función

export_all encadena todo lo anterior. Dos detalles que importan: los ficheros cuantizados vuelven a llevar los metadatos, y las posiciones de paridad son las mismas para los tres ficheros. El código se puede saltar. Si lo saltas, tráelo: git checkout p2 -- src/rukh/export/__init__.py, porque rukh export importa export_all de ahí.

// Fontaneríaexport/__init__.py: export_all encadena exportar, cuantizar y medir la paridad de los tres ficheros
src/rukh/export/__init__.py
"""Export: ONNX for the browser, fp16 and int8 quantization and PyTorch parity.
``export_all`` is the whole of ``rukh export``: write the fp32 graph, derive the fp16 and int8
files the two browser backends need, and check every file it produced against PyTorch.
"""

src/rukh/export/__init__.pylíneas 1-5 · p2

src/rukh/export/__init__.py
from __future__ import annotations
from pathlib import Path
from pydantic import BaseModel, ConfigDict
from rukh.export.onnx import (
DEFAULT_OPSET,
INPUT_NAME,
MODEL_NAME,
OUTPUT_NAME,
ExportResult,
LastStepLogits,
export_onnx,
read_metadata,
sequence_lengths,
set_metadata,
target_path,
verify_dynamic_seq,
write_metadata,
)
from rukh.export.parity import (
DEFAULT_GAMES,
ParityResult,
parity,
parity_positions,
random_prefixes,
validation_prefixes,
)
from rukh.export.quantize import (
FP16_NAME,
INT8_NAME,
QUANTIZED_OPS,
QuantizeResult,
quantize_int8,
to_fp16,
)

src/rukh/export/__init__.pylíneas 7-43 · p2

src/rukh/export/__init__.py
class ExportBundle(BaseModel):
"""Everything one ``rukh export`` produced."""
model_config = ConfigDict(extra="forbid")
onnx: ExportResult
fp16: QuantizeResult | None = None
int8: QuantizeResult | None = None
parity: dict[str, ParityResult] = {}
"""Parity per file kind: ``fp32``, ``fp16``, ``int8``."""
parity_source: str | None = None
"""``validation`` (the positions of ``docs/spec/02`` §6) or ``random-walk``."""
parity_warning: str | None = None

src/rukh/export/__init__.pylíneas 46-58 · p2

src/rukh/export/__init__.py
def export_all(
ckpt: Path,
out: Path,
opset: int = DEFAULT_OPSET,
seq_len: int = 200,
fp16: bool = False,
int8: bool = False,
check_parity: bool = False,
positions: int = 1_000,
seed: int = 0,
games: Path | None = None,
) -> ExportBundle:
"""Export, quantize and check one checkpoint in a single pass.
Parity is measured on validation positions (``games``, or the validation month of P1 when it
is on disk) and falls back to random legal walks with a warning when there are none.
"""
from rukh.tokenize.uci_vocab import UciTokenizer
from rukh.train import load_model
model, _payload = load_model(Path(ckpt))
bundle = ExportBundle(onnx=export_onnx(model, out, opset=opset, seq_len=seq_len))
if fp16:
bundle.fp16 = to_fp16(Path(bundle.onnx.path))
if int8:
bundle.int8 = quantize_int8(Path(bundle.onnx.path))
# The demo loads the fp16 or the int8 file, not the fp32 one, so the context length has to
# travel with them too; neither converter promises to keep the metadata of its input.
for quantized in (bundle.fp16, bundle.int8):
if quantized is not None and bundle.onnx.metadata:
set_metadata(Path(quantized.path), bundle.onnx.metadata)
if check_parity:
prefixes, source, warning = parity_positions(
UciTokenizer(), n=positions, block=model.cfg.block, seed=seed, games=games
)
bundle.parity_source = source
bundle.parity_warning = warning
checks = {"fp32": bundle.onnx.path}
if bundle.fp16 is not None:
checks["fp16"] = bundle.fp16.path
if bundle.int8 is not None:
checks["int8"] = bundle.int8.path
bundle.parity = {
kind: parity(model, Path(path), prefixes, n=positions) for kind, path in checks.items()
}
return bundle

src/rukh/export/__init__.pylíneas 61-106 · p2

export_all es el comando entero. Dos detalles: los metadatos se reescriben en los ficheros cuantizados, porque la demo carga esos y los conversores no prometen conservarlos; y las posiciones de paridad se sacan una sola vez para los tres ficheros, o sus cifras no se podrían comparar en la misma tabla.

src/rukh/export/__init__.py
__all__ = [
"DEFAULT_GAMES",
"DEFAULT_OPSET",
"FP16_NAME",
"INPUT_NAME",
"INT8_NAME",
"MODEL_NAME",
"OUTPUT_NAME",
"QUANTIZED_OPS",
"ExportBundle",
"ExportResult",
"LastStepLogits",
"ParityResult",
"QuantizeResult",
"export_all",
"export_onnx",
"parity",
"parity_positions",
"quantize_int8",
"random_prefixes",
"read_metadata",
"sequence_lengths",
"set_metadata",
"target_path",
"to_fp16",
"validation_prefixes",
"verify_dynamic_seq",
"write_metadata",
]

src/rukh/export/__init__.pylíneas 109-137 · p2

El comando, y lo que midió

El subcomando solo llama a export_all e imprime el resultado; se puede saltar. Si lo saltas, tráelo: git checkout p2 -- src/rukh/cli.py (si ya trajiste el cli.py entero en la lección 3, lo tienes). El «Ejecútalo» de debajo lo usa.

// Fontaneríacli.py: el subcomando rukh export
src/rukh/cli.py
@app.command("export")
def export_cmd(
ckpt: Annotated[
Path,
typer.Option(
"--ckpt", exists=True, dir_okay=False, readable=True, help="Checkpoint to export."
),
],
out: Annotated[Path, typer.Option("--out", help="Output directory (or .onnx file).")],
opset: Annotated[int, typer.Option("--opset", help="ONNX opset version.")] = 18,
seq_len: Annotated[
int, typer.Option("--seq-len", help="Example length the exporter traces.")
] = 200,
fp16: Annotated[bool, typer.Option("--fp16", help="Also write the fp16 model.")] = False,
int8: Annotated[bool, typer.Option("--int8", help="Also write the int8 model.")] = False,
check_parity: Annotated[
bool, typer.Option("--check-parity", help="Compare every file with PyTorch.")
] = False,
positions: Annotated[
int, typer.Option("--positions", help="Positions used by the parity check.")
] = 1000,
games: Annotated[
Path | None,
typer.Option(
"--games",
exists=True,
dir_okay=False,
readable=True,
help="Validation games parquet for the parity positions.",
),
] = None,
as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON only.")] = False,
) -> None:
"""Export the next-move head to ONNX, quantize it and check parity with PyTorch."""
from rukh.export import export_all
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
try:
bundle = export_all(
ckpt,
out,
opset=opset,
seq_len=seq_len,
fp16=fp16,
int8=int8,
check_parity=check_parity,
positions=positions,
games=games,
)
except (ImportError, ValueError) as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
if as_json:
typer.echo(bundle.model_dump_json(indent=2))
return
typer.echo(f"exporter: {bundle.onnx.exporter} (opset {bundle.onnx.opset})")
if bundle.onnx.warning:
typer.echo(f"warning: {bundle.onnx.warning}")
checked = "verified" if bundle.onnx.dynamic_seq_verified else "not verified"
typer.echo(
f"shapes: batch dynamic={bundle.onnx.dynamic_batch}, "
f"sequence dynamic={bundle.onnx.dynamic_seq} ({checked}), block={bundle.onnx.block}"
)
typer.echo(f"fp32: {bundle.onnx.path} ({bundle.onnx.bytes} bytes)")
for quantized in (bundle.fp16, bundle.int8):
if quantized is not None:
typer.echo(
f"{quantized.kind}: {quantized.path} ({quantized.bytes} bytes, "
f"{quantized.ratio:.2f} of fp32, {quantized.method})"
)
for kind, result in bundle.parity.items():
typer.echo(
f"parity {kind}: {result.agreement:.4f} on {result.positions} "
f"{bundle.parity_source} positions "
f"(max |delta logits| {result.max_abs_logit_delta:.4g})"
)
if bundle.parity_warning:
typer.echo(f"warning: {bundle.parity_warning}")

src/rukh/cli.pylíneas 499-576 · p2

Terminal
uv run rukh export --ckpt checkpoints/small-<fecha>/best.pt --out artifacts/onnx/small --fp16 --int8 --check-parity

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

exporter: dynamo (opset 18)
shapes: batch dynamic=True, sequence dynamic=True (verified), block=200
fp32: artifacts/onnx/small/model.onnx (156743532 bytes)
fp16: artifacts/onnx/small/model-fp16.onnx (78804476 bytes, 0.50 of fp32, onnxconverter_common.float16)
int8: artifacts/onnx/small/model-int8.onnx (43459142 bytes, 0.28 of fp32, onnxruntime.quantization.quantize_dynamic)
parity fp32: 1.0000 on 1000 validation positions (max |delta logits| 2.551e-05)
parity fp16: 0.9980 on 1000 validation positions (max |delta logits| 0.01404)
parity int8: 0.9540 on 1000 validation positions (max |delta logits| 1.75)

Dynamo, eje verificado y posiciones de validación: las tres cosas se imprimen porque podían haber salido de otra manera. Y el listón del hito (≥ 99,9 % de paridad de jugada) no lo pasa ninguna de las dos cuantizaciones.

Fichero Tamaño Paridad de jugada Max |Δ logits| ¿Pasa el ≥ 99,9 %?
fp32 156,7 MB 100 % 2,551e-05 sí
fp16 78,8 MB 99,80 % 0,01404 no
int8 43,5 MB 95,40 % 1,75 no

fp16 se queda a una décima del listón: cambia la jugada en 2 de cada 1 000 posiciones y sus logits se mueven una centésima. Es prácticamente el mismo modelo, y es el que juega en la demo cuando hay WebGPU.

int8 cambia la jugada en 46 de cada 1 000 posiciones y mueve los logits hasta 1,75, del tamaño de la distancia habitual entre la primera y la segunda jugada. Es un modelo medible y distinto, y nadie ha medido cuánto Elo cuesta. Encima es el que descarga un móvil sin WebGPU, así que quien juegue desde un teléfono antiguo se enfrenta a una variante que la tabla única no mide. Medir su Elo por separado queda para un hito posterior.

Piensa en una foto guardada en JPEG a dos calidades. A calidad 95 pesa la mitad y nadie distingue la copia sin una lupa: eso es fp16. A calidad 40 pesa un cuarto y de lejos sigue siendo la misma foto, pero si había que leer un texto pequeño (una posición con dos jugadas casi empatadas), ahí se pierde: eso es int8. Cuánto se pierde no lo dice el tamaño; hay que mirar la foto, y aquí, jugar las partidas. Es la cuenta que haces al elegir un LLM cuantizado a 8 o a 4 bits para tu portátil.

// Ejercicio 01¿Por qué int8 cambia los logits y no la jugada?

La diferencia máxima de logits del int8 es más de cien veces la del fp16 (1,75 frente a 0,01404), y aun así su paridad de jugada es del 95,40 %. Explica por qué un desastre en los logits se queda en un desperfecto moderado en las jugadas, y en qué posiciones esperarías que int8 falle.

// SoluciónVer la solución

Int8 representa cada matriz con 256 niveles y una escala por tensor: un error del 0,4 % por peso que se acumula en doce capas y desplaza los logits de forma visible. Pero elegir depende del orden de los logits, y en la mayoría de posiciones la distancia entre el primero y el segundo es mucho mayor que ese ruido. fp16, con 10 bits de mantisa, mete un error unas cien veces menor.

Int8 falla donde el margen es pequeño: dos o tres jugadas casi empatadas (aperturas con varias continuaciones estándar, finales simples donde varias jugadas mantienen la evaluación) o posiciones en las que el modelo duda en general. Ahí las jugadas suelen ser igual de razonables, así que el efecto sobre la fuerza probablemente es menor que un 4,6 %. Pero es un «probablemente» que nadie ha medido, y por eso el 95,40 % se publica como incumplimiento del listón y no como nota al pie.

Ver el entrenamiento: TrainingReplay

La isla recorre los checkpoints de la tirada real: un paso del deslizador, un step-*.pt. El gráfico dibuja las dos pérdidas contra el eje izquierdo y el top-1 contra el derecho; debajo van los números del checkpoint elegido, con la legalidad sin máscara y el Elo estimado, que en esta tirada no se midió (sale un guion). El JSON lo produce labs/m2/replay_export.py, en la lección 10.

Tres cosas que mirar, en este orden:

  1. Dónde se separan las dos pérdidas. Mientras van juntas, el modelo generaliza. Donde la de validación se aplana y la otra sigue bajando empieza el sobreajuste, y eso decide el best.pt.
  2. Cuánto compra la segunda mitad. Compara el paso 10 000 con el 20 000: esa diferencia de top-1 es lo que valieron veinte minutos de GPU.
  3. Cuándo despega la legalidad. Del paso 1 000 al 8 000 pasa de 88,0 % a 99,0 %; sigue hasta el final y comprueba que ya no se mueve.
Pérdida, exactitud top-1 y legalidad a lo largo del entrenamiento20 checkpoints, del paso 1000 al 20000. Debajo de las curvas, una barra por checkpoint con su legalidad sin máscara frente al listón del 99 %. Los valores exactos del checkpoint seleccionado están en la lista que sigue al gráfico.2,751,361,502,002,5052,6 %32,0 %35 %40 %45 %50 %paso 1 000paso 20 0005 00010 00015 000legalidad sin máscara (argmax)100 %85 %paso 1 000: 88,0 %paso 2 000: 94,0 %paso 3 000: 95,0 %paso 4 000: 97,8 %paso 5 000: 97,0 %paso 6 000: 97,0 %paso 7 000: 98,5 %paso 8 000: 99,0 %paso 9 000: 98,8 %paso 10 000: 99,3 %paso 11 000: 99,5 %paso 12 000: 99,3 %paso 13 000: 99,0 %paso 14 000: 99,3 %paso 15 000: 99,8 %paso 16 000: 99,3 %paso 17 000: 99,5 %paso 18 000: 99,3 %paso 19 000: 99,3 %paso 20 000: 99,3 %listón 99 %
Eje izquierdo: pérdida (entrenamiento y validación). Eje derecho: top-1 de siguiente jugada en validación. Tira inferior: legalidad sin máscara por argmax de cada checkpoint, con el listón del 99 % marcado. La línea vertical marca el checkpoint seleccionado.

pérdida de entrenamiento pérdida de validación top-1 de validación legalidad sin máscara (barras)

Paso
20000
Pérdida (train)
1,459
Pérdida (val)
1,520
Top-1 (val)
51,2 %
Legalidad sin máscara
99,3 %
Elo estimado
—

small-20260919-062911 · preset small · 20 checkpoints · generado 2026-09-19T08:49:05+00:00

Casi todo el descenso ocurre pronto. De la pérdida de validación de 2,6279 en el paso 1 000 a la de 1,5197 en el 20 000, 0,85 caen en los primeros 5 000 pasos y solo 0,26 en los 15 000 restantes. El top-1 sube 12,2 puntos en los primeros 5 000 pasos, 5,6 en los siguientes, y la segunda mitad entera (del 10 000 al 20 000) compró 2,4.

En los pasos 1 000 y 2 000 la validación va por debajo de la de entrenamiento, por el motivo del segundo ejercicio de la lección 3. Se cruzan en el 3 000 y desde ahí el hueco crece: +0,021 en el 10 000, +0,046 en el 15 000 y +0,060 en el 20 000, un 4 % de la pérdida. Hay sobreajuste, pero la validación todavía baja en los últimos mil pasos, así que el best.pt es el último checkpoint. La lectura obvia es que faltaba entrenar más; la última lección del módulo la pone a prueba y encuentra otra causa.

La fila de la legalidad es donde se ve emerger el juego. El modelo no tiene tablero ni reglas: solo ve secuencias de símbolos. En el paso 1 000 propone una jugada imposible en el 12 % de las posiciones; en el 8 000 respeta las reglas en el 99,0 %. Y ahí se para: hasta el 20 000 solo gana una posición de 400 (99,25 %), mientras la pérdida de validación baja de 1,6727 a 1,5197 y el top-1 sube de 47,78 % a 51,20 %. La legalidad satura mucho antes que la pérdida.

No leas de más la parte plana. Cada punto se midió sobre 400 posiciones, así que el ruido ronda ±1 punto: el 99,75 % del paso 15 000 y el 99,25 % del 20 000 son 399 y 397 posiciones legales. Lo que significa algo es el tramo de subida.

De ahí sale lo más importante de la sección: legalidad y fuerza son cosas distintas. En el paso 8 000 el modelo ya cumple las reglas y juega peor que en el 20 000, con una pérdida un 10 % más alta y 3,4 puntos menos de top-1. Saber que un alfil va en diagonal no es saber adónde conviene moverlo. Un LLM aprende pronto a escribir frases gramaticales y JSON bien formado, y mucho después a decir cosas correctas con ellos. Por eso el ≥ 99 % es un requisito de entrada y la tabla única trae además top-1, puzles y Elo. El Elo por checkpoint (--with-elo) cuesta una noche de partidas y no se midió: se sabe cuánta imitación compró cada tramo, no cuánta fuerza.

Ver la atención: AttentionMap

Abajo está la matriz de una cabeza de una capa sobre una partida corta (el mate de Légal, trece jugadas más los tokens de control). Cada fila es una jugada que mira; cada columna, una jugada mirada. La mitad superior derecha está vacía: es la máscara causal, que ahora puedes ver en lugar de creértela. El color es relativo al máximo de la cabeza elegida, así que solo tiene sentido comparar dentro de una cabeza.

Qué buscar mientras cambias de capa y de cabeza:

  • La subdiagonal encendida. Justo a la izquierda de la diagonal está «la jugada anterior», el vecino más informativo de una secuencia. Suele haber alguna cabeza dedicada a ella; en este modelo hay una sola, y la isla abre en ella (capa 9, cabeza 4).
  • Columnas verticales. Una columna entera encendida es un token al que mira todo el mundo. En los Transformers de texto se llama sumidero de atención: la cabeza no tiene nada útil que mirar y vuelca su peso en el primer token. Es quien, en una reunión, mira al techo cuando la pregunta no va con él; una columna encendida en <bos> es el estado de reposo, no un hallazgo.
  • Saltos lejos de la diagonal. La jugada 12 mirando a la 5, cuando se movió la pieza que ahora se captura. Es lo más cerca que se está de ver al modelo «razonar» sobre el tablero, y lo más fácil de sobreinterpretar.
  • Diferencias entre capas. Se suele contar que las primeras capas son locales, las del medio estructuradas y las últimas difusas. Compruébalo antes de repetirlo: aquí la mitad no se cumple.

La jugada 1 (<bos>) reparte su atención sobre todo en: 1 <bos> (100,0 %)

Capa 9, cabeza 4. Cada fila es una jugada que mira; cada columna, una jugada mirada. El color es relativo al máximo de esta cabeza (100,0 %) y el valor exacto está en el título de cada celda.
Jugada que miraJugada 1, <bos>Jugada 2, <w1800>Jugada 3, <b1800>Jugada 4, e2e4Jugada 5, e7e5Jugada 6, g1f3Jugada 7, b8c6Jugada 8, f1c4Jugada 9, d7d6Jugada 10, b1c3Jugada 11, c8g4Jugada 12, f3e5Jugada 13, g4d1Jugada 14, c4f7Jugada 15, e8e7Jugada 16, c3d5
1 <bos>100,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
2 <w1800>99,9 %0,1 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
3 <b1800>1,7 %87,7 %10,6 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
4 e2e40,2 %0,2 %98,7 %0,8 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
5 e7e50,0 %0,2 %0,7 %0,9 %98,2 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
6 g1f30,0 %0,0 %0,1 %0,0 %99,9 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
7 b8c60,0 %0,1 %0,4 %0,3 %2,3 %4,9 %91,9 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
8 f1c40,0 %0,0 %0,0 %0,0 %0,1 %0,0 %99,9 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
9 d7d60,0 %0,0 %0,5 %0,1 %0,5 %0,7 %73,4 %19,7 %5,1 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
10 b1c30,0 %0,0 %0,0 %0,0 %0,0 %0,0 %1,2 %0,0 %98,8 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
11 c8g40,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,5 %0,0 %99,4 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
12 f3e50,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %100,0 %0,0 %0,0 %0,0 %0,0 %0,0 %
13 g4d10,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %100,0 %0,0 %0,0 %0,0 %0,0 %
14 c4f70,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %100,0 %0,0 %0,0 %0,0 %
15 e8e70,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %100,0 %0,0 %0,0 %
16 c3d50,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %0,0 %7,0 %0,0 %93,0 %0,0 %

0100,0 %Subdiagonal encendida (la celda justo a la izquierda de la diagonal): cada jugada mira a la anterior. Diagonal: cada jugada se mira a sí misma. Columna encendida: una jugada a la que mira todo el mundo.

rukh-small · checkpoint checkpoints\small-20260919-062911\best.pt · generado 2026-09-19T08:27:28+00:00

La tabla sale de leer las 96 matrices de 16 × 16 con un script, no de mirar colores, que es la forma más rápida de contarse una película. Donde dice «KL frente a la uniforme» es la divergencia KLPenalización KLLa divergencia KL mide cuánto se aleja una distribución de otra: Σ p · log(p/q), cero si son iguales y mayor cuanto más difieren (no es simétrica, así que no es una distancia en sentido estricto). La penalización KL es el término que ata la política a la copia congelada de donde salió; sin él, una recompensa verificable es una invitación a dejar de jugar al ajedrez y ponerse a cultivar el número. Rukh usa el estimador k3, exp(d) − d − 1 con d = log π_ref − log π, que es no negativo por construcción: la diferencia de log-probabilidades a secas es negativa la mitad de las veces y pagaría al modelo precisamente por alejarse. entre la fila de atención y un reparto a partes iguales: 0 si la cabeza mira a todos los tokens por igual, y más cuanto más se concentra en unos pocos.

Qué Dónde Medido Lectura
Cabeza de «jugada anterior» L9H4 0,80 de media en la subdiagonal; 1,000 en cuatro filas seguidas. Siguientes: L8H7 0,65, L7H7 0,60, L4H3 0,58, L9H7 0,53; el resto < 0,45 Limpia: hay una y es inequívoca
Primera capa sin patrones L0H0-L0H7 KL frente a la uniforme 0,23-0,34 (media del modelo 0,72; L9H4 1,90); en L0H0 la celda más alta de la última fila es 0,198 Contradice «las primeras capas son locales»: la capa 0 reparte
Sumideros, y no solo en <bos> capas 10-11; L3H7 7 de 8 cabezas de la capa 11 con el máximo en <bos> (0,345-0,513); de media, <bos> 0,229 y <b1800> 0,226; L3H7 0,746-0,986 sobre <b1800> Estado de reposo, repartido entre dos tokens de control
Saltos lejos de la diagonal L5H5, L4H0, L1H3 0,999 sobre d7d6 en la fila de f3e5; 0,987 sobre f1c4 en la de g4d1; el mismo token como máximo en 3 y en 8 filas Sin concluir: «columna enganchada» o «pieza que se captura», y una partida no lo distingue
  • Lo limpio. L9H4 es la cabeza de jugada anterior: 0,80 de media en la subdiagonal, frente a un máximo de 0,65 en las otras noventa y cinco. Solo se sale del guion en las tres primeras jugadas negras, y no sé por qué; mejor dejarlo escrito que inventar una explicación.
  • Lo que contradice. La capa 0 no tiene patrones locales: sus ocho cabezas son casi uniformes. La primera capa reparte, y ya está.
  • Lo que matiza. Las capas 10 y 11 sí son difusas, pero el sumidero se reparte entre <bos> y <b1800>, el último token de control antes de la primera jugada, y L3H7 se dedica a este en exclusiva.
  • Lo que queda sin concluir. L5H5 pone 0,999 sobre d7d6 justo en la fila de f3e5, el caballo que captura en e5 después de que ese d7d6 dejara de defender el peón. Tentador, pero la misma cabeza tiene su máximo en d7d6 en tres filas, y L1H3 en e7e5 en ocho: se parece más a una cabeza enganchada a una columna. Con una partida de 16 tokens no se puede distinguir; haría falta un centenar y una estadística.

Por qué el modelo «aprende el tablero» sin verlo

El modelo nunca ha visto un tablero. Tiene 2 030 símbolos y seis millones de secuencias, y aun así propone jugadas legales el 99,4 % de las veces, lo que exige saber dónde está cada pieza después de cuarenta movimientos, incluida la torre que no se mueve desde la jugada 3. Esa información no está en el token actual: hay que reconstruirla de toda la historia.

La lectura obligatoria del módulo es Chess-GPT’s Internal World Model (2024), de Adam Karvonen, en su web, con código y modelos bajo licencia MIT. Un nanoGPT de 50 millones de parámetros, entrenado con 16 millones de partidas en PGN carácter a carácter, llegó a unos 1300 de Elo con un 99,8 % de jugadas legales en un día de GPU. small mide 1359 y 99,4 % con 5,9 millones de partidas, 42 minutos y un modelo un 22 % más pequeño. Son del mismo orden y con escaleras distintas: sirve para calibrar, no para ganar.

Qué se demostró. El método son las sondas linealesProbe linealCongelar un modelo y entrenar encima solo una capa lineal para una tarea nueva. Si la capa lineal acierta, la información ya estaba en la representación y el preentrenamiento la puso ahí; si no acierta, no se puede concluir que no esté, solo que no está de forma linealmente legible. En Rukh es el modo probe de rukh train heads, y un test comprueba que los pesos del encoder salen idénticos bit a bit.: se congela el modelo, se toman las activaciones de una capa intermedia y se entrena una regresión lineal (una matriz, sin capas ocultas) para predecir qué pieza hay en cada una de las 64 casillas. Acierta casi siempre: el tablero está en las activaciones de forma linealmente legible, aunque nadie se lo enseñara.

Una sonda lineal es como una nota final ponderada: tanto por el examen, tanto por las prácticas, sumado. No calcula nada por su cuenta. Si con una suma ponderada de las activaciones ya sale «hay un caballo en f3», el tablero estaba escrito en ellas. Una sonda con capas ocultas podría estar calculándolo ella, y no demostraría nada sobre el modelo.

La segunda parte convierte la correlación en algo más fuerte. Karvonen interviene: empuja las activaciones en la dirección que la sonda asocia a «hay una pieza en esta casilla», como si la borrase del tablero interno, y las jugadas del modelo cambian de forma coherente con el tablero editado. La representación no solo está ahí: el modelo la usa.

Qué no se demostró, que importa más para tu criterio:

  • Que el modelo tenga reglas. Puede haber aprendido las regularidades que produce el tablero sin la regla que las genera, y solo la regla generaliza a posiciones raras.
  • Que busque o calcule. Hay una única pasada hacia delante con un número fijo de operaciones. Ese es seguramente el techo del enfoque, y el motivo de que el modelo de DeepMind que llegó a 2895 sin búsqueda (Ruoss et al., 2024) necesitara 270 millones de parámetros y diez millones de partidas anotadas por Stockfish.
  • Que cualquier representación sea recuperable. Una sonda encuentra lo linealmente legible; que no encuentre algo no significa que no esté.
  • Nada sobre nuestra tokenización (una jugada por token, no PGN por carácter). Es una hipótesis hasta que la midas, y por eso M3 monta sondas sobre nuestro propio encoder.
  • Y una trampa lógica: el tablero es función determinista de la lista de jugadas, así que la información está por fuerza en la entrada. Lo sorprendente es que el modelo la haya destilado en forma explícita y lineal: esa es la frontera entre memorizar la secuencia y modelar el dominio.

Qué has aprendido

Cómo sale el modelo de PyTorch hacia el navegador, y las comprobaciones que lo convierten en una afirmación en vez de una esperanza: el grafo del último paso, el eje dinámico comprobado ejecutando, los metadatos dentro del fichero y la paridad sobre posiciones reales.

uv run rukh export … --fp16 --int8 --check-parity imprime las tres paridades: 100 % en fp32, 99,80 % en fp16 y 95,40 % en int8, con el listón en 99,9 %. Dos de los tres no lo pasan, y está escrito así. Lo que te llevas a cualquier despliegue: el tamaño de un modelo cuantizado se lee en el fichero; lo que cuesta, solo midiendo con tu tarea.

Lo siguiente es el navegador: el worker que posee la sesión de ONNX Runtime y el contrato que el fichero tiene que cumplir antes de dejarle jugar.