// 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.
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
| Lab | Ruta | Reloj | Deja | Atajo |
|---|---|---|---|---|
| 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ón | model{,-fp16,-int8}.onnx, con la paridad impresa en consola | los 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:
"onnx>=1.23", "onnxscript>=0.6", "onnxruntime>=1.30", "onnxconverter-common>=1.16", "safetensors>=0.6",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
"""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 themodel 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 browserfrom slicing a megabyte of logits per move.
``torch.onnx.export`` is tried with the dynamo exporter first (the default since torch 2.9 andwhat ``docs/spec/02`` asks for) and falls back to the legacy TorchScript tracer with a warningwhen 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 whenit is known which one ran.
``dynamic_seq`` is not taken on trust. The legacy tracer happily bakes the traced length intothe graph while still being asked for a dynamic axis, and the demo feeds a sequence that growsby one token per move, so the exported file is **run** at two different lengths and the flagreports what actually worked. The model's context (``block``) travels with the file as ONNXmetadata, so the browser knows the limit without being told separately."""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
forwardexportado 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
from __future__ import annotations
import contextlibimport loggingimport sysfrom collections.abc import Iteratorfrom pathlib import Pathfrom typing import Any, Literal
import torchfrom pydantic import BaseModel, ConfigDictfrom 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 = 18METADATA_PREFIX = "rukh_"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, :]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_*
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 = NoneEn 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.
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_NAMEdef _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 axesDos 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.
@contextlib.contextmanagerdef _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)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.
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) 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, )El except cae al exportador antiguo y guarda el motivo en warning para que acabe impreso.
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, )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.
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})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 TrueLa trazada y otra legal distinta: dos inferencias bastan para distinguir un eje dinámico de uno horneado.
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 entriesLos 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.
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}def _load(ckpt: Path) -> MoveDecoder: from rukh.train import load_model
model, _payload = load_model(ckpt) return modelquantize.py: la mitad y la cuarta parte
"""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 int8one (about 40 MB), which is the difference between a demo that loads on a phone and one thatdoes not. Quantization is restricted to ``MatMul`` and ``Gemm``: those are the weights that makeup 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 andoutputs in float32 and leaves the ops that overflow in fp32). When it is not installed there isa 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."""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
from __future__ import annotations
import loggingfrom pathlib import Pathfrom 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"]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.0def _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 / namedef 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, )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.
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 modelEl 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.
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, )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?
"""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 on0.1 % of positions is fine and one that agrees on the numbers but not on the move is not. Bothare 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 iswhat tells fp32, fp16 and int8 apart.
The positions are the thousand **validation** prefixes of ``docs/spec/02`` §6, the same ones thelegality and accuracy metrics use: a random legal walk visits positions no human would reach, soagreeing on them says little about the file the demo will load. ``random_prefixes`` stays for thecase where no validation parquet is around (and for the tests), and which of the two was used isreported alongside the number."""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
from __future__ import annotations
import loggingimport randomfrom collections.abc import Sequencefrom pathlib import Path
import chessimport numpy as npimport torchfrom pydantic import BaseModel, ConfigDict
from rukh.export.onnx import INPUT_NAME, LastStepLogitsfrom rukh.models import MoveDecoderfrom rukh.tokenize.uci_vocab import UciTokenizer
log = logging.getLogger(__name__)
DEFAULT_N = 1_000MAX_MISMATCHES = 20DEFAULT_GAMES = "data/uci/year=2025/month=02/games.parquet""""The validation month of P1: the parity positions come from here when it exists."""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."""mismatches guarda las primeras veinte posiciones donde la jugada cambió, para ir a mirarlas
cuando una paridad salga mal sin reejecutarlo todo con prints.
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 prefixesdef 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]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", warningvalidation_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.
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"])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, )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.
def _load(ckpt: Path) -> MoveDecoder: from rukh.train import load_model
model, _payload = load_model(ckpt) return model__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
"""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 int8files the two browser backends need, and check every file it produced against PyTorch."""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,)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 = Nonedef 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 bundleexport_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.
__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",]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
@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}")uv run rukh export --ckpt checkpoints/small-<fecha>/best.pt --out artifacts/onnx/small --fp16 --int8 --check-paritySalida real de la ejecución de referencia (RTX 5090):
exporter: dynamo (opset 18)shapes: batch dynamic=True, sequence dynamic=True (verified), block=200fp32: 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:
- 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. - 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.
- 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 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
- —
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 %)
| Jugada que mira | Jugada 1, <bos> | Jugada 2, <w1800> | Jugada 3, <b1800> | Jugada 4, e2e4 | Jugada 5, e7e5 | Jugada 6, g1f3 | Jugada 7, b8c6 | Jugada 8, f1c4 | Jugada 9, d7d6 | Jugada 10, b1c3 | Jugada 11, c8g4 | Jugada 12, f3e5 | Jugada 13, g4d1 | Jugada 14, c4f7 | Jugada 15, e8e7 | Jugada 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 e2e4 | 0,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 e7e5 | 0,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 g1f3 | 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 % | 0,0 % | 0,0 % |
7 b8c6 | 0,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 f1c4 | 0,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 d7d6 | 0,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 b1c3 | 0,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 c8g4 | 0,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 f3e5 | 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 % | 0,0 % |
13 g4d1 | 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 % | 0,0 % |
14 c4f7 | 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 % | 0,0 % |
15 e8e7 | 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 % | 100,0 % | 0,0 % | 0,0 % |
16 c3d5 | 0,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.
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
d7d6justo en la fila def3e5, el caballo que captura en e5 después de que esed7d6dejara de defender el peón. Tentador, pero la misma cabeza tiene su máximo end7d6en tres filas, y L1H3 ene7e5en 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.