rukh · lab

// M4 · lección 10

Los labs del afinado

Los cuatro scripts de labs/m4 enteros y los nueve laboratorios que producen cada número del módulo: mirar el corpus antes de culpar al modelo, descargar la mitad que faltaba, el corpus plano y el afinado condicionado, LoRA a mano contra peft, cuántas partidas harían falta, estilo con adaptadores, Qwen3 con QLoRA y publicar los cinco artefactos en seis repos del Hub.

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

Lección 10 de 10 del módulo «Fine-tuning e instrucción». Viene de «Lo que salió» y cierra el módulo con los cuatro scripts de labs/m4/, que son instrumentos de medida y no parte del motor, y la secuencia de comandos que produce los cinco artefactos del hito.

Los labs

Nueve laboratorios. Los dos primeros son de datos y no tocan el modelo: ahí estaba el fallo, y ahí hay que empezar.

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

LabRutaRelojDejaAtajo
Lab 1Mirar el corpus antes de culpar al modelosolo observar—el histograma que explica el agujero de Elono hay
Lab 2Descargar el tramo bajo que faltabacuesta máquina~10 min de red + ~5 de CPU; lo que cuesta es la descarga, la GPU no se usadata/uci-low/, 2,3 M partidasno hay
Lab 3El corpus plano y el afinado condicionadocuesta máquina~15 min de datos + ~20 de afinadocheckpoints/medium-elo-<fecha>/step-3800.ptrukh pull medium-elo
Lab 4LoRA escrita a manoimprescindiblesegundos, es un testtests/unit/test_lora.py en verdeno hay
Lab 5La envoltura HF y la prueba contra peftimprescindiblesegundosla misma pérdida que peft paso a paso: idéntica al bit (el test tolera 1e-5)no hay
Lab 6¿Faltan partidas o no hay diferencia?cuesta máquina~26 min por condición: 2 h 36 el barrido, 52 min el controlartifacts/eval/medium-elo-elo-sweep/ y su controlrukh pull --module m4 trae los cinco resultados ya medidos
Lab 7Estilo con adaptadorescuesta máquina~5 min de rebanadas, 7 por adaptador, 15 el grafo adaptablecheckpoints/lora-{e4,d4}-<fecha>/adapter.safetensors, 1,6 MBrukh pull lora-e4 y rukh pull lora-d4
Lab 8Qwen3 con QLoRA, por el mismo harnesscuesta máquina~10 min de PGN, el afinado, ~40 de evaluacióncheckpoints/qwen3-pgn-qlora/rukh pull qwen3-pgn-qlora
Lab 9Publicar y encender el selector de la democuesta máquina~26 min por evaluación + 12 por exportación, seis reposseis carpetas en artifacts/publish/ (o seis repos del Hub) y el selector de la demono hay
solo observar
No se ejecuta nada: se lee la salida y se responde a la pregunta.
cuesta máquina
Tiempo real de GPU, red o motor. El reloj es el de la RTX 5090 de referencia.
imprescindible
Segundos o pocos minutos, y el lab siguiente da por hecho que lo corriste.

Lab 1 · Mirar el corpus antes de culpar al modelo

// Viene delección 2 · Los tokens de Elo y el corpus que faltaba

Objetivo: encontrar el agujero con una consulta, no con una intuición.

Antes de aceptar que «el condicionamiento por Elo no da fuerza», cuenta cuántas veces ha visto el modelo cada token de cabecera. El segundo token de cada partida empaquetada es el Elo de las blancas, así que basta un script sin modelo, sin GPU y sin red.

labs/m4/header_histogram.py
"""Count how many times the model has seen each Elo header, before blaming the model.
Lab 1 of M4. The claim under test is "the Elo conditioning opens the axis but gives no strength",
which was the conclusion of the previous milestone. Before accepting it, count: the second token of
every packed game *is* White's Elo header, so a histogram of `tokens[starts + 1]` says exactly how
much training each condition got.
Run from the repository root. `--tokens` points at any packed stream:
uv run python labs/m4/header_histogram.py
uv run python labs/m4/header_histogram.py --tokens data/tokens-elo/uci/train
The corpus every published model trained on (`data/tokens-v4/uci/train`) starts at `<w1800>` and
has nothing below it: twelve header tokens that exist in the vocabulary, have a row in the
embedding table, and never received a gradient.
"""

labs/m4/header_histogram.pylíneas 1-16 · p4

labs/m4/header_histogram.py
import argparse
import collections
import sys
from pathlib import Path
import numpy as np
from rukh.tokenize.uci_vocab import UciTokenizer
# The Windows console is cp1252 by default; ask for UTF-8 before printing the bar chart.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tokens", default="data/tokens-v4/uci/train", type=Path)
parser.add_argument("--width", default=48, type=int, help="Columns of the widest bar.")
args = parser.parse_args()
tok = UciTokenizer()
tokens = np.load(args.tokens / "tokens.npy", mmap_mode="r")
starts = np.load(args.tokens / "starts.npy")

labs/m4/header_histogram.pylíneas 18-38 · p4

El reconfigure de sys.stdout evita que la consola de Windows (cp1252) mate la medición con un UnicodeEncodeError. Y tokens.npy se abre con mmap_mode="r": son 3,4 GB y solo se leen 18,9 millones de posiciones sueltas.

labs/m4/header_histogram.py
# Every game is `<bos> <wXXXX> <bXXXX> moves...`, so offset 1 from its start is White's header.
counts = collections.Counter(tokens[starts + 1].tolist())
if not counts:
raise SystemExit(f"no games in {args.tokens}")
top = max(counts.values())
print(f"{args.tokens}: {len(starts):,} games, {len(tokens):,} tokens\n")
for token_id, n in sorted(counts.items()):
bar = "#" * max(1, round(args.width * n / top))
print(f" {tok.ids[token_id]:>9s} {n:>9,d} {bar}")
# The twelve tokens the vocabulary has below 1800, and how many of them the corpus ever used.
below = [tok.vocab[f"<w{elo:04d}>"] for elo in range(600, 1800, 100)]
seen = sum(1 for token_id in below if counts.get(token_id))
print(f"\nheaders below <w1800>: {seen} of {len(below)} ever seen in this corpus")
print("an untrained header is not a header that does not work: it is noise with a row in a table")

labs/m4/header_histogram.pylíneas 40-55 · p4

tokens[starts + 1] es la medición completa: una indexación vectorizada que saca el segundo token de cada partida. La última parte convierte el histograma en un veredicto: una tabla a la que le falta una fila se lee como una tabla; una línea que dice «0 de 12» no.

Lo que sale del corpus con el que se entrenó el modelo publicado:

data/tokens-v4/uci/train: 18,942,740 games, 1,681,069,636 tokens
<w1800> 1,570,327 #############
<w1900> 1,539,899 ############
<w2000> 1,140,483 #########
<w2100> 726,462 ######
<w2200> 413,442 ###
<w2300> 1,075,847 #########
<w2400> 2,513,495 ####################
<w2500> 5,969,302 ################################################
<w2600> 2,384,084 ###################
<w2700> 845,767 #######
<w2800> 367,217 ###
<w2900> 224,455 ##
<w3000> 153,060 #
<w3100> 17,107 #
<w3200> 1,793 #
headers below <w1800>: 0 of 12 ever seen in this corpus

Cero de doce. Ni una partida por debajo de 1800 en 1 680 millones de tokens. De paso, el histograma enseña que casi un tercio del corpus es <w2500>: la Elite Database arrastra la distribución hacia arriba. Un corpus no es plano si nadie lo aplana.

  • Comprueba también data/elo-bins/manifest.json: su bin más bajo es 1800, la misma frase dicha desde otro sitio.

Lab 2 · Descargar la mitad que faltaba

// Viene delección 2 · Los tokens de Elo y el corpus que faltaba

Objetivo: un segundo corpus que se diferencie del primero solo en el rango de fuerza.

El código, src/rukh/data/fetch.py, está entero en la lección 2. Aquí van los comandos y lo que tardaron.

Terminal
uv run rukh data fetch --config configs/data/lichess-low.yaml
uv run rukh data uci --config configs/data/pipeline-low.yaml

La descarga son tres shards por mes y unos diez minutos; la conversión, cinco más, y deja data/uci-low/ con 1 147 074 partidas de enero y 1 145 401 de febrero.

  • Un test comprueba sobre las dos configuraciones que se envían que low.max_elo + 1 == high.min_elo y todo lo demás es idéntico: las dos mitades del eje embaldosan el rango sin solaparse ni dejar hueco, y eso se comprueba en CI sin red.

Lab 3 · El corpus plano y el afinado condicionado

// Viene delección 2 · Los tokens de Elo y el corpus que faltaba

Objetivo: un corpus cuya forma sea el experimento, y un afinado que no herede el calendario del preentrenamiento. El código está en la lección 2. Los comandos:

Terminal
uv run rukh data elo-bins --config configs/data/pipeline-low.yaml
uv run rukh data tokenize --config configs/data/pipeline-low.yaml --scheme uci --pack
uv run rukh train --config configs/train/medium-elo.yaml

Salida real del corpus plano: 150 000 partidas en cada banda de 1000 a 2200, 146 232 en la de 2400 y 23 191 en la de 2600. El afinado son 3 800 pasos y 19,5 minutos, y el resultado es step-3800.pt, no best.pt. El de maestros no pasa por elo-bins: empaqueta la Elite Database sola y afina, 20,7 minutos.

Terminal
uv run rukh data tokenize --config configs/data/pipeline-masters.yaml --scheme uci --pack
uv run rukh train --config configs/train/medium-masters.yaml
  • Un test del bucle comprueba que con lr = 0 el afinado es el checkpoint del que partió: la forma más barata de demostrar que init_from carga los pesos y nada más.

Lab 4 · LoRA escrita a mano

// Viene delección 3 · LoRA escrita a mano

Objetivo: un adaptador que sea LoRA de verdad, y las pruebas que lo demuestran.

src/rukh/models/lora.py está en la lección 3. Este lab comprueba las cuatro afirmaciones del método sobre un decoder de juguete, sin checkpoint.

labs/m4/lora_check.py
"""Check that the LoRA written by hand is LoRA, including against `peft`.
Lab 4 and Lab 5 of M4, in one script and with no checkpoint: it builds its own toy decoder.
Four claims, in order of how easy they are to get wrong:
1. **An untrained adapter is the identity.** `B` starts at zero, so applying LoRA changes nothing
until a gradient arrives. Any other initialisation moves the weights before the model has
learned anything, and every comparison against the base starts from a model that is already
different.
2. **Only the factors train**, and there are exactly `layers x targets x 2 x r x d_model` of them.
3. **Merging is exact.** `W + BA` folded into the weight gives the same logits as the wrapper
computing it on the fly. That is what lets a 1.6 MB adapter be exported, quantised and served
as an ordinary model.
4. **It is the same update rule as `peft`'s.** Same base weights, same `A` copied across, same
optimiser, same batch: the two loss curves have to coincide step by step. If the scaling, the
side `A` multiplies on, or the zero initialisation of `B` differed, they would separate on the
first step -- and all three mistakes produce a model that trains and converges anyway.
Run from the repository root:
uv run python labs/m4/lora_check.py
The fourth check needs the `hf` extra (`uv sync --extra cu128 --extra hf`); without it the script
runs the first three and says so.
"""

labs/m4/lora_check.pylíneas 1-26 · p4

Las cuatro afirmaciones están ordenadas por lo fácil que es equivocarse en ellas: equivocarse en el lado por el que multiplica A o en dónde vive alpha / r es lo normal, y el modelo entrena y converge igual.

labs/m4/lora_check.py
import sys
import torch
from rukh.models import DecoderConfig, MoveDecoder, preset
from rukh.models.lora import LoraConfig, apply_lora, lora_modules, merge_lora
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
TOY = DecoderConfig(vocab_size=64, n_layer=2, n_head=2, d_model=32, block=16, dropout=0.0)
R, ALPHA, STEPS = 4, 8, 6
def toy(seed: int = 0) -> MoveDecoder:
torch.manual_seed(seed)
return MoveDecoder(TOY)
torch.manual_seed(99)
x = torch.randint(1, 64, (4, 16))
y = torch.randint(1, 64, (4, 16))

labs/m4/lora_check.pylíneas 28-49 · p4

El juguete es el mismo MoveDecoder del proyecto, en pequeño, y toy(seed) siembra antes de construir para poder montar dos modelos con los mismos pesos base.

labs/m4/lora_check.py
# --- 1. an untrained adapter is the identity ---------------------------------
model = toy().eval()
with torch.no_grad():
before = model(x)[0]
trainable = apply_lora(model, LoraConfig(r=R, alpha=ALPHA, targets=("q", "v")))
with torch.no_grad():
after = model(x)[0]
print(f"1. identity at step 0: max |delta| = {(before - after).abs().max():.2e}")
# --- 2. the parameter count is the formula -----------------------------------
expected = TOY.n_layer * 2 * 2 * R * TOY.d_model
by_grad = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"2. trainable: {trainable:,} == {by_grad:,} == layers*targets*2*r*d_model = {expected:,}")
medium = preset("medium")
real = medium.n_layer * 2 * 2 * 8 * medium.d_model
print(f" on `medium` with r=8: {real:,} of 115,120,128 = {100 * real / 115_120_128:.3f} %")
print(f" as float32: {real * 4:,} bytes = {real * 4 / 1e6:.1f} MB")
# --- 3. merging is exact ------------------------------------------------------
torch.manual_seed(7)
with torch.no_grad():
for _, adapter in lora_modules(model):
for b in adapter.b:
b.normal_(std=0.05)
adapted = model(x)[0].clone()
merge_lora(model)
with torch.no_grad():
merged = model(x)[0]
print(f"3. merge is exact: max |delta| = {(adapted - merged).abs().max():.2e}")
print(f" and no wrappers are left: {len(list(lora_modules(model)))} adapters")

labs/m4/lora_check.pylíneas 51-80 · p4

La primera espera cero exacto, no «algo pequeño»: con B a cero la suma no cambia ni un bit, y un 1e-8 significaría que la inicialización no es cero. La segunda compara tres recuentos que tienen que coincidir: el de apply_lora, el de los parámetros con requires_grad y la fórmula. La tercera, la que autoriza a publicar, llena las B de ruido (con ceros, fundir sería trivialmente exacto), funde y compara: 1.19e-07 sobre el juguete es la precisión de float32.

Lab 5 · La envoltura HF y la prueba contra peft

// Viene delección 3 · LoRA escrita a mano y lección 5 · QLoRA con `transformers`: el decoder entra en el ecosistema

Objetivo: que el decoder entre en el ecosistema sin dejar de ser el decoder, y usarlo para comprobar el laboratorio anterior. La envoltura está en la lección 5; la cuarta comprobación del script anterior cierra los dos labs:

labs/m4/lora_check.py
# --- 4. the same update rule as `peft` ---------------------------------------
try:
import peft
from rukh.models.hf import RukhForCausalLM
except ImportError as exc:
print(f"4. skipped: needs the `hf` extra ({exc})")
raise SystemExit(0) from None
ours = toy()
apply_lora(ours, LoraConfig(r=R, alpha=ALPHA, targets=("attn_out",)))
theirs = peft.get_peft_model(
RukhForCausalLM.from_decoder(toy()),
peft.LoraConfig(
r=R, lora_alpha=ALPHA, lora_dropout=0.0, bias="none", target_modules=["attn.proj"]
),
)
# `attn.proj` and not `q`/`v`: `peft` cannot express a per-slice adapter on a fused `qkv`, so over
# there `target_modules=["qkv"]` would be ONE A/B pair of 2304 rows against our one per slice.
# Different parameterisations; comparing them would measure that difference, not the code.
ours_by_layer = dict(lora_modules(ours))
with torch.no_grad():
for name, module in theirs.named_modules():
if "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 = torch.optim.SGD([p for p in ours.parameters() if p.requires_grad], lr=0.5)
yours = torch.optim.SGD([p for p in theirs.parameters() if p.requires_grad], lr=0.5)
print("4. step ours peft |delta|")
for step in range(STEPS):
mine.zero_grad()
a = ours(x, y)[1]
a.backward()
mine.step()
yours.zero_grad()
b = theirs(x, labels=y).loss
b.backward()
yours.step()
print(f" {step:>4d} {a.item():.8f} {b.item():.8f} {abs(a.item() - b.item()):.2e}")

labs/m4/lora_check.pylíneas 82-124 · p4

Sin el extra hf, el script no falla: se salta la cuarta y sale con código cero, porque un lab que exige un extra para imprimir tres comprobaciones que no lo necesitan es un lab que la gente deja de ejecutar. toy() se llama dos veces con la misma semilla, porque peft.get_peft_model modifica el modelo que recibe, y la A se copia de nuestro lado al suyo para comparar las matemáticas y no dos sorteos.

Terminal
uv sync --extra cu128 --extra hf --group dev
uv run pytest -m unit -q tests/unit/test_lora.py tests/unit/test_hf_wrapper.py
uv run python labs/m4/lora_check.py

Salida en la máquina de referencia (RTX 5090), el cuarto bloque del script. El script construye su decoder de juguete en CPU y no toca la GPU: en otra máquina las pérdidas pueden variar en los últimos decimales, pero la columna |delta| tiene que salir a cero igual:

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+00

Idénticas hasta el último bit, seis pasos seguidos.

Lab 6 · ¿Faltan partidas o no hay diferencia?

// Viene delección 7 · Diversidad y barridos: dos veredictos en vez de uno

Objetivo: no volver a pagar una noche de Stockfish sin saber antes si serviría de algo.

Dos intervalos solapados pueden significar cosas opuestas. La escalera mide una tasa de puntos, una proporción, y las proporciones tienen una fórmula para esto:

labs/m4/games_needed.py
"""How many games each pair of conditions would need before its intervals stop overlapping.
Lab 6 of M4. The sweep of this milestone leaves `1500 < 2000 < 2400` unproven, and there are two
very different reasons a comparison can come out unproven: too few games, or no difference to
find. Telling them apart is arithmetic, not opinion.
The ladder plays `n` games per condition and scores them, so the score is a proportion and its
standard error is `sqrt(p (1 - p) / n)`. Two 95 % intervals stop touching when the gap between
the scores is wider than `1.96 * (se1 + se2)`, which for two proportions near a half means
n > 3.84 / (delta p)^2
per condition. Put the measured gap in and the answer comes out in games. A pair that needs two
hundred is a pair worth re-running; a pair that needs twenty thousand is a pair where the honest
conclusion is that the difference is not there, and no amount of machine time will change it.
Run from the repository root, on the sweep's own file:
uv run python labs/m4/games_needed.py
uv run python labs/m4/games_needed.py --only 1500,2000,2400
``--only`` keeps just the conditions it names, in the order they are given, which is how the
acceptance criterion of this project is read: it asks about `<w1500>`, `<w2000>` and `<w2400>`
and about no other pair.
"""

labs/m4/games_needed.pylíneas 1-25 · p4

La derivación va entera en el docstring: n > 3,84 / (Δp)² es trivial de aplicar y fácil de aplicar mal, y quien la lea dentro de un año necesita saber que 3,84 es 1,96² y que vale para proporciones cerca de la mitad.

labs/m4/games_needed.py
import argparse
import json
import math
import sys
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
Z = 1.959963985 # two-sided 95 %
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results", default="artifacts/eval/medium-elo-elo-sweep/results.json")
parser.add_argument("--games", type=int, default=160, help="Games per condition already played.")
parser.add_argument("--only", default="", help="Comma-separated headers to keep, e.g. 1500,2000.")
args = parser.parse_args()
rows = json.loads(Path(args.results).read_text(encoding="utf-8"))["rows"]
if args.only:
wanted = [int(value) for value in args.only.split(",")]
by_header = {int(row["header_elo"]): row for row in rows}
missing = [elo for elo in wanted if elo not in by_header]
if missing:
raise SystemExit(f"{args.results} has no condition for {missing}")
rows = [by_header[elo] for elo in wanted]
if len(rows) < 2:
raise SystemExit(f"{args.results} has {len(rows)} condition(s); a comparison needs two")

labs/m4/games_needed.pylíneas 27-53 · p4

--only devuelve las filas en el orden en que se pidieron, y una condición que no está en el fichero es un SystemExit: el error más caro de este lab sería contestar sobre un par distinto del que se preguntó.

labs/m4/games_needed.py
def se(p: float, n: int) -> float:
return math.sqrt(max(p * (1.0 - p), 1e-12) / n)
def needed(p1: float, p2: float) -> float:
"""Games per condition for the two 95 % intervals to stop touching."""
gap = abs(p2 - p1)
if gap <= 0:
return math.inf
spread = math.sqrt(p1 * (1 - p1)) + math.sqrt(p2 * (1 - p2))
return (Z * spread / gap) ** 2

labs/m4/games_needed.pylíneas 56-66 · p4

needed devuelve math.inf cuando la diferencia es cero: dos condiciones que puntúan igual no necesitan «muchas» partidas, necesitan infinitas, y esa diferencia es la que el lab existe para enseñar.

labs/m4/games_needed.py
print(f"{args.results}: {len(rows)} conditions, {args.games} games each\n")
print(" pair score gap separated now games needed each")
for low, high in zip(rows, rows[1:], strict=False):
p1, p2 = low["score"], high["score"]
gap = p2 - p1
apart = (p1 + Z * se(p1, args.games)) < (p2 - Z * se(p2, args.games))
n = needed(p1, p2)
n_text = "never" if not math.isfinite(n) else f"{math.ceil(n):,}"
print(
f" <w{low['header_elo']}> -> <w{high['header_elo']}> "
f"{p1:.3f}->{p2:.3f} {gap:+.3f} {'yes' if apart else 'no ':<13} {n_text}"
)
ends = (rows[0], rows[-1])
p1, p2 = ends[0]["score"], ends[1]["score"]
apart = (p1 + Z * se(p1, args.games)) < (p2 - Z * se(p2, args.games))
print(
f"\n ends <w{ends[0]['header_elo']}> -> <w{ends[1]['header_elo']}>: "
f"{p1:.3f} -> {p2:.3f}, separated now: {'yes' if apart else 'no'}, "
f"needs {math.ceil(needed(p1, p2)):,} games each"
)
print(
"\na pair that needs a few hundred games is a measurement problem;\n"
"a pair that needs tens of thousands is a difference that is not there"
)

labs/m4/games_needed.pylíneas 69-93 · p4

La tabla dice por cada par si está separado ahora y cuántas partidas harían falta. El barrido que produce su entrada es la medición cara del módulo: seis condiciones a unos 26 minutos cada una, más el control sobre el modelo sin afinar (código en la lección 7).

Terminal
uv run rukh eval sweep --model checkpoints/medium-elo-<fecha>/step-3800.pt --elos 1200,1500,1800,2000,2100,2400 --config configs/eval/greedy-sweep.yaml --stage medium-elo
uv run rukh eval sweep --model checkpoints/medium-v4-<fecha>/best.pt --elos 1200,2100 --config configs/eval/greedy-sweep.yaml --stage medium-v4
uv run python labs/m4/games_needed.py --only 1500,2000,2400

Salida en la máquina de referencia (RTX 5090), pidiéndole solo las condiciones que nombra el criterio:

<w1500> -> <w2000> 0.466->0.453 -0.013 no 24,420
<w2000> -> <w2400> 0.453->0.569 +0.116 no 284
  • Comprueba que el número de la última columna crece con el inverso del cuadrado de la diferencia: la mitad de diferencia son cuatro veces más partidas.
  • El mismo cálculo, al revés, dice cuánta diferencia detecta una tirada: con 160 partidas por condición no se ve nada por debajo de unos 110 puntos de Elo.

Lab 7 · Estilo con adaptadores

// Viene delección 3 · LoRA escrita a mano, lección 4 · Estilo y texto PGN: los dos cortes del corpus y lección 6 · Adaptadores y publicación: 1,6 MB que llegan al navegador

Objetivo: dos adaptadores que cambien algo que se vea, y medir qué cuesta. Los cortes están en la lección 4 y el exportador, en la 6.

Terminal
uv run rukh data style --config configs/data/pipeline-style.yaml
uv run rukh data tokenize --config configs/data/pipeline-style-e4.yaml --scheme uci --pack
uv run rukh data tokenize --config configs/data/pipeline-style-d4.yaml --scheme uci --pack
uv run rukh train --config configs/train/lora-e4.yaml
uv run rukh train --config configs/train/lora-d4.yaml

Cada adaptador son 1 500 pasos y unos siete minutos, y deja adapter.safetensors con adapter_config.json al lado de sus checkpoints fundidos. El cuarto script comprueba la afirmación central de LoRA con álgebra en vez de con una metáfora:

labs/m4/lora_spectrum.py
"""How many directions a rank-8 adapter really moves, measured with an SVD.
The figure of M4's LoRA section, and the check labs 4 and 5 lean on. "The correction has to pass
through eight dimensions" is easy to write and easy to believe without checking.
The check is one line of linear algebra: the singular values of
``delta W = (alpha / r) B A`` are the lengths of the directions that correction can push along, and
there are exactly ``r`` of them that are not zero. Next to the singular values of ``W`` itself --
seven hundred and sixty-eight of them, all non-zero -- the picture is the whole method.
Run from the repository root:
uv run python labs/m4/lora_spectrum.py --adapter checkpoints/lora-e4-20260920-160133
"""

labs/m4/lora_spectrum.pylíneas 1-13 · p4

Los valores singulares de (alpha/r)·B·A son r distintos de cero por construcción: si salieran nueve, lo entrenado no sería un adaptador de rango ocho.

labs/m4/lora_spectrum.py
import argparse
import json
import sys
from pathlib import Path
import torch
from rukh.models.lora import load_adapter, lora_modules
from rukh.train import load_model
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--ckpt", default="checkpoints/medium-v4-20260919-174623/best.pt", type=Path)
parser.add_argument("--adapter", default="checkpoints/lora-e4-20260920-160133", type=Path)
parser.add_argument("--layer", default=0, type=int, help="Block to read the matrices from.")
parser.add_argument("--keep", default=24, type=int, help="Singular values to write out.")
parser.add_argument("--out", default="artifacts/web/lora-spectrum.json", type=Path)
args = parser.parse_args()
model = load_model(args.ckpt)[0].eval()
cfg = load_adapter(model, args.adapter / "adapter.safetensors")
adapters = dict(lora_modules(model))
name = f"blocks.{args.layer}.attn.qkv"
adapter = adapters[name]
# The query slice of the fused projection: the first `d_model` output rows.
start, stop = adapter.slices[0]
with torch.no_grad():
base = adapter.base.weight[start:stop].float()
delta = (cfg.scale * (adapter.b[0].float() @ adapter.a[0].float())).float()
base_sv = torch.linalg.svdvals(base)
delta_sv = torch.linalg.svdvals(delta)

labs/m4/lora_spectrum.pylíneas 15-48 · p4

El script imprime el r y el alpha leídos del fichero, no los que uno cree que usó, y compara W[start:stop] con su propia corrección, la misma submatriz. Todo se sube a float() antes del SVD, porque en float16 saldrían ceros donde hay valores de 1e-7, justo el número que decide si el noveno valor singular es cero de verdad.

labs/m4/lora_spectrum.py
nonzero = int((delta_sv > delta_sv.max() * 1e-6).sum())
print(f"{name} query slice: W is {tuple(base.shape)}, delta W is {tuple(delta.shape)}")
print(f" r = {cfg.r}, alpha = {cfg.alpha}, scale = {cfg.scale}")
print(
f" non-zero singular values: W {int((base_sv > base_sv.max() * 1e-6).sum())}, "
f"delta W {nonzero}"
)
print(f" ||delta W||_F / ||W||_F = {torch.linalg.norm(delta) / torch.linalg.norm(base):.4f}")
print(" first singular values of delta W:", [round(float(v), 4) for v in delta_sv[: cfg.r + 2]])

labs/m4/lora_spectrum.pylíneas 50-58 · p4

El umbral de «distinto de cero» es relativo (> max · 1e-6), porque la escala de un adaptador entrenado no se conoce de antemano. Se imprimen r + 2 valores porque el interés está en el noveno.

labs/m4/lora_spectrum.py
payload = {
"adapter": args.adapter.name,
"module": name,
"r": cfg.r,
"alpha": cfg.alpha,
"scale": cfg.scale,
"shape": list(base.shape),
"nonzero_delta": nonzero,
"relative_norm": float(torch.linalg.norm(delta) / torch.linalg.norm(base)),
"base": [float(v) for v in base_sv[: args.keep]],
"delta": [float(v) for v in delta_sv[: args.keep]],
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(payload, indent=1) + "\n", encoding="utf-8", newline="\n")
print(f"\nwrote {args.out}")

labs/m4/lora_spectrum.pylíneas 60-74 · p4

Terminal
uv run python labs/m4/lora_spectrum.py --adapter checkpoints/lora-e4-<fecha>

Salida en la máquina de referencia (RTX 5090):

blocks.0.attn.qkv query slice: W is (768, 768), delta W is (768, 768)
r = 8, alpha = 16, scale = 2.0
non-zero singular values: W 768, delta W 8
||delta W||_F / ||W||_F = 0.0441
first singular values of delta W: [0.8338, 0.4277, 0.3592, 0.3211, 0.2811, 0.2302, 0.1991, 0.1774, 0.0, 0.0]

Ocho, y el noveno es 0.0 y no algo pequeño. La línea de ||delta W||_F es el cociente de sus normas de Frobenius (la raíz de la suma de los cuadradosNorma de FrobeniusEl tamaño de una matriz en un solo número: la raíz cuadrada de la suma de los cuadrados de todos sus elementos, como la longitud de un vector que tuviera todos sus números en fila. Sirve para decir cuánto pesa una matriz al lado de otra. En M4, ‖ΔW‖ / ‖W‖ compara la corrección de un adaptador con la matriz que corrige: sobre la consulta del primer bloque de medium, la de lora-e4 es el 4,41 %, y aun así cambia del todo la primera jugada.): la corrección pesa el 4,41 % de la matriz que corrige.

Terminal
uv run rukh export --ckpt checkpoints/medium-v4-<fecha>/best.pt --out artifacts/onnx/medium-lora --fp16 --int8 --check-parity --positions 1000 --adapter-inputs --adapter checkpoints/lora-e4-<fecha>

Unos quince minutos, y las tres comprobaciones del exportador adaptable salen en la consola: la paridad con los factores a cero, la diferencia entre los dos adaptadores y la paridad del adaptador cargado en PyTorch frente al grafo sobre mil posiciones.

Lab 8 · Qwen3 con QLoRA, por el mismo harness

// Viene delección 4 · Estilo y texto PGN: los dos cortes del corpus y lección 5 · QLoRA con `transformers`: el decoder entra en el ecosistema

Objetivo: que el modelo general y el propio se midan con el mismo metro, y que lo único distinto entre ellos sea la representación. El código está en las lecciones 4 y 5. Las mismas partidas, escritas como las leería un humano:

[WhiteElo "2015"] [BlackElo "2028"]
1. e4 c6 2. d4 d5 3. exd5 cxd5 4. Bd3 Nf6 5. h3 Nc6 6. c3 e5 ... 1-0
Terminal
uv run rukh data pgn-text --config configs/data/pipeline-pgn.yaml
uv run rukh train qwen --config configs/train/qwen3-pgn.yaml
uv run rukh eval qwen --adapter checkpoints/qwen3-pgn-qlora --config configs/eval/greedy.yaml --stage qwen3-pgn-qlora

El afinado descarga Qwen3-0.6B del Hub la primera vez (1,5 GB) y corre 1 500 pasos; la evaluación tarda unos 40 minutos, porque cada jugada es una generación de texto. Si bitsandbytes no carga, la ejecución cae a bf16 y lo escribe en su run.json.

Lab 9 · Publicar y encender el selector

// Viene delección 6 · Adaptadores y publicación: 1,6 MB que llegan al navegador, lección 7 · Diversidad y barridos: dos veredictos en vez de uno y lección 8 · El selector en la demo: seis condiciones y 1,6 MB

Objetivo: que lo medido salga del disco, y que la demo no prometa más de lo que se midió. El publicador está en la lección 6, rukh eval openings en la 7 y el selector en la 8. Son seis repos: los cinco artefactos entrenados del módulo y rukh-medium-lora, el grafo adaptable de la base sobre el que se montan los adaptadores. Cada publish lee la evaluación de su --stage, así que cada etapa va en el mismo orden: evaluar, exportar si es un modelo y publicar.

Los dos modelos afinados (unos 26 minutos cada evaluación y 12 cada exportación):

Terminal
uv run rukh eval --model checkpoints/medium-elo-<fecha>/step-3800.pt --config configs/eval/greedy.yaml --stage medium-elo
uv run rukh export --ckpt checkpoints/medium-elo-<fecha>/step-3800.pt --out artifacts/onnx/medium-elo --fp16 --int8 --check-parity
uv run rukh publish model --ckpt checkpoints/medium-elo-<fecha>/step-3800.pt --repo chorcat/rukh-medium-elo --stage medium-elo --onnx artifacts/onnx/medium-elo --dry-run
uv run rukh eval --model checkpoints/medium-masters-<fecha>/step-3800.pt --config configs/eval/greedy.yaml --stage medium-masters
uv run rukh export --ckpt checkpoints/medium-masters-<fecha>/step-3800.pt --out artifacts/onnx/medium-masters --fp16 --int8 --check-parity
uv run rukh publish model --ckpt checkpoints/medium-masters-<fecha>/step-3800.pt --repo chorcat/rukh-medium-masters --stage medium-masters --onnx artifacts/onnx/medium-masters --dry-run

El grafo adaptable ya lo exportaste en el Lab 7; se publica con la card de su base, que lee la evaluación de medium-v4:

Terminal
uv run rukh publish model --ckpt checkpoints/medium-v4-<fecha>/best.pt --repo chorcat/rukh-medium-lora --stage medium-v4-greedy --onnx artifacts/onnx/medium-lora --dry-run

Los dos adaptadores. El --effect no lo escribe ningún comando: se monta a mano con la salida de rukh eval openings (primera línea de cada pareja) y con la evaluación del adaptador (segunda línea), como cuenta la lección 6. Si solo quieres ver las cards, trae los dos ficheros ya montados en vez de escribirlos: git checkout p4 -- artifacts/publish/effects/.

Terminal
uv run rukh eval openings --model checkpoints/medium-v4-<fecha>/best.pt --against checkpoints/lora-e4-<fecha>/step-1500.pt
uv run rukh eval --model checkpoints/lora-e4-<fecha>/step-1500.pt --config configs/eval/greedy.yaml --stage lora-e4
uv run rukh publish adapter --run checkpoints/lora-e4-<fecha> --repo chorcat/rukh-lora-e4 --base chorcat/rukh-medium --effect artifacts/publish/effects/lora-e4.json --dry-run
uv run rukh eval openings --model checkpoints/medium-v4-<fecha>/best.pt --against checkpoints/lora-d4-<fecha>/step-1500.pt
uv run rukh eval --model checkpoints/lora-d4-<fecha>/step-1500.pt --config configs/eval/greedy.yaml --stage lora-d4
uv run rukh publish adapter --run checkpoints/lora-d4-<fecha> --repo chorcat/rukh-lora-d4 --base chorcat/rukh-medium --effect artifacts/publish/effects/lora-d4.json --dry-run

Y Qwen, cuya evaluación hiciste en el Lab 8:

Terminal
uv run rukh publish qwen --run checkpoints/qwen3-pgn-qlora --repo chorcat/rukh-qwen3-pgn-qlora --dry-run

Con --dry-run cada carpeta queda en artifacts/publish/ y la card se puede leer antes de subir nada. Para subir de verdad, quita --dry-run después de identificarte en el Hub una vez con uv run hf auth login, como en «Publicar el dataset» de M1: guarda el token en tu perfil y de ahí lo leen tanto rukh publish como db.py. Una variable de entorno HF_TOKEN hace lo mismo y, si existe, manda sobre el fichero; no hacen falta las dos. Para publicar en tu cuenta, cambia chorcat por tu usuario en cada --repo (un --repo con barra manda sobre el dueño por defecto) y, en --base, deja chorcat/rukh-medium o pon el tuyo si lo publicaste: lo explica la lección 6.

  • Abre la card de lora-e4 en seco y comprueba que tiene dos tablas: lo que cambió (59,64 % → 99,85 % de e2e4) y lo que costó (legalidad, top-1 y puzles en los dos modelos).

Este módulo deja una familia, y la demo es donde se nota. Exporta tu medium-elo y tu medium-masters y cárgalos uno tras otro en «Tu propio modelo», con tu base de M2 en medio: que el afinado cambia el repertorio y no la fuerza se ve en un par de minutos de partidas. Para probar el selector «Elo objetivo», carga chorcat/rukh-medium-elo desde el desplegable de etapa: solo se enciende con una etapa que el registro declara condicionada.

Qué has aprendido, cómo se mide

Primero los números, una fila por etapa, tal y como los cita este módulo (la fila de medium-elo es la condición <w1800> del barrido, que comparte suite con las otras cinco; su Elo canónico, 1558, sale de otra tirada y no se mezcla):

Etapa Elo (IC 95 %) legal top-1 puzles entropía 1.ª a <w1800>
medium-v4 (base) 1504 (1446–1558) 99,8 % 54,4 % 37,5 % 1,7695 bits
medium-elo (<w1800>) 1498 (1435–1552) 99,80 % 54,1 % 38,2 % 1,7408
medium-masters 1583 (1525–1641) — 54,9 % 38,0 % 1,8850
medium-v4 + lora-e4 no medido 99,80 % 54,7 % 37,8 % 0,0209
qwen3-pgn-qlora (0,6 B) < 807 (160 derrotas de 160) 62,50 % 12,5 % 0,9 % —

Ninguna columna de fuerza separa su intervalo del de la base; todas las de comportamiento se mueven. Eso es el módulo, y lo que te llevas sirve para afinar cualquier modelo:

  • El corpus antes que el modelo. Un token que nunca aparece en el entrenamiento no existe, y se descubre contando, sin cargar el modelo.
  • Mide el comportamiento sin muestreo cuando puedas. La entropía analítica de la primera jugada vio el eje ordenado donde el Elo no separaba las condiciones.
  • Un resultado negativo hay que ganárselo: partidas necesarias, ejecución de control y suelo de reproducibilidad, tres herramientas que sirven para cualquier evaluación.
  • LoRA cambia una decisión sin costar nada, y con los factores como entradas del grafo cambiar de estilo cuesta lo que pesa el estilo.
  • La representación es la mitad del resultado: cuando la salida tiene estructura, métela en el vocabulario o en la decodificación.

El aprendiz ha tenido sus clases particulares. Ahora juega como le pidas, de 1200 a 2400, con un repertorio que se estrecha o se ensancha según la orden; abre siempre igual si le pegas unas notas adhesivas de 1,6 MB, y se las quitas sin que el libro se entere. Lo que no ha aprendido es a jugar mejor: todas sus clases han sido imitar, y nadie le ha dicho todavía qué jugada es buena y cuál no.

Eso es M5, donde el modelo deja de imitar y empieza a preferir: un entrenador que le dice, de dos jugadas, cuál es mejor. Es la misma etapa que convierte un LLM que imita texto en uno alineado con lo que se le pide.

La cheatsheet del módulo, con su pregunta y su respuesta corta, está justo debajo.

// el recorrido · lo conseguido

  1. M0 El tallerEntorno reproducible(hecho)
  2. M1 Datos y tokensDatos y tokenización(hecho)
  3. M2 El decoderPreentrenar un GPT(hecho)
  4. M3 El encoderEmbeddings y clasificar(hecho)
  5. M4 Fine-tuningSFT y LoRA(hecho)
  6. M5 AlineamientoPreferencias: DPO, GRPO(por delante)
  7. M6 Evaluar y publicarMedir y desplegar(por delante)
  8. FASE 2 El entrenadorAgentes, RAG, MCP

lo que tienes ahora

Un selector de Elo que cambia cómo juega, un adaptador de 1,6 MB que se cambia en caliente, QLoRA sobre Qwen3 y la prueba de que afinar cambia el estilo pero no la fuerza.

siguiente parada · M5 Alineamiento

Llega un entrenador que le dice cuál de dos jugadas prefiere o le puntúa cada intento. El riesgo: que aprenda a agradar al juez en vez de jugar mejor.

// cheatsheet M4

Doce preguntas para llevarte

01¿Qué diferencia hay entre preentrenar y afinar?
Menos de la que sugiere el vocabulario. Es el mismo bucle, la misma pérdida y el mismo optimizador; lo único que cambia es de dónde salen los lotes y, normalmente, la tasa de aprendizaje. `medium-elo` se entrena con entropía cruzada sobre el siguiente token igual que `medium-v4`, con otros datos y con `lr` 1e-4 en vez de 4,5e-4. Las dos consecuencias prácticas son las que importan: el afinado hereda todo lo que el preentrenamiento aprendió y puede deshacer cosas que nadie le pidió deshacer (olvido catastrófico), y la pérdida de validación deja de ser el objetivo porque mide la distribución de la que te estás yendo. En Rukh el afinado por Elo subió la pérdida de validación de 1,3733 a 1,3987 y bajó el top-1 de 54,73 % a 54,66 %: eso es el precio, no el fallo.
02¿Por qué no funcionaba el condicionamiento por Elo y cómo se descubrió?
Porque la mitad baja del eje nunca se había entrenado. `configs/data/lichess-2025-01-02.yaml` fija `min_elo: 1800` aplicado a los dos jugadores, y ese es el filtro con el que se descargó todo el corpus del proyecto: los 5,9 M de partidas base, los 13,1 M de la Elite Database, los 19 M de `tokens-v4`. Así que los doce tokens por debajo de 1800 —`<w0600>` a `<w1700>`— nunca recibieron un gradiente y su embedding seguía en la inicialización. Pedir «juega como 1500» era enseñarle ruido. No se descubrió leyendo el modelo sino contando: el segundo token de cada partida empaquetada *es* el Elo de las blancas, así que un histograma de tres líneas sobre `tokens.npy` lo enseña. La regla que deja: antes de concluir que un mecanismo no funciona, comprueba cuántos ejemplos ha visto.
03¿Qué es LoRA y por qué su rango es a la vez su ventaja y su límite?
En vez de mover una matriz de pesos `W`, se aprende una corrección `B·A` de rango `r` y se suma. Sobre `medium`, una proyección de consulta es 768 × 768 = 589 824 números y su adaptador con `r = 8` son 12 288: el 2,08 %. Sobre consulta y valor en las dieciséis capas, 393 216 parámetros de 115 120 128 —el 0,34 %, 1,6 MB en `float32`— y los pesos base no se mueven, así que todos los adaptadores comparten una sola copia. El límite es el mismo hecho: todo lo que el adaptador aprenda tiene que pasar por ocho dimensiones por matriz. Sirve para inclinar un modelo hacia un estilo, un dominio o un formato; no sirve para enseñarle algo que no sabe.
04¿Por qué `B` empieza en cero y qué pasaría si no?
Porque así el modelo adaptado **es** el modelo base en el paso 0: `B·A = 0`, la salida es idéntica y no ha cambiado nada hasta que llega el primer gradiente. Cualquier otra inicialización movería los pesos antes de aprender nada y toda comparación contra la base empezaría desde un modelo que ya es distinto —con lo que «lo que hizo el adaptador» incluiría el sorteo de la inicialización. `A` sí se sortea (Kaiming uniforme, como en el paper): si las dos fueran cero el gradiente del producto sería cero y el adaptador no arrancaría nunca.
05¿Cómo compruebas que la LoRA que escribiste es LoRA de verdad?
Poniéndola al lado de la implementación de referencia sobre los mismos pesos. Un `alpha` en el sitio equivocado, `A` y `B` intercambiadas o una inicialización distinta dan un modelo que entrena, converge y produce números creíbles, y ninguna da LoRA. En Rukh se montan dos modelos sobre los mismos pesos base —uno con `apply_lora`, otro con `peft.get_peft_model`—, se copia nuestra `A` en la suya para que la comparación sea de las matemáticas y no de dos sorteos, y los dos dan pasos de SGD sobre el mismo lote comparando las pérdidas **paso a paso** con tolerancia 1e-5. Detalle: la comparación se hace sobre `attn.proj` y no sobre `q`/`v`, porque `peft` no puede expresar lo mismo que nosotros sobre una `qkv` fusionada (con `target_modules=["qkv"]` da una sola pareja `A`/`B` de 2304 filas; nosotros damos una por rango).
06«Monótono» y «separado»: ¿por qué son dos afirmaciones y no una?
Porque una es barata y la otra es la que vale. Monótono quiere decir que las estimaciones puntuales suben con la condición, y con tres números y ruido eso sale por azar una vez de cada seis. Separado quiere decir que los intervalos de confianza consecutivos no se solapan, y eso ya no pasa por casualidad. El proyecto se quemó con esto: una prueba de 32 partidas dio 0,703 contra 0,609 a favor de `<w2600>` y pareció una victoria del condicionamiento; con 160 partidas se cayó (1058 frente a 1095, intervalos casi superpuestos) y hubo que retirar la conclusión. Por eso `rukh eval sweep` imprime los dos veredictos por separado y el criterio de `GOAL.md` se lee sobre el segundo. Medido en M4: **ninguna de las dos**. Las estimaciones no son monótonas (1425, 1549, 1498, 1538, 1606, 1644), y de los cinco pares contiguos solo uno está separado, y por los pelos: 1200→1500 (1479 frente a 1481). Ningún par de los que nombra el criterio lo está. Y aparece una tercera pregunta que solo existe cuando la segunda sale que no: ¿faltan partidas o no hay diferencia? Entre `<w1500>` y `<w2000>` harían falta 24 420 partidas por condición, así que no faltan partidas.
07Si el modelo condicionado a 1200 juega peor, ¿cómo sabes que juega peor y no que se ha roto?
Midiendo la legalidad sin máscara por condición. Hay dos maneras muy distintas de empeorar: elegir jugadas legales peores —que es lo que hace un humano débil— o proponer jugadas que no existen —que es lo que hace un modelo al que se le ha deshecho la comprensión del tablero. La segunda sería un dial roto con aspecto de funcionar, porque la máscara de legalidad de la demo esconde las jugadas ilegales y el jugador solo vería un rival más flojo. Son dos columnas distintas del barrido precisamente para que no se confundan. Y la respuesta medida es la tercera posibilidad, que era la que no estaba en la lista: **ni una cosa ni la otra**. A `<w1200>` el modelo afinado escribe **cero** jugadas ilegales de mil —el base escribía cuatro— y su Elo es el mismo que el del base. No se rompió y tampoco juega peor de forma medible: lo que cambió es el repertorio, 1,26 bits menos de entropía en la primera jugada.
08¿Qué demuestra hacer QLoRA de un modelo de 0,6 B en una GPU de 32 GB?
De la cuantización, nada: el modelo en bf16 son 1,2 GB y no hay memoria que ahorrar, que es la única ventaja de QLoRA. Lo honesto es intentarlo, medir el pico de memoria y decir cuál de las dos cosas pasó, en vez de fingir que se ha demostrado una técnica cuya razón de ser no aplica a esa escala. Lo que sí demuestra el experimento es otra cosa: si merecía la pena escribir un modelo propio. Y para que esa respuesta valga, las dos mitades tienen que diferenciarse en **una** sola cosa: mismas partidas, mismas métricas, mismo harness, y la representación como única variable.
09¿Por qué `best.pt` es una trampa en un afinado?
Porque significa «menor pérdida de validación», y en un afinado que cambia de corpus a propósito la pérdida de validación sube desde el principio. En `medium-elo` `best.pt` se quedó congelado en el **paso 200** —un modelo casi sin tocar— mientras el resultado real era el paso 3 800. Quien evaluara ese fichero después estaría midiendo los pesos equivocados y los números saldrían perfectamente plausibles, que es la clase peligrosa de error: no hay excepción que lo delate. El bucle ahora escribe un aviso cuando hay `init_from` y `best.pt` no es el último paso, nombrando el checkpoint que sí es el resultado.
10¿Por qué un adaptador se publica en su propio repositorio y no como un modelo?
Porque no es un modelo. Un repositorio de modelo de este proyecto lleva pesos, tres exportaciones ONNX, un vocabulario y una card cuyos números salen de `rukh eval`; un adaptador son dos matrices por proyección adaptada —1,6 MB frente a los 460 MB de los pesos en `float32`— y por sí solo no hace absolutamente nada: sin el checkpoint base exacto es un montón de números sin modelo que corregir. Por eso la card empieza por el modelo base y el comando que lo carga, y las métricas que publica son las que dicen qué **cambió** el adaptador (el reparto de primeras jugadas que movió) y qué **costó** (legalidad, top-1 y puzles medidos en la base y en la base con el adaptador, con la misma suite). Lo que no se ha medido, como el Elo del modelo adaptado, la card lo dice como «no medido». Publicar un adaptador con el Elo del modelo base sería publicar el número de otro.
11¿Cómo se cambia de estilo en el navegador sin descargar otro modelo?
Sacando el adaptador de los pesos y metiéndolo en el grafo. Fundir una LoRA y exportar el ONNX cuesta 231 MB por estilo en `medium`, o sea 462 MB por dos estilos para mover 1,6 MB de corrección: tira por tierra lo único que compra el método. La alternativa es exportar el decoder con `A` y `B` como **entradas** del grafo, apiladas por capas en dos tensores `(capas, rangos, r, d)` y `(capas, rangos, d, r)`, y que el navegador suba 1,6 MB por cada cambio de estilo. Tres propiedades lo hacen honesto y las tres se prueban: con ceros el grafo **es** el modelo base exactamente —por eso el fichero adaptable sustituye al ordinario en vez de sumarse a él, y «sin estilo» no es otro modelo—; con otro adaptador la respuesta cambia sin tocar el fichero; y la paridad contra PyTorch con el mismo adaptador cargado se mide sobre posiciones reales. El factor `alpha/r` vive en el **fichero**, no en el grafo, de modo que un adaptador de cualquier rango es correcto sin que nadie tenga que cuadrar dos números a mano. Lo que no admite es un adaptador cuyos rangos no midan todos lo mismo: los factores viajan como un tensor cada uno, así que entra `q`, `k` y `v` sobre el `qkv` fusionado y no entra el MLP, cuyo `fc` es cuatro veces más ancho.
12¿Qué cambia un afinado y qué no?
Cambia el **comportamiento** y no cambia la **competencia**, y M4 lo mide cinco veces por caminos distintos. Del lado del comportamiento: un adaptador de 1,6 MB —el 0,34 % del modelo— sube `1. e4` del 59,64 % al **99,85 %** sin costar una décima de legalidad, de top-1 ni de puzles; y la cabecera de Elo ordena el repertorio en las **seis** condiciones (entropía de la primera jugada de 1,596 a 1,992 bits) sin jugar una sola partida. Del lado de la competencia: ni el afinado condicionado (1558) ni el de maestros (1583) separan su intervalo del modelo del que salieron (1504), los puzles son planos en todas las condiciones (37,0 – 38,2 %) y el modelo **sin** afinar recorre 161 Elo por el mismo eje usando cabeceras que nunca entrenó. El mecanismo explica las dos mitades: predecir el siguiente token sobre partidas humanas enseña **qué se juega** a cada nivel y nunca premia **calcular mejor**. Mover la competencia necesita otra herramienta —recompensas, DPO, GRPO— y ese es M5.
Todas las cheatsheets, imprimibles →