// M4 · lección 05
QLoRA con `transformers`: el decoder entra en el ecosistema
models/hf.py —el decoder vestido de PreTrainedModel, con las tres trampas que no salen en ningún tutorial—, train/qwen.py —QLoRA de Qwen3-0.6B con transformers, peft y trl—, y las dos piezas que lo meten en el harness de M2: qwen_source.py, que cuenta las tres maneras de fallar que el decoder no tiene, y qwen_suite.py, que corre la misma suite sobre otro jugador.
Lección 5 de 10 del módulo «Fine-tuning e instrucción». Viene de «Estilo y texto PGN» y sigue en
«Adaptadores y publicación». Es la lección más larga del módulo: cuatro ficheros nuevos, dos suites
de tests y el extra de dependencias que los hace posibles. Se puede leer en dos sesiones, cortando
después de la prueba contra peft.
Qué vas a construir
Las dos mitades de una comparación: el decoder de este curso vestido de PreTrainedModel, para
ponerlo al lado de una implementación de referencia, y un Qwen3-0.6B afinado con QLoRAQLoRALoRA sobre un modelo base cuantizado a 4 bits: los pesos congelados ocupan una cuarta parte y los adaptadores siguen entrenándose en 16 bits, así que cabe en la GPU un modelo que en fp16 no cabría. El ahorro se nota cuando el peso está en las capas lineales; a 0,6 B parámetros, con una tabla de embeddings que es el 26 % del modelo y no se cuantiza, el ahorro medido fueron 341 MB de pesos (29 %) y 7 MB del pico de memoria (0,4 %). El nombre promete más de lo que da a esa escala, y decirlo forma parte de usarlo bien. sobre las mismas partidas y enchufado al harness de M2 como un jugador
más. Al terminar tendrás la respuesta a si merecía la pena escribir un modelo propio y la prueba de
que la LoRA escrita a mano optimiza lo mismo que peft, paso a paso.
hf.py: la envoltura
"""``MoveDecoder`` dressed as a ``PreTrainedModel``, so the Hugging Face stack works on it.
The decoder is deliberately plain PyTorch: it is the model of this course and reading it shouldnot require knowing anybody's framework. But the rest of the ecosystem -- ``peft``, ``trl``'strainers, ``AutoModel``, the Hub's inference widgets -- speaks ``PreTrainedModel``, and a wrapperis a hundred lines whereas re-implementing LoRA, DPO and GRPO is not.
The wrapper **owns** a ``MoveDecoder``; it does not reimplement one. So there is exactly onedefinition of the forward pass in this repository, and the test that the two give identical logitsis a test that the wrapping is faithful rather than a second implementation drifting from thefirst. The weights are the same objects, which is also why ``from_decoder`` costs nothing.
What this buys, concretely, is the check M4 owes the reader: the hand-written LoRA of``rukh.models.lora`` and ``peft``'s, on the same weights with the same rank and the same targets,have to produce the same loss curve. Without the wrapper the two could not be put side by side atall, and "we implemented LoRA" would rest on the author's word."""La envoltura contiene un MoveDecoder. Como hay una sola definición del paso hacia delante, el
test de que las dos dan los mismos logits comprueba que el envoltorio es fiel, no que una segunda
implementación sigue a la primera.
Las importaciones se pueden saltar en la primera lectura, pero sin ellas el fichero no importa: si
las saltas, trae hf.py entero antes de teclear nada de él y lee los bloques de debajo contra él,
git checkout p4 -- src/rukh/models/hf.py.
// FontaneríaLas importaciones de transformers, con su if TYPE_CHECKING
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import torchfrom torch import nn
from rukh.models.config import DecoderConfigfrom rukh.models.decoder import IGNORE_INDEX, MoveDecoder
if TYPE_CHECKING: from transformers import PretrainedConfig, PreTrainedModelelse: # pragma: no cover - resolved at import time from transformers import PretrainedConfig, PreTrainedModel
from transformers import GenerationMixinfrom transformers.modeling_outputs import CausalLMOutput
__all__ = ["MODEL_TYPE", "RukhConfig", "RukhForCausalLM"]
MODEL_TYPE = "rukh"Las dos ramas idénticas del if TYPE_CHECKING son el precio de importar de una librería que declara
sus tipos de forma dinámica.
class RukhConfig(PretrainedConfig): """``DecoderConfig`` in the shape ``transformers`` expects to serialise.
The fields are the decoder's, spelled the same way, so a ``config.json`` written here and the YAML a run was configured with say the same thing. ``transformers`` insists on a few names of its own (``vocab_size``, ``model_type``); everything else is left alone rather than renamed into ``hidden_size``/``num_hidden_layers``, because a reader who has the decoder open should recognise what they are looking at. """
model_type = MODEL_TYPE
def __init__( self, vocab_size: int = 2030, n_layer: int = 12, n_head: int = 8, d_model: int = 512, block: int = 200, dropout: float = 0.0, pos: str = "learned", tie_embeddings: bool = True, **kwargs: Any, ) -> None: self.vocab_size = vocab_size self.n_layer = n_layer self.n_head = n_head self.d_model = d_model self.block = block self.dropout = dropout self.pos = pos self.tie_embeddings = tie_embeddings kwargs.setdefault("tie_word_embeddings", tie_embeddings) super().__init__(**kwargs)
@classmethod def from_decoder(cls, cfg: DecoderConfig, **kwargs: Any) -> RukhConfig: return cls( vocab_size=cfg.vocab_size, n_layer=cfg.n_layer, n_head=cfg.n_head, d_model=cfg.d_model, block=cfg.block, dropout=cfg.dropout, pos=cfg.pos, tie_embeddings=cfg.tie_embeddings, **kwargs, )
def decoder_config(self) -> DecoderConfig: """The ``DecoderConfig`` this config describes.
Not called ``decoder``: ``transformers`` reads a config attribute of that name as the decoder half of an encoder-decoder pair and tries to call ``to_dict`` on it, which turns a bound method into a crash the first time anything asks for a generation config. """ return DecoderConfig( vocab_size=self.vocab_size, n_layer=self.n_layer, n_head=self.n_head, d_model=self.d_model, block=self.block, dropout=self.dropout, pos=self.pos, # type: ignore[arg-type] tie_embeddings=self.tie_embeddings, )Los campos se llaman como en el decoder (n_layer, d_model), no como en transformers; solo se
ceden vocab_size y model_type. Y aquí está la primera trampa: el método no se puede llamar
decoder, porque transformers lee un atributo de la config con ese nombre como la mitad
decodificadora de un par encoder-decoder, y revienta la primera vez que algo pide una config de
generación.
class RukhForCausalLM(PreTrainedModel, GenerationMixin): """A ``PreTrainedModel`` whose whole body is one ``MoveDecoder``.
``GenerationMixin`` comes along because ``prepare_inputs_for_generation`` is defined and a class that answers it without inheriting the mixin is a model that looks generative and is not. Nothing in this project generates through ``.generate()`` -- the sampler of ``rukh.infer`` does it with the legality mask, which ``transformers`` knows nothing about -- but a wrapper that half-implements an interface is worse than one that implements it. """
config_class = RukhConfig base_model_prefix = "decoder" supports_gradient_checkpointing = False _no_split_modules = ["Block"] _tied_weights_keys = {"decoder.lm_head.weight": "decoder.tokens.weight"} """Which name is the copy and which is the storage: the head shares its tensor with the embedding table, because ``MoveDecoder`` ties them in its constructor. ``safetensors`` stores tensors and not aliases, so ``save_pretrained`` refuses a state dict with two names for one tensor unless it is told. This is the same fact ``rukh.train.checkpoint.TIED_SOURCES`` records for the project's own checkpoint format."""
def __init__(self, config: RukhConfig) -> None: super().__init__(config) self.decoder = MoveDecoder(config.decoder_config()) # Registers the tied-weight map and the other static properties on the top model. It does # not touch the weights in this version of `transformers`, so calling it after building # the decoder is safe; skipping it leaves `all_tied_weights_keys` unset and every save or # load fails on an attribute the framework expects every model to have. self.post_init()Dos trampas más, comentadas donde ocurren. Los pesos atados hay que declararlos: lm_head.weight
comparte almacenamiento con la tabla de embeddings, y safetensors rechaza dos nombres para un
tensor salvo que se le diga cuál es la copia. Es un enlace simbólico: un copiador que no sepa de
enlaces guarda dos ficheros o se niega a guardar ninguno. Y hay que llamar a post_init(), o
cualquier guardado falla con un AttributeError sobre un atributo que uno no ha escrito.
@classmethod def from_decoder(cls, decoder: MoveDecoder, **kwargs: Any) -> RukhForCausalLM: """Wrap an existing decoder **without copying its weights**.
The tensors are shared, so training the wrapper trains the decoder and a checkpoint taken from either is the same checkpoint. That is the property that makes the peft comparison meaningful: both sides start from literally the same numbers. """ model = cls(RukhConfig.from_decoder(decoder.cfg, **kwargs)) model.decoder = decoder return model
def get_input_embeddings(self) -> nn.Module: return self.decoder.tokens
def set_input_embeddings(self, value: nn.Module) -> None: self.decoder.tokens = value # type: ignore[assignment]
def get_output_embeddings(self) -> nn.Module: return self.decoder.lm_headfrom_decoder envuelve sin copiar los pesos: entrenar la envoltura entrena el decoder, y los dos
lados de la comparación con peft empiezan desde los mismos números.
def forward( self, input_ids: torch.Tensor, labels: torch.Tensor | None = None, attention_mask: torch.Tensor | None = None, # noqa: ARG002 - the decoder is always causal return_dict: bool | None = None, # noqa: ARG002 - a dataclass is the only output shape **kwargs: Any, ) -> CausalLMOutput: """Logits, and the decoder's own loss when ``labels`` are given.
``labels`` are **already shifted** here, unlike most causal models in ``transformers``. The packed stream of this project stores ``(x, y)`` pairs where ``y`` is ``x`` moved one step, so shifting again inside the model would teach it to predict the move after next. The loss is the decoder's, ``ignore_index=0``, so a run through the wrapper and a run through the training loop optimise the same number. """ logits, loss = self.decoder(input_ids, labels) return CausalLMOutput(loss=loss, logits=logits)
def prepare_inputs_for_generation( self, input_ids: torch.Tensor, **kwargs: Any ) -> dict[str, Any]: """Crop to the context window; there is no KV cache to carry between steps.""" return {"input_ids": input_ids[:, -self.config.block :]}
@property def ignore_index(self) -> int: """The id the loss skips: ``<pad>``, id 0, in every scheme of this project.""" return IGNORE_INDEXLa tercera trampa es la que más caro sale: aquí las etiquetas no se desplazan. Los modelos
causales de transformers desplazan labels dentro de forward, pero el flujo empaquetado de este
proyecto ya guarda y un paso por delante de x, así que desplazar otra vez entrenaría al modelo a
predecir la jugada de después de la siguiente.
Los tests de la envoltura, y la prueba que hace honesto el módulo
Los tests de fidelidad (mismos logits, mismos pesos, misma pérdida) se pueden dejar plegados; el que
importa, la comparación paso a paso contra peft, va a la vista después. El «Ejecútalo» del extra
los corre todos, así que si no tecleas los plegados, trae el fichero entero ahora, antes de teclear
el test de debajo (que ya viene dentro): git checkout p4 -- tests/unit/test_hf_wrapper.py.
// Teststests/unit/test_hf_wrapper.py: fidelidad de la envoltura y recuentos contra peft
"""Tests for rukh.models.hf: the wrapper, and the check that our LoRA is LoRA.
The second half is the one that matters for M4. Writing LoRA by hand is only worth the pages ittakes in the lesson if the thing written is the thing the papers describe, and the way to knowthat is to put it next to the reference implementation on the same weights and watch the twooptimise the same number. Without this test "we implemented LoRA" would rest on the author's word.
The Hugging Face stack is an optional extra (`uv sync --extra hf`): a CPU CI job with athree-minute budget should not install it to run P0-P3. These tests skip when it is absent and arerun locally and in the extended job."""
from __future__ import annotations
from pathlib import Path
import pytestimport torch
from rukh.models import DecoderConfig, MoveDecoderfrom rukh.models.lora import LoraConfig, apply_lora, lora_modules
transformers = pytest.importorskip("transformers", reason="needs the `hf` extra")peft = pytest.importorskip("peft", reason="needs the `hf` extra")
from rukh.models.hf import RukhConfig, RukhForCausalLM # noqa: E402
pytestmark = pytest.mark.unit
TOY = DecoderConfig(vocab_size=64, n_layer=2, n_head=2, d_model=32, block=16, dropout=0.0)
def _decoder(seed: int = 0) -> MoveDecoder: torch.manual_seed(seed) return MoveDecoder(TOY)
def _batch() -> tuple[torch.Tensor, torch.Tensor]: torch.manual_seed(99) x = torch.randint(1, 64, (4, 16)) y = torch.randint(1, 64, (4, 16)) return x, yLos dos importorskip van antes del import de rukh.models.hf para que el fichero se salte entero
en un entorno sin transformers: la pila de Hugging Face es un extra opcional.
def test_the_wrapper_and_the_decoder_give_the_same_logits() -> None: """One definition of the forward pass, wrapped -- not a second one that can drift.""" decoder = _decoder().eval() model = RukhForCausalLM.from_decoder(decoder).eval() x, _ = _batch() with torch.no_grad(): torch.testing.assert_close(decoder(x)[0], model(x).logits)
def test_wrapping_shares_the_weights_rather_than_copying_them() -> None: decoder = _decoder() model = RukhForCausalLM.from_decoder(decoder) assert model.decoder is decoder assert model.get_input_embeddings().weight.data_ptr() == decoder.tokens.weight.data_ptr()
def test_the_loss_is_the_decoder_s_own() -> None: """Same number, so a run through `SFTTrainer` optimises what the training loop optimises.""" decoder = _decoder().eval() model = RukhForCausalLM.from_decoder(decoder).eval() x, y = _batch() with torch.no_grad(): expected = decoder(x, y)[1] got = model(x, labels=y).loss assert expected is not None torch.testing.assert_close(expected, got)
def test_labels_are_not_shifted_again() -> None: """The packed stream already stores `y` one step ahead of `x` (`rukh.tokenize.loader`).
Every causal model in `transformers` shifts labels inside `forward`; doing that here would train the model to predict the move *after* next, and the loss would still look plausible. """ decoder = _decoder().eval() model = RukhForCausalLM.from_decoder(decoder).eval() x, _ = _batch() shifted = torch.roll(x, shifts=-1, dims=1) with torch.no_grad(): through_wrapper = model(x, labels=shifted).loss by_hand = decoder(x, shifted)[1] torch.testing.assert_close(through_wrapper, by_hand)
def test_the_config_round_trips_through_disk(tmp_path: Path) -> None: decoder = _decoder().eval() model = RukhForCausalLM.from_decoder(decoder).eval() model.save_pretrained(tmp_path) reloaded = RukhForCausalLM.from_pretrained(tmp_path).eval() assert reloaded.config.decoder_config() == TOY x, _ = _batch() with torch.no_grad(): torch.testing.assert_close(model(x).logits, reloaded(x).logits)Cinco tests de fidelidad, uno por afirmación: mismos logits, pesos compartidos (mismo data_ptr(),
la dirección de memoria), la misma pérdida, etiquetas sin volver a desplazar y la config que va y
vuelve por disco.
def test_the_config_is_not_mistaken_for_an_encoder_decoder_pair() -> None: """`transformers` reads a config attribute called `decoder` as the other half of a pair.
It calls `to_dict()` on it, so a method with that name crashes the first time anything asks for a generation config -- which is anything that touches `.generate()`. """ config = RukhConfig.from_decoder(TOY) assert not hasattr(config, "decoder") or not callable(getattr(config, "decoder", None)) transformers.GenerationConfig.from_model_config(config)
def _peft_config(r: int, alpha: int, targets: list[str]) -> peft.LoraConfig: return peft.LoraConfig( r=r, lora_alpha=alpha, lora_dropout=0.0, bias="none", target_modules=targets, )
def test_peft_counts_the_same_trainable_parameters_as_the_hand_written_lora() -> None: """Same rank, same matrices: the arithmetic has to agree before the curves can.""" ours = _decoder() mine = apply_lora(ours, LoraConfig(r=4, alpha=8, targets=("attn_out",)))
theirs = RukhForCausalLM.from_decoder(_decoder()) adapted = peft.get_peft_model(theirs, _peft_config(4, 8, ["attn.proj"])) reference = sum(p.numel() for p in adapted.parameters() if p.requires_grad) assert mine == referenceEl de los recuentos es el paso previo a la comparación: si peft y nosotros no contamos los mismos
parámetros entrenables, comparar curvas no tendría sentido.
def test_the_hand_written_lora_and_peft_optimise_the_same_number() -> None: """The check that makes the chapter honest.
Both sides start from the same base weights and are given the same `A` (copied across, so the comparison is of the maths and not of two random draws), then take the same optimiser steps on the same batch. If the update rule differed at all -- the scaling, which side `A` multiplies, whether `B` starts at zero -- the losses would separate immediately. """ x, y = _batch() r, alpha, steps = 4, 8, 6
ours = _decoder() apply_lora(ours, LoraConfig(r=r, alpha=alpha, targets=("attn_out",))) mine = [p for p in ours.parameters() if p.requires_grad]
theirs = RukhForCausalLM.from_decoder(_decoder()) adapted = peft.get_peft_model(theirs, _peft_config(r, alpha, ["attn.proj"]))
# Copy our `A` into theirs so both start from the same point; `B` is zero on both sides. ours_by_layer = {name: module for name, module in lora_modules(ours)} with torch.no_grad(): for name, module in adapted.named_modules(): if not hasattr(module, "lora_A") or "default" not in getattr(module, "lora_A", {}): continue key = name.replace("base_model.model.decoder.", "").replace(".base_layer", "") module.lora_A["default"].weight.copy_(ours_by_layer[key].a[0])
mine_opt = torch.optim.SGD(mine, lr=0.5) their_opt = torch.optim.SGD([p for p in adapted.parameters() if p.requires_grad], lr=0.5)
my_curve: list[float] = [] their_curve: list[float] = [] for _ in range(steps): mine_opt.zero_grad() loss = ours(x, y)[1] assert loss is not None loss.backward() mine_opt.step() my_curve.append(float(loss.detach()))
their_opt.zero_grad() other = adapted(x, labels=y).loss other.backward() their_opt.step() their_curve.append(float(other.detach()))
assert my_curve[-1] < my_curve[0] # it is training at all for step, (a, b) in enumerate(zip(my_curve, their_curve, strict=True)): assert a == pytest.approx(b, abs=1e-5), f"step {step}: {a} vs {b}"
def test_peft_leaves_the_base_weights_frozen_exactly_as_ours_does() -> None: theirs = RukhForCausalLM.from_decoder(_decoder()) adapted = peft.get_peft_model(theirs, _peft_config(4, 8, ["attn.proj"])) trainable = {name for name, p in adapted.named_parameters() if p.requires_grad} assert trainable assert all("lora_" in name for name in trainable)Esta es la prueba que hace honesto el módulo entero. Dos modelos sobre los mismos pesos base, uno
con apply_lora y otro con peft.get_peft_model; se copia nuestra A en la suya para comparar las
matemáticas y no dos sorteos, y los dos dan pasos de SGD sobre el mismo lote. La guarda
my_curve[-1] < my_curve[0] evita el test vacío: dos modelos que no entrenan también dan curvas
idénticas. El objetivo es attn_out porque peft no puede expresar un adaptador por rodaja sobre una
qkv fusionada; sobre attn.proj, una nn.Linear normal, las dos hacen lo mismo.
El script labs/m4/lora_check.py hace la misma comparación y la imprime paso a paso; es su cuarta
comprobación (de ahí el 4. de la cabecera), y corre en CPU como el test. Salida en la máquina de
referencia:
4. step ours peft |delta| 0 4.17093372 4.17093372 0.00e+00 1 4.16829062 4.16829062 0.00e+00 2 4.16558743 4.16558743 0.00e+00 3 4.16275978 4.16275978 0.00e+00 4 4.15973043 4.15973043 0.00e+00 5 4.15640736 4.15640736 0.00e+00Idénticas hasta el último bit, seis pasos seguidos.
El extra hf, y por qué es un extra
Nada de esta lección se puede importar con el entorno base del proyecto, y eso es una decisión.
[project.optional-dependencies]cpu = ["torch>=2.11,<2.12"]cu128 = ["torch>=2.11,<2.12"]# The Hugging Face training stack, needed only by M4: the `PreTrainedModel` wrapper around# `MoveDecoder`, `peft` as the reference LoRA the hand-written one is checked against, and the# QLoRA fine-tune of Qwen3 on PGN. It is an extra and not a core dependency because the CPU CI# job has a three-minute budget and nothing in P0-P3 imports any of it.hf = ["transformers>=5.17", "peft>=0.21", "trl>=1.13", "accelerate>=1.15", "bitsandbytes>=0.50"]Nada de M0 a M3 importa ninguno de estos cinco paquetes, y la CI de CPU tiene tres minutos de presupuesto: meterlos en las dependencias pagaría la instalación en cada ejecución de cada rama.
Para qué sirve cada librería del extra
Son cinco piezas del mismo ecosistema, y cada una hace una cosa:
transformerses el catálogo y el cargador.AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B")baja del Hub la configuración y los pesos, construye la arquitectura que dicen y te devuelve unnn.Modulecorriente;AutoTokenizerhace lo mismo con el tokenizador, ygenerate()muestrea. Es lo que hace que cambiar de modelo sea cambiar una cadena. La envolturahf.pyexiste para que el decoder de Rukh hable ese mismo idioma.peft(parameter-efficient fine-tuning) mete adaptadores en un modelo ya construido:get_peft_modelcongela todos los pesos, añade una LoRALoRALow-Rank Adaptation: en vez de modificar una matriz de pesos W, se aprende una corrección B·A de rango r (A de r × d, B de d × r, con B inicializada a cero para que el modelo adaptado sea el base en el paso 0) y se suma a W escalada por alpha/r. Entrena pocos parámetros —enmedium, 393 216 con r = 8 sobre consulta y valor, el 0,34 %—, se guarda como un adaptador de 1,6 MB y se puede cambiar en caliente. QLoRA hace lo mismo sobre un modelo base cuantizado a 4 bits. en los módulos que le nombras (q_proj,v_proj…) y al guardar escribe solo el adaptador. Es la LoRA de la lección 3, hecha por otros, y por eso sirve de referencia para comprobar la nuestra.trltrae los bucles de entrenamiento de un LLM:SFTTrainerpara el afinado supervisado (lotes, pérdida, checkpoints, registro) y también los de DPO y GRPO, dos afinados que aprenden de preferencias («esta respuesta es mejor que esa») y de recompensas en vez de imitar ejemplos, y que M5 escribe a mano.SFTConfiges su lista de hiperparámetros.acceleratees la fontanería de dispositivos: dónde va cada capa, la precisión mixta, varias GPU. Los entrenadores detrlla usan por debajo y casi nunca se llama a mano.bitsandbytesson los núcleos de CUDA que guardan y multiplican pesos de 8 y 4 bits.BitsAndBytesConfigle dice atransformersque cargue las capas lineales así.
Si vienes de la web, es la diferencia entre escribir tu servidor HTTP y usar un framework: M2 y M3 escribieron el bucle y el modelo para entenderlos, y aquí se usa lo que usaría un equipo que necesita afinar un modelo esta semana.
qwen.py: el afinado del modelo general
"""QLoRA of a general language model on the same games, so the comparison is fair.
M4 owes the reader a number for a question the whole project raises by existing: why build a115 M decoder for chess when a general model five times its size can be fine-tuned in anafternoon? The only answer worth printing is a measurement, and a measurement is only worthprinting when the two sides differ in one thing. So:
- the **games** are the same, drawn from the same month and held out against the same validation split (``rukh.data.pgn_text``);- the **metrics** are the same, because the fine-tuned Qwen is plugged into the harness of P2 as another ``MoveSource`` (``rukh.eval.qwen_source``) and plays the same Stockfish ladder;- the **representation** is what differs, and that is the point. The decoder reads one token per legal move; Qwen reads ``1. e4 e5 2. Nf3`` cut into whatever pieces its BPE makes of it, and nothing in it forbids writing a move that does not exist.
This file is the training half, and it is deliberately somebody else's code: ``transformers`` forthe model, ``peft`` for the adapter, ``trl`` for the loop. The project has its own LoRA and its ownloop and does not need either here -- what it needs is the experience of the ecosystem path,which is what a reader will actually reach for, and a run that is comparable to the publishedQLoRA recipes rather than to ours.
Four-bit is attempted and not required. The memory it saves is the reason QLoRA exists, and at0.6 B parameters on a 32 GB card there is nothing to save: the honest thing is to try it, reportwhether it worked, and fall back to bf16 rather than pretend a technique was demonstrated whenthe dependency would not build."""
from __future__ import annotations
import jsonimport loggingfrom pathlib import Pathfrom typing import TYPE_CHECKING, Any
from pydantic import Field
from rukh.config import BaseConfigfrom rukh.paths import resolve
if TYPE_CHECKING: from collections.abc import Mapping
log = logging.getLogger(__name__)
__all__ = ["QwenConfig", "QwenReport", "four_bit_config", "train_qwen"]Este fichero es deliberadamente código de otros: transformers para el modelo, peft para el
adaptador, trl para el bucle y bitsandbytes para los cuatro bits. Es el camino del ecosistema, el
que vas a usar de verdad: cámbiale el nombre del modelo y el fichero de datos y afinas un Llama o un
Mistral con tus propios ejemplos. Los cuatro bits se intentan, no se exigen, y los imports pesados
van dentro de las funciones para que rukh train qwen --help conteste sin el extra.
La configuración y el informe son fontanería; lo único que hay que retener es que el informe separa
la memoria de los pesos del pico de entrenamiento. Si lo saltas, trae el fichero entero ahora, antes
de seguir tecleándolo (el bloque de arriba viene dentro, igual) y lee los de debajo contra él:
git checkout p4 -- src/rukh/train/qwen.py.
// FontaneríaQwenConfig y QwenReport: la configuración y lo que el afinado deja escrito
class QwenConfig(BaseConfig): """One QLoRA run of a general model over PGN text."""
model: str = "Qwen/Qwen3-0.6B" data_dir: str = "data/pgn-text" out_dir: str = "checkpoints" run_name: str = "qwen3-pgn-qlora" max_length: int = Field(default=512, ge=64) """Tokens per sample. A 120-ply game in SAN with the rating header is 350-450 of Qwen's tokens, so 512 fits a whole game and truncates the longest few rather than half of them.""" batch_size: int = Field(default=8, ge=1) grad_accum: int = Field(default=4, ge=1) lr: float = Field(default=2e-4, gt=0) max_steps: int = Field(default=1500, ge=1) warmup: int = Field(default=100, ge=0) r: int = Field(default=16, ge=1) alpha: int = Field(default=32, ge=1) dropout: float = Field(default=0.05, ge=0.0, lt=1.0) targets: tuple[str, ...] = ("q_proj", "k_proj", "v_proj", "o_proj") four_bit: bool = True """Try to load the base model in 4 bits with ``bitsandbytes``; fall back to bf16 if not.""" n_train: int | None = None """Samples to read from ``train.jsonl``; ``None`` reads them all.""" seed: int = 42
class QwenReport(BaseConfig): """What the run did, including the parts that did not go as configured."""
model: str adapter_dir: str four_bit: bool """Whether 4-bit quantisation was actually used, not whether it was asked for.""" fallback_reason: str | None = None trainable_params: int total_params: int train_samples: int steps: int final_loss: float | None = None weights_memory_mb: float | None = None """What the loaded model occupies before a single step. This is the number QLoRA exists for, and the only one that separates the two precisions: the peak during training is dominated by activations and optimizer state, which 4-bit does not touch.""" peak_memory_mb: float | None = None
@property def trainable_share(self) -> float: return self.trainable_params / max(self.total_params, 1)En QwenReport, four_bit dice si la cuantización se usó de verdad, no si se pidió, y
fallback_reason por qué no. Y la memoria va en dos campos: weights_memory_mb, lo que ocupan los
pesos cargados, que es lo que QLoRA mueve, y peak_memory_mb, el pico durante el entrenamiento,
dominado por activaciones y optimizador. Publicar solo el pico haría parecer inútil la técnica;
publicar solo los pesos, milagrosa.
Qué es cuantizar a 4 bits
Cuatro bits dan para 16 valores distintos. CuantizarCuantizació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. un peso a 4
bits es sustituirlo por el más cercano de 16 niveles fijos, y guardar solo cuál de ellos es. Para
que los niveles sirvan a pesos de cualquier tamaño, se reparten los pesos en bloques de 64 y cada
bloque guarda su propia escala, el mayor valor absoluto. Con ocho pesos y 16 niveles repartidos a
partes iguales entre −1 y 1:
import torch
w = torch.tensor([0.12, -0.53, 0.07, 0.91, -0.02, 0.33, -0.18, 0.46]) # un bloque de pesosscale = w.abs().max() # 0,91: la constante del bloquelevels = torch.linspace(-1, 1, 16) # 16 niveles: lo que caben en 4 bitscodes = ((w / scale)[:, None] - levels).abs().argmin(dim=1) # el nivel más cercanoback = levels[codes] * scale # lo que se usa al calcularprint(codes.tolist())print(back.numpy().round(2))print(round((w - back).abs().max().item(), 3))[8, 3, 8, 15, 7, 10, 6, 11][ 0.06 -0.55 0.06 0.91 -0.06 0.3 -0.18 0.42]0.059Cada peso ocupa ahora cuatro bits más su parte de la escala, y el error máximo es de 0,06. Fíjate en dónde duele: 0,07 y −0,02 acaban en ±0,06, porque 16 niveles repartidos a partes iguales no tienen un cero y gastan la mitad de su resolución en valores grandes que casi no aparecen. Las tres piezas de la receta de QLoRA atacan justo eso:
- NF4 (normal float de 4 bits) no reparte los niveles a partes iguales: los pone donde se amontonan los pesos de una red entrenada, que siguen más o menos una campana centrada en cero. Hay un cero exacto, muchos niveles cerca de él y pocos en los extremos, de modo que cada nivel recibe más o menos el mismo número de pesos.
- Doble cuantización: la escala de cada bloque, guardada en 32 bits, cuesta medio bit por peso (32 / 64). Cuantizarla a su vez a 8 bits, en bloques de 256 escalas, la deja en unos 0,13 bits por peso. Es poco, pero en un modelo de miles de millones son cientos de megas.
- Cómputo en bf16: los pesos se guardan en 4 bits, pero la GPU no multiplica en 4 bits.
Cada bloque se descuantiza al vuelo a bf16bfloat16Formato de coma flotante de 16 bits con los mismos 8 bits de exponente que fp32 y solo 7 de mantisa: pierde precisión pero conserva el rango, así que un gradiente pequeño no se va a cero. Por eso entrena sin escalado de pérdida, al contrario que fp16, cuyo exponente de 5 bits desborda por abajo., se multiplica y se tira. Eso es
bnb_4bit_compute_dtype: ahorra memoria, no cálculo.
La base cuantizada está congelada: nadie podría darle un paso de gradiente a un peso que solo puede valer uno de 16 números. Lo que se entrena es la LoRA de encima, en bf16. En cuentas redondas, una matriz de mil millones de pesos pasa de 2 GB en bf16 a algo más de 0,5 GB, y eso es lo que deja afinar un modelo de 7 B en una tarjeta de consumo. Con los 0,6 B de Qwen3 en una tarjeta de 32 GB no hay nada que ahorrar, y por eso aquí los cuatro bits se intentan pero no se exigen.
def four_bit_config() -> tuple[Any | None, str | None]: """``(BitsAndBytesConfig, None)`` when 4-bit is available, ``(None, reason)`` when it is not.
NF4 with double quantisation and a bf16 compute dtype: the recipe of the QLoRA paper, so the run is comparable with the published ones rather than with a variant of our own. """ try: import bitsandbytes # noqa: F401 import torch from transformers import BitsAndBytesConfig except ImportError as exc: return None, f"bitsandbytes is not installed ({exc})" return ( BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16, ), None, )La receta de 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. es la del artículo de QLoRA (NF4 con doble
cuantización y cómputo en bf16). La función devuelve (config, None) o (None, razón) en vez de
lanzar: quien la llama decide si la ausencia de bitsandbytes es fatal.
def _dataset(cfg: QwenConfig, split: str) -> Any: from datasets import load_dataset
path = resolve(cfg.data_dir) / f"{split}.jsonl" if not path.is_file(): raise FileNotFoundError(f"{path} not found; run `rukh data pgn-text` first") data = load_dataset("json", data_files={split: path.as_posix()})[split] if split == "train" and cfg.n_train is not None: data = data.select(range(min(cfg.n_train, len(data)))) return data
def _count_params(model: Any) -> tuple[int, int]: """``(trainable, total)``, counting a 4-bit weight as the parameters it stands for.
``bitsandbytes`` stores two 4-bit values per byte, so ``numel()`` on a quantised weight returns half the parameter count and the "0.1 % trainable" line would come out twice as flattering as the truth. """ trainable = total = 0 for param in model.parameters(): count = param.numel() if param.__class__.__name__ == "Params4bit": count *= 2 total += count if param.requires_grad: trainable += count return trainable, totalUn detalle de contabilidad que cambia el titular: bitsandbytes guarda dos valores de 4 bits por
byte, así que numel() sobre un peso cuantizado devuelve la mitad de los parámetros que representa.
Sin corregirlo, «solo el 0,76 % es entrenable» saldría el doble de favorable de lo que es.
def train_qwen(cfg: QwenConfig, device: str | None = None) -> QwenReport: """Fine-tune ``cfg.model`` on PGN text with LoRA, in 4 bits when that is possible.""" import torch from peft import LoraConfig as PeftLoraConfig from peft import get_peft_model, prepare_model_for_kbit_training from transformers import AutoModelForCausalLM, AutoTokenizer from trl import SFTConfig, SFTTrainer
where = device or ("cuda" if torch.cuda.is_available() else "cpu") quantization, reason = (None, "not requested") if cfg.four_bit: quantization, reason = four_bit_config() if quantization is None and cfg.four_bit: log.warning("4-bit unavailable, training in bf16 instead: %s", reason)
tokenizer = AutoTokenizer.from_pretrained(cfg.model) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token model = AutoModelForCausalLM.from_pretrained( cfg.model, dtype=torch.bfloat16, quantization_config=quantization, device_map={"": where} if quantization is not None else None, ) if quantization is not None: model = prepare_model_for_kbit_training(model) elif where == "cuda": model = model.to(where) model.config.use_cache = False
weights_memory = torch.cuda.memory_allocated() / 1e6 if where == "cuda" else None adapted = get_peft_model( model, PeftLoraConfig( r=cfg.r, lora_alpha=cfg.alpha, lora_dropout=cfg.dropout, bias="none", task_type="CAUSAL_LM", target_modules=list(cfg.targets), ), ) trainable, total = _count_params(adapted) log.info( "%s: %s trainable of %s (%.3f %%), %s", cfg.model, f"{trainable:,}", f"{total:,}", 100 * trainable / max(total, 1), "4-bit" if quantization is not None else "bf16", )Qwen3 no trae token de relleno, y sin uno el colador de lotes de trl falla al primer lote de
longitudes distintas. La caché de claves y valores se apaga porque no sirve al entrenar. Y
weights_memory se mide justo después de cargar y antes de poner el adaptador, el único momento en
que significa «lo que ocupan los pesos».
out_dir = resolve(cfg.out_dir) / cfg.run_name args = SFTConfig( output_dir=out_dir.as_posix(), dataset_text_field="text", max_length=cfg.max_length, packing=False, per_device_train_batch_size=cfg.batch_size, gradient_accumulation_steps=cfg.grad_accum, learning_rate=cfg.lr, max_steps=cfg.max_steps, warmup_steps=cfg.warmup, lr_scheduler_type="cosine", logging_steps=25, save_strategy="no", bf16=True, seed=cfg.seed, report_to=["mlflow"], run_name=cfg.run_name, ) trainer = SFTTrainer( model=adapted, args=args, train_dataset=_dataset(cfg, "train"), processing_class=tokenizer, ) if where == "cuda": torch.cuda.reset_peak_memory_stats() result = trainer.train() peak = torch.cuda.max_memory_allocated() / 1e6 if where == "cuda" else None
out_dir.mkdir(parents=True, exist_ok=True) adapted.save_pretrained(out_dir.as_posix()) tokenizer.save_pretrained(out_dir.as_posix()) report = QwenReport( model=cfg.model, adapter_dir=out_dir.as_posix(), four_bit=quantization is not None, fallback_reason=None if quantization is not None else reason, trainable_params=trainable, total_params=total, train_samples=len(trainer.train_dataset), steps=cfg.max_steps, final_loss=float(result.training_loss) if result.training_loss is not None else None, weights_memory_mb=weights_memory, peak_memory_mb=peak, ) (out_dir / "run.json").write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8") return reportpacking=False: con partidas de 350-450 tokens y un max_length de 512, empaquetar varias en una
secuencia ahorraría poco y le enseñaría cortes de partida a mitad de texto. Y report_to=["mlflow"]
mete esta ejecución en el mismo cuaderno de bitácora que todas las demás del proyecto.
def load_qwen_adapter(adapter_dir: Path | str, device: str | None = None) -> tuple[Any, Any]: """``(model, tokenizer)`` of a finished run, merged and in eval mode.
Merged on purpose: the evaluation plays hundreds of games move by move, and a merged model runs the base matrices instead of the base plus two extra matmuls per adapted projection. """ import torch from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer
directory = Path(adapter_dir) config: Mapping[str, Any] = json.loads( (directory / "adapter_config.json").read_text(encoding="utf-8") ) where = device or ("cuda" if torch.cuda.is_available() else "cpu") base = AutoModelForCausalLM.from_pretrained( str(config["base_model_name_or_path"]), dtype=torch.bfloat16 ) model = PeftModel.from_pretrained(base, directory.as_posix()) model = model.merge_and_unload().to(where).eval() tokenizer = AutoTokenizer.from_pretrained(directory.as_posix()) return model, tokenizerCargar para evaluar funde el adaptador (merge_and_unload, nuestro merge_lora), porque la
evaluación juega cientos de partidas y un modelo fundido se ahorra dos multiplicaciones por
proyección adaptada.
# Qwen3-0.6B fine-tuned on the same games the decoder trains on, written as PGN text.## The point is the comparison, so the budget is chosen to be *generous* to Qwen rather than fair# to us: 1 500 steps of 32 sequences of up to 512 tokens is about 24 M tokens of chess for a model# that already speaks, against the 1.68 B the decoder needed from scratch. If it still loses by a# wide margin, nobody can say it was starved.## `r = 16` on all four attention projections is the usual QLoRA recipe, twice the rank the# project's own adapters use, for the same reason: this side gets the benefit of the doubt.model: Qwen/Qwen3-0.6Bdata_dir: data/pgn-textout_dir: checkpointsrun_name: qwen3-pgn-qloramax_length: 512batch_size: 8grad_accum: 4lr: 2.0e-4max_steps: 1500warmup: 100r: 16alpha: 32dropout: 0.05targets: [q_proj, k_proj, v_proj, o_proj]four_bit: truen_train: 100000seed: 42El presupuesto es generoso con Qwen: unos 24 M de tokens de ajedrez para un modelo que ya sabe
hablar, y r = 16 sobre las cuatro proyecciones de atención, el doble del rango de nuestros
adaptadores. Si aun así pierde por mucho, nadie puede decir que se le dejó sin comer.
# Two steps over a hundred games: not a run, a rehearsal.## The real Qwen fine-tune is half an hour of GPU and everything that can fail in it fails in the# first thirty seconds -- the model download, the quantisation backend, the `trl` API, the tokenizer# padding, the save. Finding that out at minute twenty-five is the expensive way.configs/train/qwen3-smoke.yaml
La variante de ensayo existe porque todo lo que puede fallar en media hora de GPU falla en los
primeros treinta segundos: la descarga, la cuantización, la API de trl, el guardado. Enterarse en
el minuto veinticinco es la forma cara.
qwen_source.py: las tres maneras de fallar que el decoder no tiene
"""A fine-tuned general model plugged into the harness of P2, so the comparison is like for like.
The decoder cannot write a move that does not exist: its vocabulary *is* the set of moves, and an"illegal" move is a legal move played in the wrong position. A language model writing SAN can failin three ways instead of one, and telling them apart is most of what this comparison teaches:
- **unparseable** -- what it wrote is not a move at all (``Nf9``, ``O-O-O-O``, a sentence);- **illegal** -- a well-formed move that this position does not allow;- **ambiguous** -- a move that two pieces could make, which SAN is supposed to disambiguate and the model did not.
All three are counted separately and none is silently repaired. A game still has to finish, so afailed proposal falls back to a legal move, exactly as the decoder's does, and the fallback isrecorded rather than hidden -- same rule as ``illegal_proposals`` in ``rukh.infer.game``.
The prompt is the PGN text of ``rukh.data.pgn_text``, which is the format the model was fine-tunedon, with the same rating header the decoder gets as ``<wXXXX> <bXXXX>``. Generation is greedy andsix tokens long: a move is one to four of Qwen's tokens and nothing longer is going to help."""
from __future__ import annotations
import loggingimport refrom typing import TYPE_CHECKING, Any
import chessfrom pydantic import BaseModel, ConfigDict
from rukh.data.pgn_text import prompt_for
if TYPE_CHECKING: pass
log = logging.getLogger(__name__)
__all__ = ["QwenPlayer", "QwenStats", "parse_san_answer", "qwen_source"]El decoder no puede escribir una jugada que no existe; un modelo que escribe SAN puede fallar de tres maneras más, y separarlas es el trabajo de este fichero. Es la diferencia entre un desplegable y un campo de texto libre en un formulario: el desplegable no admite un país que no existe; el campo de texto admite «Espanya», «España » con un espacio al final y «asdf», y cada uno es un error distinto que hay que contar aparte.
MAX_NEW_TOKENS = 6DECODER_PLIES = 196"""Plies the decoder can carry (``block`` 200 minus its three header tokens and the move it isabout to make). The text model gets the same number, not the 120 its fine-tuning corpus was cutat: a harness that let one side play longer games than the other would be measuring the harness.Past 120 plies the prompt is longer than anything Qwen was fine-tuned on, and that is a propertyof the model rather than of the measurement -- it belongs in the result, not in the setup."""MOVE_NUMBER = re.compile(r"^\d+\.+")"""``12.`` or ``12...`` at the head of the answer: bookkeeping, not a move."""TRAILING = "!?""""Annotation glyphs a commentator adds; ``+`` and ``#`` are part of SAN and stay."""DECODER_PLIES = 196 da al modelo de texto el mismo número de pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply. que al decoder, no los 120 a los que se cortó su corpus: un harness que dejara a un lado jugar partidas más largas que al otro estaría midiendo el harness. Que pasados los 120 el prompt sea más largo que nada de lo que Qwen vio es una propiedad del modelo, y va en el resultado.
class QwenStats(BaseModel): """How the answers came out, by failure mode rather than as one number."""
model_config = ConfigDict(extra="forbid")
asked: int = 0 legal: int = 0 unparseable: int = 0 """The answer was not a move in any position.""" illegal: int = 0 """A well-formed move that this position does not allow.""" ambiguous: int = 0 """SAN that two pieces could satisfy; the model failed to disambiguate.""" empty: int = 0 """The model produced no non-whitespace token at all."""
@property def legal_rate(self) -> float: return self.legal / self.asked if self.asked else 0.0
def record(self, outcome: str) -> None: self.asked += 1 setattr(self, outcome, getattr(self, outcome) + 1)def parse_san_answer(board: chess.Board, answer: str) -> tuple[chess.Move | None, str]: """``(move, outcome)`` for the model's continuation, with the outcome named.
Only the first token is considered. A model that writes ``e4 e5 2. Nf3`` has answered the question asked and then kept going; scoring the whole continuation would be scoring a different question, and taking the last move would be scoring luck. """ text = answer.strip() if not text: return None, "empty" first = text.split()[0] stripped = MOVE_NUMBER.sub("", first) if not stripped: # the answer was just a move number; the move is the next token parts = text.split() if len(parts) < 2: return None, "empty" stripped = parts[1] stripped = stripped.rstrip(TRAILING) if not stripped: return None, "unparseable" try: return board.parse_san(stripped), "legal" except chess.IllegalMoveError: return None, "illegal" except chess.AmbiguousMoveError: return None, "ambiguous" except (chess.InvalidMoveError, ValueError): return None, "unparseable"Solo se considera el primer token: un modelo que escribe e4 e5 2. Nf3 ha contestado y luego ha
seguido, y puntuar la continuación sería puntuar otra pregunta. Si el modelo escribe 12. antes de
la jugada, se mira el segundo token; sin eso, PGN correcto se contaría como basura. Las tres
excepciones de python-chess son los tres modos de fallo.
class QwenPlayer: """A ``rukh.infer.Player`` backed by a causal language model that writes SAN."""
def __init__( self, model: Any, tokenizer: Any, stats: QwenStats | None = None, max_new_tokens: int = MAX_NEW_TOKENS, max_plies: int = DECODER_PLIES, ) -> None: self.model, self.tokenizer = model, tokenizer self.stats = stats if stats is not None else QwenStats() self.max_new_tokens = max_new_tokens self.max_plies = max_plies self.white_elo = self.black_elo = 1800
@property def limit(self) -> int: """Plies this player can carry; the decoder's number, so both play the same game.""" return self.max_plies
def start(self, white_elo: int, black_elo: int) -> None: self.white_elo, self.black_elo = white_elo, black_elo
def observe(self, move: chess.Move) -> None: """Nothing to record: the board carries the history and the prompt is built from it."""
def answer(self, board: chess.Board) -> str: """The raw continuation the model writes for this position.""" import torch
prompt = prompt_for(board, self.white_elo, self.black_elo) encoded = self.tokenizer(prompt, return_tensors="pt").to(self.model.device) with torch.no_grad(): out = self.model.generate( **encoded, max_new_tokens=self.max_new_tokens, do_sample=False, pad_token_id=self.tokenizer.pad_token_id or self.tokenizer.eos_token_id, ) written = out[0, encoded["input_ids"].shape[1] :] return str(self.tokenizer.decode(written, skip_special_tokens=True))El jugador implementa el protocolo Player que se extrae de play_game más abajo. La generación es
greedy y de seis tokens: una jugada son de uno a cuatro tokens de Qwen, y la suite del decoder juega
con top-k 1, lo más parecido a la moda.
def propose(self, board: chess.Board) -> tuple[chess.Move | None, str]: """What the model wrote, parsed, with the outcome named. No mask, no rescue.""" move, outcome = parse_san_answer(board, self.answer(board)) self.stats.record(outcome) return move, outcome
def choose(self, board: chess.Board) -> tuple[chess.Move | None, bool]: """The move to play, and whether the model's own answer was usable.
The fallback is the first legal move in the position's own order, not a second sample: the model already answered, and asking again until it says something legal would measure a different model from the one the legality rate describes. """ move, outcome = self.propose(board) if move is not None: return move, True legal = list(board.legal_moves) return (legal[0] if legal else None), False
def qwen_source(player: QwenPlayer) -> Any: """The ``MoveSource`` the puzzle suite expects, backed by the same player.
Puzzles are scored on the move the model *chooses*, so the fallback applies here too and a position where it wrote nonsense counts as a failed puzzle rather than as a missing one. """
def choose(board: chess.Board, history: list[int]) -> chess.Move | None: # noqa: ARG001 move, _ = player.choose(board) return move
return choosepropose es lo que el modelo escribió, sin máscara ni rescate. choose rescata con la primera
jugada legal de la posición, una elección arbitraria a propósito: volver a preguntar hasta obtener
algo legal mediría otro modelo. El rescate mantiene el harness corriendo; el contador es de lo que se
informa, igual que illegal_proposals en el decoder.
Los tests del análisis se pueden saltar en la primera lectura. El «Ejecútalo» de debajo los corre,
así que si no los tecleas, tráelos: git checkout p4 -- tests/unit/test_qwen_source.py.
// Teststests/unit/test_qwen_source.py: cada modo de fallo con la posición que lo produce
"""Tests for rukh.eval.qwen_source: the three ways a text model can fail where the decoder cannot.
Nothing here loads a language model. What is worth testing is the parsing and the accounting: thedecoder's vocabulary *is* the set of moves, so its only failure is "legal move, wrong position",while a model writing SAN can produce something that is not a move, a move this position does notallow, or a move two pieces could make. Folding those into one number would throw away the mostinteresting half of the comparison."""
from __future__ import annotations
import chessimport pytest
from rukh.eval.qwen_source import QwenPlayer, QwenStats, parse_san_answer
pytestmark = pytest.mark.unit
def _board(moves: list[str]) -> chess.Board: board = chess.Board() for uci in moves: board.push(chess.Move.from_uci(uci)) return board
@pytest.mark.parametrize( ("answer", "expected"), [ (" e4", "e2e4"), ("e4 e5 2. Nf3", "e2e4"), # it kept going; only the answer to the question counts ("1. e4", "e2e4"), # a move number in front is bookkeeping ("e4!?", "e2e4"), # commentary glyphs are not part of the move (" Nf3 ", "g1f3"), ], ids=["plain", "kept-going", "numbered", "annotated", "padded"],)def test_a_legal_answer_is_read_as_the_move_it_names(answer: str, expected: str) -> None: move, outcome = parse_san_answer(chess.Board(), answer) assert outcome == "legal" assert move is not None and move.uci() == expectedtests/unit/test_qwen_source.py
Los tests no cargan ningún modelo: lo que merece test es el análisis y la contabilidad.
def test_a_move_number_alone_reaches_for_the_next_token() -> None: move, outcome = parse_san_answer(chess.Board(), "1. e4") assert outcome == "legal" and move is not None
def test_something_that_is_not_a_move_is_unparseable() -> None: """`Nf9` is not a square; no position in chess allows it, so it is its own failure.""" for answer in ("Nf9", "hola", "O-O-O-O", "-"): _, outcome = parse_san_answer(chess.Board(), answer) assert outcome == "unparseable", answer
def test_a_well_formed_move_this_position_forbids_is_illegal() -> None: """The only failure the decoder can have, so it is the one that makes the two comparable.""" move, outcome = parse_san_answer(chess.Board(), "Qh5") # the queen is boxed in at the start assert outcome == "illegal" and move is None
def test_san_two_pieces_could_satisfy_is_ambiguous() -> None: """SAN is contextual: this is the failure mode the decoder's vocabulary cannot even express.""" board = _board(["g1f3", "d7d5", "d2d4", "g8f6"]) assert board.san(chess.Move.from_uci("b1d2")) == "Nbd2" # both knights reach d2 move, outcome = parse_san_answer(board, "Nd2") assert outcome == "ambiguous" and move is None
def test_an_empty_answer_is_counted_as_such() -> None: assert parse_san_answer(chess.Board(), " ")[1] == "empty" assert parse_san_answer(chess.Board(), "")[1] == "empty"
def test_the_stats_add_up_and_the_rate_is_of_what_was_asked() -> None: stats = QwenStats() for outcome in ("legal", "legal", "illegal", "unparseable", "ambiguous", "empty"): stats.record(outcome) assert stats.asked == 6 assert (stats.legal, stats.illegal, stats.unparseable, stats.ambiguous, stats.empty) == ( 2, 1, 1, 1, 1, ) assert stats.legal_rate == pytest.approx(2 / 6)tests/unit/test_qwen_source.py
Cada modo de fallo con la posición que lo produce de verdad. El de Nd2 demuestra primero la
ambigüedad (assert board.san(...) == "Nbd2"), para que el test no siga pasando si alguien cambia la
posición.
class _Scripted: """A stand-in for the language model that answers from a list, in order."""
device = "cpu"
def __init__(self, answers: list[str]) -> None: self.answers = list(answers)
class _Player(QwenPlayer): def __init__(self, answers: list[str]) -> None: super().__init__(_Scripted(answers), tokenizer=None) self._answers = list(answers)
def answer(self, board: chess.Board) -> str: # noqa: ARG002 return self._answers.pop(0) if self._answers else ""
def test_a_failed_proposal_is_rescued_and_still_counted() -> None: """A game has to finish, and the rescue must not hide that the model's own answer failed.
Same rule as the decoder's `illegal_proposals`: the fallback keeps the harness running, the count is what the legality rate is built from. """ player = _Player(["Nf9", "e4"]) move, was_legal = player.choose(chess.Board()) assert was_legal is False assert move in chess.Board().legal_moves # rescued, so the game continues assert player.stats.unparseable == 1
move, was_legal = player.choose(chess.Board()) assert was_legal is True assert move is not None and move.uci() == "e2e4" assert player.stats.legal == 1
def test_the_rescue_does_not_ask_the_model_again() -> None: """Asking until it answers legally would measure a different model from the reported one.""" player = _Player(["Nf9"]) player.choose(chess.Board()) assert player.stats.asked == 1
def test_a_finished_position_rescues_to_nothing_rather_than_crashing() -> None: mate = chess.Board("7k/5KQ1/8/8/8/8/8/8 b - - 0 1") move, was_legal = _Player(["Kh7"]).choose(mate) assert move is None and was_legal is Falsetests/unit/test_qwen_source.py
Con un modelo de guion se prueban los tres contratos del rescate: ocurre y se cuenta, no se vuelve a
preguntar, y una posición terminada devuelve None en vez de reventar.
La pieza que deja entrar a otro jugador en la escalera
Para que Qwen juegue contra los mismos rivales que el decoder hizo falta partir play_game, en
src/rukh/infer/game.py, en dos: el jugador y todo lo que lo rodea.
El refactor es fontanería y se puede saltar: el decoder sigue jugando exactamente igual, y el
«Ejecútalo» de abajo lo comprueba. La suite de Qwen importa las piezas nuevas, así que si lo saltas,
trae el fichero: git checkout p4 -- src/rukh/infer/game.py.
// Fontaneríagame.py: el protocolo Player, DecoderPlayer y play_game_with
"""Playing a whole game: a player against an opponent, counting illegal proposals.
An opponent is anything with ``choose(board) -> chess.Move``: the seeded ``RandomOpponent``used by the unit tests, ``StockfishOpponent`` for the Elo harness, or the browser in the demo.
The *player* -- the side being measured -- is a protocol too, and for one reason: M4 has to puta general language model through the same ladder as the decoder, and a comparison where the twosides are measured by different code is not a comparison. ``DecoderPlayer`` is the original path,unchanged and still the default; ``rukh.eval.qwen_source`` supplies the other one. Everythingaround them -- the opponent, termination, adjudication, how a cut game is scored -- stays here,which is exactly the part that must not differ between the two.El rival, la terminación y la adjudicación se quedan aquí, porque son lo que no puede diferir entre los dos jugadores.
class Player(Protocol): """Whatever plays the side being measured.
``choose`` returns the move the player will actually make **and** whether its unmasked proposal was legal. The two are different questions: a game has to keep going, so an illegal proposal is rescued with a masked draw, but the rescue must not hide the fact that it happened -- that count is the legality signal the whole harness is built around. """
@property def limit(self) -> int: """Plies this player can carry before it runs out of context."""
def start(self, white_elo: int, black_elo: int) -> None: """Begin a new game at the given ratings."""
def choose(self, board: chess.Board) -> tuple[chess.Move | None, bool]: ...
def observe(self, move: chess.Move) -> None: """Record a move that was played, by either side."""
class DecoderPlayer: """``MoveDecoder`` as a ``Player``: the path every published number was measured on."""
def __init__(self, model: MoveDecoder, tok: UciTokenizer, cfg: SampleConfig) -> None: self.model, self.tok, self.cfg = model, tok, cfg self.masked = cfg.model_copy(update={"mask_illegal": True}) self.generator = model_generator(model, cfg) self.history: list[int] = []
@property def limit(self) -> int: return self.model.cfg.block - HEADER_TOKENS - 1
def start(self, white_elo: int, black_elo: int) -> None: self.history = _history(self.tok, white_elo, black_elo)
def choose(self, board: chess.Board) -> tuple[chess.Move | None, bool]: move, _ = pick_move(self.model, self.tok, board, self.history, self.cfg, self.generator) if move is not None: return move, True rescued, _ = pick_move( self.model, self.tok, board, self.history, self.masked, self.generator ) return rescued, False
def observe(self, move: chess.Move) -> None: self.history.append(self.tok.vocab.get(move.uci(), self.tok.unk_id))El protocolo obliga a decir dos cosas: qué jugada se hace y si la propuesta sin máscara era legal.
La partida tiene que seguir, pero el rescate no puede esconder que ocurrió. DecoderPlayer es el
camino original copiado palabra por palabra, para que ningún número publicado antes cambie.
player = DecoderPlayer(model, tok, cfg) return play_game_with( player, opponent, model_color=model_color, white_elo=white_elo, black_elo=black_elo, max_plies=max_plies, board=board, )
def play_game_with( player: Player, opponent: Opponent, model_color: chess.Color = chess.WHITE, white_elo: int = 1800, black_elo: int = 1800, max_plies: int | None = None, board: chess.Board | None = None,) -> GameResult: """``play_game`` for any ``Player``; the decoder's version is the one-line wrapper above.""" board = board if board is not None else chess.Board() limit = max_plies if max_plies is not None else player.limit player.start(white_elo, black_elo) moves: list[str] = [] illegal = 0
while len(moves) < limit and not is_over(board): if board.turn == model_color: move, was_legal = player.choose(board) if not was_legal: illegal += 1 if move is None: break else: move = opponent.choose(board) moves.append(move.uci()) player.observe(move) board.push(move)play_game queda como una línea sobre play_game_with, cuyo bucle es el de antes con el jugador en
lugar de las llamadas a pick_move.
qwen_suite.py: la misma suite, otro jugador
La suite de Qwen es la de M2 con otro jugador, y se puede saltar en la primera lectura. Dos ideas la
justifican: todo (posiciones, puzles, escalera, bootstrap) se toma prestado para que la diferencia
no venga del código, y la tasa de legalidad se toma como instantánea justo después de las posiciones
de validación, para que tenga el mismo denominador que la del decoder. El ensayo del final de la
lección la usa, igual que la configuración de ensayo del mismo pliegue, así que si lo saltas, trae
los dos: git checkout p4 -- src/rukh/eval/qwen_suite.py configs/eval/qwen-smoke.yaml.
// Fontaneríaqwen_suite.py entero y su configuración de ensayo
"""The same suite, run on a general language model, so the M4 table compares like with like.
Everything here is borrowed: the validation positions of ``rukh.eval.legality``, the puzzle splitof ``rukh.eval.puzzles``, the Stockfish ladder and the bootstrap of ``rukh.eval.elo``. Only theplayer differs, which is the whole design -- if the two sides of the comparison went throughdifferent code, the difference in the numbers would be partly the code.
One column exists here and not in the decoder's report, and it is the interesting one. A decoderwhose vocabulary *is* the set of moves can only fail one way: a legal move in the wrong position.A model writing SAN can also write something that is not a move at all, or a move two pieces couldmake and that it failed to disambiguate. Those are counted apart (``QwenStats``) rather thansummed into "illegal", because summing them would hide exactly what the representation costs."""
from __future__ import annotations
import loggingfrom datetime import UTC, datetimefrom pathlib import Pathfrom typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict
from rukh import pathsfrom rukh.eval.elo import EloResult, estimate, play_rungsfrom rukh.eval.puzzles import PuzzleResult, load_puzzles, run_puzzlesfrom rukh.eval.qwen_source import QwenPlayer, QwenStats, qwen_sourcefrom rukh.eval.report import WebRow, elo_line, write_report_filesfrom rukh.eval.suite import EvalConfig, _positionsfrom rukh.tokenize.uci_vocab import UciTokenizer
if TYPE_CHECKING: from rukh.eval.legality import Position
log = logging.getLogger(__name__)
__all__ = ["QwenResult", "evaluate_qwen", "run_qwen_suite"]«Todo aquí es prestado» es el diseño: posiciones, puzles, escalera de Stockfish y bootstrap son los de M2. Si los dos lados pasaran por código distinto, la diferencia en los números sería en parte el código.
class QwenResult(BaseModel): """One evaluation of a text model on the decoder's own suite."""
model_config = ConfigDict(extra="forbid")
stage: str base_model: str adapter_dir: str params: int date: str device: str positions: int written: QwenStats """How its answers came out, by failure mode. ``legal_rate`` is the column that faces the decoder's "legality without the mask".""" top1: float | None = None puzzles: PuzzleResult | None = None elo: EloResult | None = None notes: list[str] = [] config: dict[str, Any] = {}El resultado añade written, el desglose por modo de fallo, cuya legal_rate se enfrenta a la
«legalidad sin máscara» del decoder.
def evaluate_qwen( adapter_dir: Path | str, cfg: EvalConfig, stage: str = "qwen3-pgn-qlora", use_cache: bool = True, device: str | None = None,) -> QwenResult: """Run legality, next-move accuracy, puzzles and Elo on a fine-tuned general model.""" from rukh.eval.cache import EvalCache, config_sha from rukh.train.qwen import load_qwen_adapter
directory = Path(adapter_dir) model, tokenizer = load_qwen_adapter(directory, device=device) where = str(next(model.parameters()).device) tok = UciTokenizer() notes: list[str] = [] positions: list[Position] = _positions(cfg, tok, notes) player = QwenPlayer(model, tokenizer)
log.info("legality and accuracy over %s positions", min(len(positions), cfg.accuracy_positions)) scored = positions[: cfg.accuracy_positions] hits = 0 for position in scored: board = _board_of(position) move, _ = player.propose(board) if move is not None and position.target is not None and move.uci() == position.target: hits += 1 written = player.stats.model_copy(deep=True)
result = QwenResult( stage=stage, base_model=_base_model(directory), adapter_dir=directory.as_posix(), params=sum(p.numel() for p in model.parameters()), date=datetime.now(UTC).date().isoformat(), device=where, positions=len(scored), written=written, top1=hits / len(scored) if scored else None, notes=notes, config=cfg.model_dump(mode="json"), )La línea que importa es written = player.stats.model_copy(deep=True): una instantánea de los
contadores tomada justo después de las posiciones de validación.
cache = EvalCache( paths.resolve(cfg.cache_db) if use_cache else None, f"qwen:{stage}", enabled=use_cache, config_sha=config_sha(cfg.cache_fields()), ) try: puzzle_path = paths.resolve(cfg.puzzles) if puzzle_path.is_file(): items = load_puzzles( puzzle_path, cfg.puzzles_per_band, seed=cfg.seed, split=cfg.puzzle_split ) log.info("puzzles: %s", len(items)) result.puzzles = run_puzzles( qwen_source(player), tok, items, cache=cache, header_elo=cfg.header_elo, force_header=cfg.force_header, ) else: notes.append(f"puzzles not found at {puzzle_path}: puzzle suite skipped") if cfg.elo_games: log.info("elo: %s games per rung", cfg.elo_games) records = play_rungs( None, tok, cfg.elo_rungs, cfg.elo_games, cfg.sampling(), move_time=cfg.elo_move_time, max_plies=cfg.elo_max_plies, cache=cache, header_elo=cfg.header_elo, player=player, ) result.elo = estimate(records, samples=cfg.bootstrap, seed=cfg.seed) finally: cache.close() # `result.written` stays the snapshot taken over the validation positions and nothing else. # The player keeps counting through the puzzles and the games, and folding those in would # give a legality rate over a different denominator than the decoder's -- which is the one # number in this report that has to face the decoder's directly. result.notes = notes return resultEs el detalle metodológico más fino del fichero: el jugador sigue contando durante los puzles y las partidas, y sumarlo todo daría una tasa de legalidad sobre otro denominador que la del decoder.
def _board_of(position: Position) -> Any: import chess
board = chess.Board() for uci in position.moves: board.push(chess.Move.from_uci(uci)) return board
def _base_model(directory: Path) -> str: import json
config = directory / "adapter_config.json" if not config.is_file(): return "unknown" return str(json.loads(config.read_text(encoding="utf-8")).get("base_model_name_or_path", "?"))def render_markdown(result: QwenResult) -> str: """The report, with the failure breakdown the decoder's report has no column for.""" stats = result.written
def share(count: int) -> str: return f"{100 * count / stats.asked:.2f} %" if stats.asked else "n/a"
lines = [ f"# Evaluation of `{result.stage}`", "", f"- Base model: `{result.base_model}`", f"- Adapter: `{result.adapter_dir}`", f"- Parameters: {result.params:,}", f"- Device: `{result.device}`", f"- Date: {result.date}", "", "## Headline", "", "| Metric | Value |", "|---|---|", f"| Legal moves written, no mask | {share(stats.legal)} |", f"| Top-1 next move | {'n/a' if result.top1 is None else f'{result.top1 * 100:.1f} %'} |", f"| Puzzles solved | " f"{'n/a' if result.puzzles is None else f'{result.puzzles.rate * 100:.1f} %'} |", f"| Estimated Elo | {elo_line(result.elo)} |", "", "## How the answers failed", "", "A decoder cannot write a move that does not exist: its vocabulary is the set of moves, " "so its only failure is a legal move in the wrong position. A model writing SAN has three " "more ways to be wrong, and folding them together would hide what the representation " "costs.", "", "| Outcome | Count | Share |", "|---|---:|---:|", f"| legal | {stats.legal} | {share(stats.legal)} |", f"| illegal here | {stats.illegal} | {share(stats.illegal)} |", f"| not a move at all | {stats.unparseable} | {share(stats.unparseable)} |", f"| ambiguous SAN | {stats.ambiguous} | {share(stats.ambiguous)} |", f"| nothing written | {stats.empty} | {share(stats.empty)} |", f"| **asked** | **{stats.asked}** | |", "", ] if result.puzzles is not None: lines += [ "## Puzzles by band", "", "| Band | Attempted | Solved | Rate |", "|---|---:|---:|---:|", ] lines += [ f"| {band.band} | {band.attempted} | {band.solved} | {band.rate * 100:.1f} % |" for band in result.puzzles.bands ] lines.append("") if result.elo is not None: lines += ["## Games against Stockfish", "", "| Rung | Games | Score |", "|---|---:|---:|"] lines += [f"| {rung.name} | {rung.games} | {rung.score:.3f} |" for rung in result.elo.rungs] lines.append("") if result.notes: lines += ["## Notes", ""] + [f"- {note}" for note in result.notes] + [""] return "\n".join(lines)La explicación de «How the answers failed» va dentro del informe generado: quien lo abra dentro de un año tiene que entender por qué hay cinco filas donde el otro informe tiene una.
def row_of(result: QwenResult) -> WebRow: """The row this model contributes to the single results table.""" elo = result.elo interval = ( [elo.ci_low, elo.ci_high] if elo and not elo.separated and elo.ci_low is not None and elo.ci_high is not None else None ) return WebRow( stage=result.stage, params=result.params, legality=result.written.legal_rate or None, top1=result.top1, puzzles=result.puzzles.by_band() if result.puzzles else {}, elo=elo.elo if elo else None, elo_ci=interval, elo_lower=elo.elo_lower if elo else None, elo_upper=elo.elo_upper if elo else None, elo_separated=bool(elo.separated) if elo else False, date=result.date, )
def run_qwen_suite( adapter_dir: Path | str, cfg: EvalConfig, stage: str = "qwen3-pgn-qlora", use_cache: bool = True, device: str | None = None,) -> tuple[QwenResult, Any]: """Evaluate and report, so the row lands in the same table as every other stage.""" import json
result = evaluate_qwen(adapter_dir, cfg, stage=stage, use_cache=use_cache, device=device) report = write_report_files( result.stage, render_markdown(result), json.loads(result.model_dump_json()), paths.resolve(cfg.out_dir), paths.resolve(cfg.web_results) if cfg.web_results else None, row_of(result), ) return result, reportrow_of da a Qwen una fila de la tabla única del proyecto, junto al decoder. Cuando se ganan o se
pierden todas las partidas (elo.separated) no hay intervalo que publicar, solo una cota: Qwen
perdió 160 de 160, así que su fila lleva < 807.
# A rehearsal of `rukh eval qwen`: enough of every phase to fail, not enough to wait for.games: data/uci/year=2025/month=02/games.parquetpuzzles: data/puzzles/puzzles.parquetpuzzle_split: testlegality_positions: 12accuracy_positions: 12position_pool: 500puzzles_per_band: 3elo_games: 1elo_rungs: - name: uci-1320 uci_elo: 1320 elo: 1320elo_move_time: 0.05bootstrap: 50temperature: 0.05top_k: 1block: 200seed: 42header_elo: 1800diversity_games: 0out_dir: artifacts/evalweb_results: nullcache_db: artifacts/eval/cache-qwen-smoke.sqlitetrack: falseEl ensayo de la evaluación tiene lo justo de cada fase para que falle si tiene que fallar, y una caché propia para no contaminar la de las mediciones de verdad.
Los dos subcomandos de la CLI
Los dos subcomandos son cáscaras sobre la librería; se pueden saltar, y no hay nada que teclear:
cli.py llegó entero en la lección 2.
// Fontaneríacli.py: rukh train qwen y rukh eval qwen
@train_app.command("qwen")def train_qwen_cmd( config: Annotated[ Path | None, typer.Option("--config", exists=True, dir_okay=False, readable=True, help="YAML config."), ] = None, device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None,) -> None: """QLoRA a general language model on PGN text, for the comparison table of M4.
Four-bit is attempted and not required: at 0.6 B parameters on a 32 GB card it saves nothing that matters, so if `bitsandbytes` will not load the run falls back to bf16 and says so in its report instead of claiming a technique it did not use. """ from rukh.config import load_yaml from rukh.train.qwen import QwenConfig, train_qwen
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") cfg = load_yaml(config, QwenConfig) if config is not None else QwenConfig() try: report = train_qwen(cfg, device=device) except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc typer.echo(f"model: {report.model}") typer.echo(f"precision: {'4-bit NF4' if report.four_bit else 'bf16'}") if report.fallback_reason: typer.echo(f"fallback: {report.fallback_reason}") typer.echo( f"trainable: {report.trainable_params:,} of {report.total_params:,} " f"({100 * report.trainable_share:.3f} %)" ) typer.echo(f"samples: {report.train_samples:,} over {report.steps:,} steps") if report.final_loss is not None: typer.echo(f"loss: {report.final_loss:.4f}") if report.weights_memory_mb is not None: typer.echo(f"weights: {report.weights_memory_mb:,.0f} MB on the device") if report.peak_memory_mb is not None: typer.echo(f"peak mem: {report.peak_memory_mb:,.0f} MB during training") typer.echo(f"adapter: {report.adapter_dir}")Cada línea que imprime el subcomando sale de un campo de QwenReport, así que la consola y run.json no
pueden discrepar. fallback: solo se imprime si hay razón: una línea «fallback: none» en cada
ejecución entrenaría al lector a no leerla.
@eval_app.command("qwen")def eval_qwen_cmd( adapter: Annotated[ Path, typer.Option( "--adapter", exists=True, file_okay=False, help="Directory of the fine-tuned adapter." ), ], suite: Annotated[str, typer.Option("--suite", help="Suite name: full or quick.")] = "full", config: Annotated[ Path | None, typer.Option( "--config", exists=True, dir_okay=False, readable=True, help="Suite YAML override." ), ] = None, stage: Annotated[str, typer.Option("--stage", help="Row name in the results table.")] = ( "qwen3-pgn-qlora" ), no_cache: Annotated[bool, typer.Option("--no-cache", help="Recompute everything.")] = False, device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None,) -> None: """Put a fine-tuned general model through the decoder's own suite.
Same positions, same puzzles, same Stockfish ladder. The one column this report has and the decoder's does not is the breakdown of *how* its answers failed: a model writing SAN can write something that is not a move, or a move two pieces could make, and neither is possible for a vocabulary in which one token is one move. """ from rukh.eval import load_suite from rukh.eval.qwen_suite import run_qwen_suite from rukh.eval.report import elo_line from rukh.eval.suite import SUITES
if suite not in SUITES: typer.echo(f"error: --suite must be one of {', '.join(SUITES)}", err=True) raise typer.Exit(code=2) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") cfg = load_suite(suite, config) try: result, report = run_qwen_suite( adapter, cfg, stage=stage, use_cache=not no_cache, device=device ) except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc stats = result.written typer.echo(f"stage: {result.stage} ({result.params:,} parameters)") typer.echo(f"legal: {stats.legal_rate:.4f} of {stats.asked} answers") typer.echo( f"failures: {stats.illegal} illegal, {stats.unparseable} not a move, " f"{stats.ambiguous} ambiguous, {stats.empty} empty" ) if result.top1 is not None: typer.echo(f"accuracy: top1 {result.top1:.4f}") if result.puzzles is not None: typer.echo(f"puzzles: {result.puzzles.rate:.4f} solved") if result.elo is not None: typer.echo(f"elo: {elo_line(result.elo)} over {result.elo.games} games") typer.echo(f"report: {report.markdown}")La de evaluación imprime los cuatro modos de fallo por separado en la consola, que es donde se mira mientras corre.
Después, las dos ejecuciones de verdad:
uv run rukh train qwen --config configs/train/qwen3-pgn.yamluv run rukh eval qwen --adapter checkpoints/qwen3-pgn-qlora --config configs/eval/greedy.yaml --stage qwen3-pgn-qloraEl afinado real corre 1 500 pasos; la evaluación por la misma escalera tarda unos 40 minutos, más que la del decoder, porque cada jugada es una generación de texto.
El run.json contesta a la pregunta de la memoria, y a esta escala la respuesta es la esperada:
poco. Cuatro bits sobre Qwen3-0.6B ahorraron 341 MB de pesos (un 29 %) y 7 MB del pico (un
0,4 %). La tabla de embeddings, el 25,9 % del modelo, no se cuantiza, y el pico lo marcan las
activaciones y el optimizador. La ventaja de QLoRA aparece cuando el modelo no cabe, y a 0,6 B en
una tarjeta de 32 GB cabe todo.
Qué has aprendido
Cómo se entra en un ecosistema sin mudarse a él: una envoltura que deja una sola definición del paso
hacia delante y a cambio abre peft, trl y el Hub, con tres trampas que cuesta descubrir (las
etiquetas ya desplazadas, el atributo que no se puede llamar decoder, los pesos atados). Y la otra
mitad de una comparación honesta: mismo corpus, mismo harness, un presupuesto generoso con el otro
lado y una columna que cuenta las tres maneras de fallar que el decoder no tiene.
Lo siguiente es sacar todo esto del disco. Sigue en «Adaptadores y publicación».