// 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.
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
| Lab | Ruta | Reloj | Deja | Atajo |
|---|---|---|---|---|
| Lab 1Mirar el corpus antes de culpar al modelo | solo observar | — | el histograma que explica el agujero de Elo | no hay |
| Lab 2Descargar el tramo bajo que faltaba | cuesta máquina | ~10 min de red + ~5 de CPU; lo que cuesta es la descarga, la GPU no se usa | data/uci-low/, 2,3 M partidas | no hay |
| Lab 3El corpus plano y el afinado condicionado | cuesta máquina | ~15 min de datos + ~20 de afinado | checkpoints/medium-elo-<fecha>/step-3800.pt | rukh pull medium-elo |
| Lab 4LoRA escrita a mano | imprescindible | segundos, es un test | tests/unit/test_lora.py en verde | no hay |
| Lab 5La envoltura HF y la prueba contra peft | imprescindible | segundos | la 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 control | artifacts/eval/medium-elo-elo-sweep/ y su control | rukh pull --module m4 trae los cinco resultados ya medidos |
| Lab 7Estilo con adaptadores | cuesta máquina | ~5 min de rebanadas, 7 por adaptador, 15 el grafo adaptable | checkpoints/lora-{e4,d4}-<fecha>/adapter.safetensors, 1,6 MB | rukh pull lora-e4 y rukh pull lora-d4 |
| Lab 8Qwen3 con QLoRA, por el mismo harness | cuesta máquina | ~10 min de PGN, el afinado, ~40 de evaluación | checkpoints/qwen3-pgn-qlora/ | rukh pull qwen3-pgn-qlora |
| Lab 9Publicar y encender el selector de la demo | cuesta máquina | ~26 min por evaluación + 12 por exportación, seis repos | seis carpetas en artifacts/publish/ (o seis repos del Hub) y el selector de la demo | no 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.
"""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 ofevery packed game *is* White's Elo header, so a histogram of `tokens[starts + 1]` says exactly howmuch 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>` andhas nothing below it: twelve header tokens that exist in the vocabulary, have a row in theembedding table, and never received a gradient."""import argparseimport collectionsimport sysfrom 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")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.
# 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")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 corpusCero 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 es1800, 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.
uv run rukh data fetch --config configs/data/lichess-low.yamluv run rukh data uci --config configs/data/pipeline-low.yamlLa 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_eloy 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:
uv run rukh data elo-bins --config configs/data/pipeline-low.yamluv run rukh data tokenize --config configs/data/pipeline-low.yaml --scheme uci --packuv run rukh train --config configs/train/medium-elo.yamlSalida 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.
uv run rukh data tokenize --config configs/data/pipeline-masters.yaml --scheme uci --packuv run rukh train --config configs/train/medium-masters.yaml- Un test del bucle comprueba que con
lr = 0el afinado es el checkpoint del que partió: la forma más barata de demostrar queinit_fromcarga 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.
"""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 scriptruns the first three and says so."""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.
import sys
import torch
from rukh.models import DecoderConfig, MoveDecoder, presetfrom 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))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.
# --- 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_modelby_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_modelprint(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")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:
# --- 4. the same update rule as `peft` ---------------------------------------try: import peft
from rukh.models.hf import RukhForCausalLMexcept 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}")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.
uv sync --extra cu128 --extra hf --group devuv run pytest -m unit -q tests/unit/test_lora.py tests/unit/test_hf_wrapper.pyuv run python labs/m4/lora_check.pySalida 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+00Idé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:
"""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 twovery different reasons a comparison can come out unproven: too few games, or no difference tofind. Telling them apart is arithmetic, not opinion.
The ladder plays `n` games per condition and scores them, so the score is a proportion and itsstandard error is `sqrt(p (1 - p) / n)`. Two 95 % intervals stop touching when the gap betweenthe 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 twohundred is a pair worth re-running; a pair that needs twenty thousand is a pair where the honestconclusion 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 theacceptance criterion of this project is read: it asks about `<w1500>`, `<w2000>` and `<w2400>`and about no other pair."""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.
import argparseimport jsonimport mathimport sysfrom 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")--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ó.
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) ** 2needed 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.
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")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).
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-elouv run rukh eval sweep --model checkpoints/medium-v4-<fecha>/best.pt --elos 1200,2100 --config configs/eval/greedy-sweep.yaml --stage medium-v4uv run python labs/m4/games_needed.py --only 1500,2000,2400Salida 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.
uv run rukh data style --config configs/data/pipeline-style.yamluv run rukh data tokenize --config configs/data/pipeline-style-e4.yaml --scheme uci --packuv run rukh data tokenize --config configs/data/pipeline-style-d4.yaml --scheme uci --packuv run rukh train --config configs/train/lora-e4.yamluv run rukh train --config configs/train/lora-d4.yamlCada 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:
"""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 passthrough 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, andthere 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"""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.
import argparseimport jsonimport sysfrom pathlib import Path
import torch
from rukh.models.lora import load_adapter, lora_modulesfrom 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)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.
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]])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.
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}")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.
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-0uv run rukh data pgn-text --config configs/data/pipeline-pgn.yamluv 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 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):
uv run rukh eval --model checkpoints/medium-elo-<fecha>/step-3800.pt --config configs/eval/greedy.yaml --stage medium-elouv run rukh export --ckpt checkpoints/medium-elo-<fecha>/step-3800.pt --out artifacts/onnx/medium-elo --fp16 --int8 --check-parityuv 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-runuv run rukh eval --model checkpoints/medium-masters-<fecha>/step-3800.pt --config configs/eval/greedy.yaml --stage medium-mastersuv run rukh export --ckpt checkpoints/medium-masters-<fecha>/step-3800.pt --out artifacts/onnx/medium-masters --fp16 --int8 --check-parityuv 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-runEl 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:
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-runLos 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/.
uv run rukh eval openings --model checkpoints/medium-v4-<fecha>/best.pt --against checkpoints/lora-e4-<fecha>/step-1500.ptuv run rukh eval --model checkpoints/lora-e4-<fecha>/step-1500.pt --config configs/eval/greedy.yaml --stage lora-e4uv 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-runuv run rukh eval openings --model checkpoints/medium-v4-<fecha>/best.pt --against checkpoints/lora-d4-<fecha>/step-1500.ptuv run rukh eval --model checkpoints/lora-d4-<fecha>/step-1500.pt --config configs/eval/greedy.yaml --stage lora-d4uv 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-runY Qwen, cuya evaluación hiciste en el Lab 8:
uv run rukh publish qwen --run checkpoints/qwen3-pgn-qlora --repo chorcat/rukh-qwen3-pgn-qlora --dry-runCon --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-e4en seco y comprueba que tiene dos tablas: lo que cambió (59,64 % → 99,85 % dee2e4) 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
- M0 El tallerEntorno reproducible(hecho)
- M1 Datos y tokensDatos y tokenización(hecho)
- M2 El decoderPreentrenar un GPT(hecho)
- M3 El encoderEmbeddings y clasificar(hecho)
- M4 Fine-tuningSFT y LoRA(hecho)
- M5 AlineamientoPreferencias: DPO, GRPO(por delante)
- M6 Evaluar y publicarMedir y desplegar(por delante)
- 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.