// M2 · lección 09
Publicar el modelo
Los pesos, los tres ONNX, el vocabulario y una model card generada de las métricas que se midieron: cómo se empaqueta un modelo para que alguien más lo pueda usar, por qué safetensors rechaza los tied embeddings y qué tiene que decir una tarjeta para no ser publicidad.
Qué vas a construir
rukh publish model: 423 líneas de Python y una plantilla de 143 que convierten un checkpoint en un
repositorio del Hub que alguien que no eres tú puede usar. Los pesos, los tres .onnx, el vocabulario exacto con el que se entrenó,
un config.json con la procedencia y una model cardModel cardEl README de un repositorio del Hub, y la afirmación pública de qué se publicó y qué se midió. Las de Rukh se generan desde `run.json` y `results.json`, no se escriben a mano, y llevan el SHA del fichero y el de los pesos, el muestreo y la suite de cada número, la fecha, lo que no se midió y la licencia. `rukh publish cards` las regenera todas desde la tabla y sube solo el README. en inglés
generada de las métricas medidas y no escrita a mano.
Esa última parte es la que hace la lección. Una tarjeta escrita a mano es una tarjeta que se queda vieja en la primera reevaluación, y una tarjeta que solo dice lo bueno es un folleto.
publish/model.py
"""Publish a trained decoder to the Hugging Face Hub: weights, ONNX, tokenizer and card.
One repository per stage holds everything somebody needs to reproduce or run the model: thePyTorch weights, the fp16 and int8 ONNX files the demo loads, the exact vocabulary the model wastrained on, and an English card written from the MLflow run and the evaluation ``results.json``rather than by hand, so the numbers in the card are the numbers that were measured.
The folder is always staged locally first (under ``artifacts/publish/<repo>/``) and uploaded inone ``upload_folder`` call; ``dry_run`` stops after staging and touches no network. Every``HfApi`` call goes through ``_api`` so the tests can replace it, exactly as ``data/publish``does.
The head is tied to the token embedding, so ``lm_head.weight`` and ``tokens.weight`` are onetensor under two names. ``safetensors`` refuses to serialise that (it stores tensors, notaliases) and counting both would inflate the parameter count by a whole embedding table, so thetied name is dropped from what is written and the loader re-ties it: ``MoveDecoder`` builds thetie in its constructor and ``rukh.train.load_state`` accepts the missing name."""Cuatro párrafos y cuatro decisiones.
Un repositorio por etapa, con todo lo que hace falta para reproducir o ejecutar el modelo. No solo los pesos: también los ONNX que carga la demo y el vocabulario, porque un modelo cuyo tokenizador vive en otro sitio es un modelo que alguien va a ejecutar con el tokenizador equivocado.
La carpeta se prepara en local y se sube en una sola llamada. dry_run para después de
prepararla y no toca la red, lo que significa que puedes ver exactamente qué se va a publicar antes
de publicarlo.
Todas las llamadas al Hub pasan por _api, igual que en el publicador de datasets de M1, así que
los tests lo sustituyen sin fingir la red.
Y la cuarta es la buena, y es una consecuencia directa de la lección 2: la cabeza está atada al embedding, así que lm_head.weight y tokens.weight son un tensor con dos nombres. safetensorssafetensorsFormato de serialización de tensores que el Hub de Hugging Face espera y previsualiza, y que no ejecuta código al leerse. Guarda tensores y no alias, así que un modelo con tied embeddings —donde `lm_head.weight` y `tokens.weight` son un tensor con dos nombres— tiene que publicar el nombre atado fuera del fichero y volver a atarlo al cargar. se niega a serializar eso —guarda tensores, no alias— y contar los dos inflaría el
número de parámetros en una tabla de embeddings entera. Así que el nombre atado se quita de lo que se
escribe y el cargador lo vuelve a atar. Las dos mitades de ese acuerdo ya las has visto: el
constructor de MoveDecoder crea el atado y rukh.train.load_state acepta que ese nombre falte.
from __future__ import annotations
import jsonimport loggingimport shutilfrom pathlib import Pathfrom typing import Any
from jinja2 import Environment, FileSystemLoader, StrictUndefinedfrom pydantic import BaseModel, ConfigDict
from rukh import __version__from rukh.config import BaseConfigfrom rukh.data.publish import CARDS_DIRfrom rukh.paths import resolvefrom rukh.tokenize.uci_vocab import UciTokenizerfrom rukh.train.checkpoint import TIED_HEAD
log = logging.getLogger(__name__)
CARD_TEMPLATE = "model.md.jinja"CONFIG_NAME = "config.json"README_NAME = "README.md"SAFETENSORS_NAME = "model.safetensors"TORCH_NAME = "pytorch_model.bin"VOCAB_PATH = "tokenizer/vocab.json"ONNX_DIR = "onnx"ONNX_FILES = ("model-fp16.onnx", "model-int8.onnx", "model.onnx")REPO_TYPE = "model"class ModelPublishConfig(BaseConfig): """Where the staged folder goes and which links the card carries."""
owner: str = "chorcat" publish_dir: str = "artifacts/publish" eval_dir: str = "artifacts/eval" demo_url: str = "https://rukh.borjaglez.com" course_url: str = "https://lab.rukh.borjaglez.com" repository_url: str = "https://github.com/borja-glez/rukh" license: str = "apache-2.0" datasets: list[str] = ["chorcat/rukh-games-1800", "chorcat/rukh-tokenizer"]ModelPublishConfig son once líneas y casi todas son enlaces, porque una tarjeta sin enlace a la
demo y al curso es una tarjeta que no lleva a ninguna parte. La licencia por defecto es
apache-2.0: el código del proyecto es MIT y los pesos son Apache-2.0, que no es lo mismo y está
decidido en el README de M0.
class RunSummary(BaseModel): """The part of an MLflow run a card needs."""
model_config = ConfigDict(extra="forbid")
run_id: str name: str | None = None params: dict[str, str] = {} metrics: dict[str, float] = {}
class ModelPublishResult(BaseModel): """What was staged and, unless this was a dry run, uploaded."""
model_config = ConfigDict(extra="forbid")
repo_id: str repo_type: str = REPO_TYPE stage: str dry_run: bool folder: str card_path: str files: list[str] weights_format: str """``safetensors`` or ``torch`` when ``safetensors`` is not installed.""" run_id: str | None = None params: int tied_embeddings: bool = False """``lm_head.weight`` was left out of the weights file and is re-tied when loading."""def _api(): # type: ignore[no-untyped-def] """The ``HfApi`` client (tests monkeypatch this).""" from huggingface_hub import HfApi
return HfApi()
def _client(): # type: ignore[no-untyped-def] """The MLflow client on the local store (tests monkeypatch this).""" from mlflow.tracking import MlflowClient
from rukh.tracking import tracking_uri
return MlflowClient(tracking_uri=tracking_uri(create=False))def read_run(run_id: str | None = None, run_name: str | None = None) -> RunSummary | None: """The MLflow run behind a checkpoint, by id or by the most recent run of that name.
A missing store, a missing run or an MLflow that cannot be reached is not an error: the card simply falls back to what the checkpoint itself records. """ from rukh.tracking import EXPERIMENT
try: client = _client() if run_id: run = client.get_run(run_id) else: experiment = client.get_experiment_by_name(EXPERIMENT) if experiment is None or not run_name: return None found = client.search_runs( [experiment.experiment_id], filter_string=f"attributes.run_name = '{run_name}'", order_by=["attributes.start_time DESC"], max_results=1, ) if not found: return None run = found[0] except Exception as exc: # noqa: BLE001 - tracking is optional, never fatal for a release log.warning("could not read the MLflow run (%s: %s)", type(exc).__name__, exc) return None return RunSummary( run_id=run.info.run_id, name=run.info.run_name, params={str(k): str(v) for k, v in run.data.params.items()}, metrics={str(k): float(v) for k, v in run.data.metrics.items()}, )De dónde sale la receta de la tarjeta: de la ejecución de MLflow, por id o por el nombre más reciente
que coincida con el directorio del checkpoint. Y el except Exception con su comentario: una base
de MLflow que falta, una ejecución borrada o un MLflow inalcanzable no son un error. La tarjeta se
queda con lo que el propio checkpoint registra y se publica igual. Un lanzamiento que se cae porque
el servidor de métricas no responde es un lanzamiento con las prioridades cambiadas.
def read_eval(stage: str, cfg: ModelPublishConfig) -> dict[str, Any] | None: """The ``results.json`` the evaluation harness wrote for this stage, if it ran.""" path = resolve(cfg.eval_dir) / stage / "results.json" if not path.is_file(): return None try: payload = json.loads(path.read_text(encoding="utf-8")) except ValueError: return None return payload if isinstance(payload, dict) else Nonedef publish_state(model: Any) -> dict[str, Any]: """The state dict as it is published: on the CPU, contiguous and with no aliased tensor.
With tied embeddings ``lm_head.weight`` *is* ``tokens.weight``; it is dropped here so the file holds every tensor exactly once. Loading re-ties it (see the module docstring). """ state = {key: value.detach().cpu().contiguous() for key, value in model.state_dict().items()} if model.cfg.tie_embeddings: state.pop(TIED_HEAD, None) return stateNueve líneas donde vive el cuarto párrafo de la cabecera. .contiguous() porque safetensors
guarda memoria plana y un tensor transpuesto no lo es; y el state.pop(TIED_HEAD) solo si
tie_embeddings, porque un modelo sin atar sí tiene que publicar las dos matrices.
def write_weights(state: dict[str, Any], folder: Path) -> tuple[str, str]: """Write the state dict; returns ``(file name, format)``.
``safetensors`` is the format the Hub expects and the only one it will preview, so it wins when it is installed; otherwise the weights go out as a torch pickle under its own name rather than pretending to be something they are not. The state dict must already be free of aliases (``publish_state``): ``safetensors.save_file`` raises on two names for one storage. """ import torch
tensors = {key: value.detach().cpu().contiguous() for key, value in state.items()} try: from safetensors.torch import save_file except ImportError: log.warning("safetensors is not installed; the weights go out as %s", TORCH_NAME) torch.save(tensors, folder / TORCH_NAME) return TORCH_NAME, "torch" save_file(tensors, str(folder / SAFETENSORS_NAME)) return SAFETENSORS_NAME, "safetensors"safetensors gana cuando está instalado, porque es el formato que el Hub espera y el único del que
hace vista previa. Cuando no está, los pesos salen como un pickle de torch con su propio nombre,
pytorch_model.bin, en vez de fingir ser algo que no son. Esa es la regla del proyecto entera en una
línea: degradar está bien, mentir sobre lo que se degradó no.
def write_config(payload: dict[str, Any], stage: str, params: int, folder: Path) -> dict[str, Any]: """Write ``config.json``: the decoder shape plus the provenance of the checkpoint.""" model_cfg = dict(payload.get("model_cfg") or {}) config = { "architectures": ["MoveDecoder"], "model_type": "rukh-move-decoder", "library_name": "rukh", "rukh_version": __version__, "stage": stage, "step": payload.get("step"), "params": params, "tokenizer": "uci", "vocab_hash": payload.get("vocab_hash"), "data_manifest_sha": payload.get("data_manifest_sha"), "git_sha": payload.get("git_sha"), **model_cfg, } (folder / CONFIG_NAME).write_text( json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n" ) return configEl config.json es la forma del decoder más la procedencia: el paso, el hash del vocabulario, el
SHA del manifiesto de datos y el SHA de git. Los cuatro salen del checkpoint, y son lo que permite a
alguien —o a ti, en M4— reconstruir el modelo exacto y saber con qué se entrenó.
El **model_cfg va al final a propósito: la forma del modelo gana sobre cualquier clave que se
solape. Y ese config.json es el que eval.suite.hub_checkpoint vuelve a leer para evaluar un
modelo publicado, así que el formato es un contrato con el propio harness.
def copy_onnx(onnx_dir: Path | None, folder: Path) -> list[str]: """Copy the exported ONNX files into ``onnx/`` and return what was copied.""" if onnx_dir is None: return [] source = Path(onnx_dir) if not source.is_dir(): raise FileNotFoundError(f"no ONNX directory at {source}") target = folder / ONNX_DIR target.mkdir(parents=True, exist_ok=True) copied: list[str] = [] for name in ONNX_FILES: candidate = source / name if candidate.is_file(): shutil.copy2(candidate, target / name) copied.append(f"{ONNX_DIR}/{name}") if not copied: raise FileNotFoundError(f"{source} holds none of {', '.join(ONNX_FILES)}") return copiedCopiar los ONNX, y dos errores que se lanzan en vez de tragarse: un directorio que no existe y un directorio que no tiene ninguno de los tres ficheros. Publicar un repositorio «con ONNX» que no lleva ONNX es el fallo que nadie detecta hasta que la demo intenta cargarlo.
El orden de ONNX_FILES pone el fp16 y el int8 antes del fp32. No es indiferente: es el orden en que
se listan en la tarjeta, y los dos primeros son los que la demo usa.
def _percent(value: Any) -> str: return "n/a" if value is None else f"{float(value) * 100:.1f} %"
def _sampled_label(sampled: dict[str, Any]) -> str: """The row name of the sampled legality rate, with the setting it was drawn under.""" if not sampled or sampled.get("temperature") is None: return "Legality without the mask, sampled" top_k = sampled.get("top_k") tail = "" if top_k is None else f", top-k {top_k}" return f"Legality without the mask, sampled (T={float(sampled['temperature']):g}{tail})"
def _elo_cell(elo: dict[str, Any]) -> str: """The Elo row: an interval, or the one-sided bound of a separated fit.""" if not elo: return "n/a" if elo.get("ci_low") is not None and elo.get("ci_high") is not None: return f"{elo['elo']:.0f} (95 % CI {elo['ci_low']:.0f}-{elo['ci_high']:.0f})" if elo.get("elo_lower") is not None: return f"> {elo['elo_lower']:.0f} (one-sided 95 % bound; every game won)" if elo.get("elo_upper") is not None: return f"< {elo['elo_upper']:.0f} (one-sided 95 % bound; every game lost)" return f"{elo['elo']:.0f} (no interval)"Tres funciones de formato y una de ellas es la lección de la lección 6 repetida aquí: el nombre de la fila de la legalidad muestreada incluye la temperatura y el top-k con los que se midió. En la tabla de la tarjeta se lee «Legality without the mask, sampled (T=0.05, top-k 1)», así que nadie puede confundir ese número con el de la demo.
Y _elo_cell es la misma lógica de tres ramas de report.py: intervalo, cota inferior, cota
superior. Está duplicada porque la tarjeta lee el results.json ya serializado —diccionarios, no
modelos— y unificarlas obligaría a la tarjeta a importar el harness entero.
def card_context( repo_id: str, stage: str, cfg: ModelPublishConfig, config: dict[str, Any], evaluation: dict[str, Any] | None, run: RunSummary | None, files: list[str],) -> dict[str, Any]: """Everything the Jinja card needs, with every absent metric spelled ``n/a``.""" elo = (evaluation or {}).get("elo") or {} accuracy = (evaluation or {}).get("accuracy") or {} argmax = (evaluation or {}).get("legality_argmax") or {} sampled = (evaluation or {}).get("legality_sampled") or {} puzzles = (evaluation or {}).get("puzzles") or {} metrics = [ ("Legality without the mask, argmax", _percent(argmax.get("rate"))), (_sampled_label(sampled), _percent(sampled.get("rate"))), ("Top-1 next move", _percent(accuracy.get("top1"))), ("Top-3 next move", _percent(accuracy.get("top3"))), ("Puzzles solved", _percent(puzzles.get("rate"))), ("Estimated Elo", _elo_cell(elo)), ] bands = [ (band["band"], _percent(band["rate"])) for band in puzzles.get("bands", []) if isinstance(band, dict) ] recipe = run.params if run else {} notes = [str(note) for note in (evaluation or {}).get("notes", []) if str(note).strip()] return { "notes": notes, "tied_embeddings": bool(config.get("tie_embeddings")), "repo_id": repo_id, "stage": stage, "license": cfg.license, "datasets": cfg.datasets, "demo_url": f"{cfg.demo_url}/?stage={stage}", "course_url": cfg.course_url, "repository_url": cfg.repository_url, "params": config.get("params", 0), "config": config, "config_json": json.dumps(config, indent=2, ensure_ascii=False), "metrics": metrics, "puzzle_bands": bands, "evaluated_on": (evaluation or {}).get("date"), "suite": (evaluation or {}).get("suite"), "run_id": run.run_id if run else None, "recipe": sorted(recipe.items()), "files": files, "has_onnx": any(name.startswith(f"{ONNX_DIR}/") for name in files), "rukh_version": __version__, }El contexto de la plantilla, y el patrón que lo hace honesto: cada métrica ausente se escribe
n/a, nunca se omite la fila y nunca se pone un cero. Compáralo con lo que hacía el informe: la
misma decisión, tomada dos veces, porque las dos salidas se leen sin el contexto de la otra.
Las notes son las advertencias que el harness escribió —los escalones nominales, el tiempo por
jugada, las partidas adjudicadas, los puzles que no se midieron— y van a la tarjeta tal cual.
Ahí está la diferencia entre una model card y un folleto: las limitaciones las escribe el que midió,
no el que publica.
def render_card(context: dict[str, Any]) -> str: """Render the English model card.""" env = Environment( loader=FileSystemLoader(str(CARDS_DIR)), undefined=StrictUndefined, autoescape=False, keep_trailing_newline=True, ) return env.get_template(CARD_TEMPLATE).render(**context)StrictUndefined es la decisión importante de estas nueve líneas: una variable que la plantilla usa
y el contexto no trae lanza en vez de renderizar una cadena vacía. Sin eso, renombrar una clave
del contexto produce una tarjeta con un hueco donde debería estar el Elo, publicada, en internet.
Y autoescape=False porque la salida es Markdown, no HTML: escapar < y & llenaría la tarjeta de
entidades.
def publish_model( ckpt: Path, repo: str, cfg: ModelPublishConfig | None = None, onnx_dir: Path | None = None, stage: str | None = None, run_id: str | None = None, dry_run: bool = False,) -> ModelPublishResult: """Stage (and unless ``dry_run``, upload) one trained decoder as a Hub model repository.""" from rukh.train import load_model
cfg = cfg or ModelPublishConfig() ckpt = Path(ckpt) repo_id = repo if "/" in repo else f"{cfg.owner}/{repo}" name = stage or repo_id.split("/")[-1].removeprefix("rukh-") model, payload = load_model(ckpt) state = publish_state(model) params = model.num_params(non_embedding=False)
folder = resolve(cfg.publish_dir) / repo_id folder.mkdir(parents=True, exist_ok=True) weights_name, weights_format = write_weights(state, folder) config = write_config(payload, name, params, folder) UciTokenizer().export(folder / VOCAB_PATH) files = [weights_name, CONFIG_NAME, VOCAB_PATH, *copy_onnx(onnx_dir, folder)]
run = read_run(run_id, run_name=ckpt.parent.name) evaluation = read_eval(name, cfg) card = render_card(card_context(repo_id, name, cfg, config, evaluation, run, files)) card_path = folder / README_NAME card_path.write_text(card, encoding="utf-8", newline="\n")
if not dry_run: api = _api() api.create_repo(repo_id, repo_type=REPO_TYPE, exist_ok=True) api.upload_folder( repo_id=repo_id, folder_path=str(folder), repo_type=REPO_TYPE, commit_message=f"Publish {name}", ) return ModelPublishResult( repo_id=repo_id, stage=name, dry_run=dry_run, folder=folder.as_posix(), card_path=card_path.as_posix(), files=[*files, README_NAME], weights_format=weights_format, run_id=run.run_id if run else None, params=params, tied_embeddings=model.cfg.tie_embeddings, )El comando entero, de arriba abajo: cargar el modelo, quitar el alias, escribir los pesos, escribir la configuración, exportar el vocabulario, copiar los ONNX, leer la ejecución y la evaluación, renderizar la tarjeta y —si no es un ensayo— crear el repositorio y subir la carpeta.
Dos detalles de usabilidad. repo if "/" in repo else f"{cfg.owner}/{repo}" deja escribir
--repo rukh-small sin el dueño. Y name = stage or repo_id.split("/")[-1].removeprefix("rukh-")
deduce la etapa del nombre del repositorio, que es lo que hace que chorcat/rukh-small encuentre
artifacts/eval/small/results.json sin que se lo digas.
exist_ok=True en create_repo hace que republicar sea idempotente, que es lo que quieres cuando
acabas de reevaluar y solo cambia la tarjeta.
"""Publishing trained models to the Hugging Face Hub with cards generated from MLflow."""
from rukh.publish.model import ( CONFIG_NAME, ONNX_FILES, README_NAME, SAFETENSORS_NAME, TORCH_NAME, VOCAB_PATH, ModelPublishConfig, ModelPublishResult, RunSummary, card_context, copy_onnx, publish_model, publish_state, read_eval, read_run, render_card, write_config, write_weights,)
__all__ = [ "CONFIG_NAME", "ONNX_FILES", "README_NAME", "SAFETENSORS_NAME", "TORCH_NAME", "VOCAB_PATH", "ModelPublishConfig", "ModelPublishResult", "RunSummary", "card_context", "copy_onnx", "publish_model", "publish_state", "read_eval", "read_run", "render_card", "write_config", "write_weights",]La plantilla de la tarjeta
Es el primer .jinja del proyecto que no es de datos, y el que va a copiar cada modelo del curso a
partir de aquí. Por partes.
---license: {{ license }}library_name: rukhpipeline_tag: text-generationlanguage: - endatasets:{%- for dataset in datasets %} - {{ dataset }}{%- endfor %}tags: - chess - rukh - decoder - onnx---src/rukh/data/cards/model.md.jinja
El front matter que el Hub lee para indexar: la licencia, la biblioteca, la tarea, los datasets de
origen y las etiquetas. Los {%- for %} con el guion recortan el espacio en blanco anterior, que en
YAML no es cosmético: una línea en blanco de más rompe la lista.
datasets: apunta a los datasets publicados en M1, así que el Hub enlaza el modelo con sus datos
automáticamente. Es lo más cerca que se está de una cadena de procedencia sin montar nada.
# {{ repo_id }}
A GPT decoder written from scratch that plays chess by predicting the next move of a gamewritten in UCI. This is the `{{ stage }}` stage of [Rukh]({{ repository_url }}), a coursethat builds a chess language model end to end: {{ "{:,}".format(params) }} parameters, avocabulary of {{ config.get("vocab_size", 2030) }} fixed tokens and a context of{{ config.get("block", 200) }} moves.
Play against it in the browser: [{{ demo_url }}]({{ demo_url }}) · read how it was built:[{{ course_url }}]({{ course_url }})src/rukh/data/cards/model.md.jinja
## Results
{% if evaluated_on %}Measured with `rukh eval --suite {{ suite }}` on {{ evaluated_on }}.{% else %}Not evaluated yet: run `rukh eval --model <checkpoint>` to fill this table.{% endif %}
| Metric | Value ||---|---|{%- for name, value in metrics %}| {{ name }} | {{ value }} |{%- endfor %}{%- if puzzle_bands %}
Puzzles by difficulty band:
| Band | Solved ||---|---|{%- for band, value in puzzle_bands %}| {{ band }} | {{ value }} |{%- endfor %}{%- endif %}src/rukh/data/cards/model.md.jinja
La tabla de resultados, con un condicional que dice qué se hizo para medirla o, si no se midió, qué comando la llenaría. Un «no evaluado todavía» con la orden al lado es mucho más útil que una tabla de guiones.
Legality is measured **without** the legality mask, twice, because the two numbers answerdifferent questions:
- **argmax** is the share of validation positions whose single most likely token is a legal move, with no temperature and no top-k. It is a property of the weights and it is the definition behind the "at least 99 % legal" bar of the project.- **sampled** draws the token exactly as the demo draws it, so it is what a player would meet with the mask switched off. It is always the lower of the two.
The demo masks illegal moves before sampling, so it never plays one.{% if notes %}How to read these numbers:
{% for note in notes %}- {{ note }}{% endfor %}{% endif %}src/rukh/data/cards/model.md.jinja
Y aquí está el párrafo que justifica la mitad del módulo, en la tarjeta pública y no en un documento interno: las dos definiciones de legalidad, cuál es el listón, y la frase que cierra —«la demo enmascara las jugadas ilegales antes de muestrear, así que nunca juega una»—. Quien descargue estos pesos sabe qué está midiendo cada número antes de usarlo.
El {% if notes %} de debajo imprime las advertencias del harness bajo el título «How to read these
numbers». Ese título es deliberado: no son notas al pie, son instrucciones de lectura.
## Input and output
The model reads a game as a sequence of tokens and predicts the next one:
```<bos> <w1800> <b1800> e2e4 e7e5 g1f3 ... <1-0> <eos>```
The two header tokens are the 100-Elo bins of White and Black. Moves are UCI strings(`e2e4`, `e7e8q`, and castling as the king's two-square move `e1g1`). The vocabulary is a fixedenumeration, not learned from data, and ships in `tokenizer/vocab.json`.src/rukh/data/cards/model.md.jinja
La forma de la entrada, con un ejemplo literal de la secuencia de tokens. Es lo primero que necesita alguien que quiera ejecutar el modelo, y la última frase es la que evita el error de bulto: el vocabulario es una enumeración fija, no aprendida de los datos, y viaja en el repositorio.
## Files
{% for file in files %}- `{{ file }}`{% endfor %}{% if tied_embeddings %}The language-modelling head is tied to the token embedding, so the weights file holds`tokens.weight` and **not** `lm_head.weight`: the two are one tensor, and storing it twice wouldboth break `safetensors` and double-count the embedding in the parameter count. Re-tie it afterloading (`model.lm_head.weight = model.tokens.weight`); `rukh` does it in the constructor.{% endif %}{%- if has_onnx %}The ONNX graph returns only the logits of the **last** step, `(batch, vocab)`: that is all thedemo needs and it keeps the output tensor two hundred times smaller. `model-fp16.onnx` is forWebGPU and `model-int8.onnx` for the WASM fallback.{%- else %}This release carries the PyTorch weights only; the ONNX files the browser demo loads are builtwith `rukh export --ckpt <checkpoint> --out <dir> --fp16 --int8`.{%- endif %}src/rukh/data/cards/model.md.jinja
Los ficheros, y los dos condicionales que explican lo raro. El de los tied embeddings dice qué falta en el fichero de pesos, por qué, y cómo volver a atarlo; el de los ONNX dice que el grafo devuelve solo el último paso y cuál de los dos ficheros es para cada backend, o —si no se publicaron— el comando que los genera.
Es el patrón de toda la plantilla: cada rareza del formato se explica donde alguien la va a encontrar, no en un documento de diseño.
## Training recipe
{% if recipe %}| Parameter | Value ||---|---|{%- for name, value in recipe %}| `{{ name }}` | `{{ value }}` |{%- endfor %}{% else %}The MLflow run for this checkpoint was not available when the card was generated; the shape ofthe model is in `config.json`.{% endif %}{% if run_id %}MLflow run: `{{ run_id }}`.{% endif %}
```json{{ config_json }}```src/rukh/data/cards/model.md.jinja
La receta, sacada de los parámetros de MLflow, y el config.json entero al final. Si la ejecución no
estaba disponible, lo dice y remite a config.json. Fíjate en el {% if recipe %}: la alternativa
—una tabla vacía— sería peor que una frase.
## Data
Trained on {% for dataset in datasets %}[`{{ dataset }}`](https://huggingface.co/datasets/{{ dataset }}){% if not loop.last %}, {% endif %}{% endfor %},derived from the [Lichess open database](https://database.lichess.org) (CC0): rated standardgames with both players at 1800+ Elo, at least 180 seconds of base time, 20 to 300 plies,converted from SAN to legal UCI. Validation uses a month the model never saw.
## Limitations
- It is a move-sequence model, not a search engine: it has no lookahead and no evaluation function, so it blunders tactics that any engine sees instantly.- It has no board state of its own. Given a position without its move history (a puzzle FEN, say) it plays from a much shorter prompt than it was trained on and is markedly weaker.- Without the legality mask it proposes illegal moves at the rate in the table above. Any application must mask, as the demo does.- It was trained on games between 1800+ humans on Lichess and imitates them, blunders included. It is not an oracle of good play and is not conditioned to be one.- The estimated Elo is a fit to a few hundred games against a limited Stockfish, with a bootstrap interval: it is an estimate with a confidence interval, not a rating. The interval covers sampling noise only. The rungs below Stockfish's 1320 floor are nominal `Skill Level` anchors rather than measured ratings, and games that run out of context are adjudicated on the final position instead of being scored as draws.src/rukh/data/cards/model.md.jinja
Los datos y las limitaciones. Las cinco limitaciones no son un formalismo y merecen leerse una a una, porque son exactamente lo que el módulo ha medido:
- No es un motor de búsqueda: una pasada hacia delante, sin exploración de variantes y sin función de evaluación, así que falla tácticas que cualquier motor ve al instante.
- No tiene estado del tablero propio. Dada una posición sin su historial —un FEN de un puzle— juega desde un prompt mucho más corto que el de su entrenamiento y es notablemente más débil. Es el handicap de los puzles de la lección 5, escrito en la tarjeta.
- Sin la máscara propone jugadas ilegales a la tasa de la tabla, y cualquier aplicación tiene que enmascarar.
- Imita a humanos de 1800+ de Lichess, errores incluidos. No es un oráculo de buen juego y no está condicionado para serlo.
- El Elo es un ajuste a unos cientos de partidas contra un Stockfish limitado, con su intervalo, que cubre solo el ruido de muestreo; los escalones por debajo de 1320 son anclas nominales; y las partidas que se quedan sin contexto se adjudican.
La quinta es la que este módulo aprendió a base de equivocarse, y está en la tarjeta antes de que nadie pregunte.
## License
{{ license | upper }}. The code and the weights are released under the Apache License 2.0;the training data comes from Lichess under CC0. Please credit Lichess when you use them.
Generated with `rukh` {{ rukh_version }}.src/rukh/data/cards/model.md.jinja
Las cuatro licencias del proyecto resumidas en dos frases, y la petición de citar a Lichess. La
última línea es la que hace la tarjeta rastreable: la versión de rukh que la generó.
El comando, y los últimos cambios de cli.py
@publish_app.command("model")def publish_model_cmd( ckpt: Annotated[ Path, typer.Option( "--ckpt", exists=True, dir_okay=False, readable=True, help="Checkpoint to publish." ), ], repo: Annotated[str, typer.Option("--repo", help="Hub repository, e.g. chorcat/rukh-small.")], onnx: Annotated[ Path | None, typer.Option("--onnx", exists=True, file_okay=False, help="Directory with the ONNX files."), ] = None, stage: Annotated[ str | None, typer.Option("--stage", help="Stage name; default: the repo without `rukh-`."), ] = None, run_id: Annotated[ str | None, typer.Option("--run-id", help="MLflow run to take the recipe from.") ] = None, dry_run: Annotated[ bool, typer.Option("--dry-run", help="Stage the folder locally; touch no network.") ] = False, as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON only.")] = False,) -> None: """Stage weights, ONNX, tokenizer and a generated card, and upload them to the Hub.""" from rukh.publish import ModelPublishConfig, publish_model
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") try: result = publish_model( ckpt, repo, ModelPublishConfig(), onnx_dir=onnx, stage=stage, run_id=run_id, dry_run=dry_run, ) except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc if as_json: typer.echo(result.model_dump_json(indent=2)) return typer.echo(f"repo: {result.repo_id} ({result.repo_type})") typer.echo(f"stage: {result.stage} ({result.params:,} parameters)") typer.echo(f"mode: {'dry-run (staged, nothing uploaded)' if dry_run else 'uploaded'}") typer.echo(f"weights: {result.weights_format}") typer.echo(f"run: {result.run_id or 'not found in MLflow'}") typer.echo(f"folder: {result.folder}") typer.echo("files:") for path in result.files: typer.echo(f" {path}")--dry-run prepara la carpeta y no toca la red, y la salida imprime la carpeta y la lista de
ficheros, así que se puede inspeccionar lo que se va a publicar antes de publicarlo. --onnx
apunta al directorio de la exportación de la lección 7; sin él, el repositorio sale solo con los
pesos de PyTorch y la tarjeta lo dice.
Con esto, cli.py cierra el módulo. Pasó de 350 líneas en M1 a 667, y los cambios que faltaban por
ver son tres. La cabecera, que ahora enumera los nueve grupos de órdenes, y un import logging que
es el único módulo que las cinco órdenes nuevas necesitan arriba —el resto de sus importaciones van
dentro de cada función, para que rukh --help no cargue torch—:
"""Command-line interface: info, data, train, play, eval, export, publish, engine, mlflow.
Every command is a thin shell over the library: parse options, call one function, print."""
from __future__ import annotations
import loggingfrom pathlib import Pathfrom typing import Annotated…y el subgrupo nuevo, que es la única línea de fontanería que hizo falta para colgar publish model
del árbol de órdenes:
no_args_is_help=True, add_completion=False,«Cada orden es una cáscara fina sobre la biblioteca: analiza opciones, llama a una función,
imprime.» Esa frase de la cabecera es comprobable: de las 318 líneas que M2 añadió a cli.py,
ninguna calcula nada. Es la propiedad que hace que los labs, los tests y el curso puedan usar la
biblioteca directamente sin pasar por la línea de órdenes.
Publicarlo
uv run rukh publish model --ckpt checkpoints/small/best.pt --repo rukh-small \ --onnx artifacts/onnx/small --dry-runCon --dry-run la carpeta queda en artifacts/publish/chorcat/rukh-small/ y se puede leer entera:
model.safetensors, config.json, tokenizer/vocab.json, los tres onnx/*.onnx y el README.md
renderizado. Quita la bandera y sube.
Los dos modelos del hito están publicados, modelchorcat/rukh-tiny y modelchorcat/rukh-small. Abre la tarjeta de rukh-small y compara su tabla con la de la lección 6: son los mismos números, generados del mismo results.json, y eso es el punto entero del fichero que acabas de leer.
// Ejercicio 01Publica, vuelve a evaluar y publica otra vez
Con --dry-run, publica tiny. Después evalúa el mismo checkpoint con --suite quick cambiando
la temperatura a 0,05 en una copia de la configuración, y vuelve a publicar con --dry-run. ¿Qué
cambia en el README.md? ¿Y en config.json?
// SoluciónVer la solución
En el README.md cambian dos cosas: la fila de la legalidad muestreada, que ahora se llama
«(T=0.05, top-k 20)» en vez de «(T=0.6, top-k 20)», y sus cifras. _sampled_label lee la
temperatura del results.json, así que el nombre de la fila sigue a la medida sin que nadie lo
toque. Las notas también cambian, porque LEGALITY_DEFINITIONS interpola la temperatura.
En config.json no cambia nada: describe la forma del modelo y su procedencia, no cómo se
evaluó. Esa separación es la que permite reevaluar cien veces sin tocar el contrato del fichero de
pesos.
Y hay un tercer efecto que conviene mirar: si evalúas con --stage distinto, read_eval no
encuentra el results.json de esa etapa y la tarjeta sale con la tabla de «no evaluado todavía».
No es un fallo, es el acoplamiento por nombre haciendo su trabajo, y explica por qué el nombre de
etapa se elige una vez y no se toca.
Qué has aprendido
Cómo se empaqueta un modelo para que exista fuera de tu disco, y las tres decisiones que lo hacen
utilizable: los pesos sin alias en el formato que el Hub entiende, la procedencia dentro del
config.json, y una tarjeta generada de las métricas medidas con sus limitaciones escritas por quien
las midió.
Cómo se mide: uv run rukh publish model --ckpt … --repo rukh-small --onnx … --dry-run prepara
una carpeta con seis ficheros y un README.md cuya tabla coincide con artifacts/eval/small/results.json,
y uv run rukh eval --model chorcat/rukh-small vuelve a evaluar lo publicado descargándolo, que
es la comprobación de que el paquete está completo.
Lo siguiente son los labs: los cuatro scripts de labs/m2/ enteros, que son los que producen los
dos JSON de las islas y las cuentas que has estado usando todo el módulo.