// M4 · lección 03
Fine-tuning: los labs
Nueve labs con sus comandos: mirar el corpus antes de culpar al modelo, descargar la mitad que faltaba, el corpus plano y el afinado condicionado, LoRA a mano, la envoltura HF y la prueba contra peft, cuántas partidas harían falta, estilo con adaptadores, Qwen3 con QLoRA y publicar.
Parte 3 de 3 del módulo «Fine-tuning e instrucción». Viene de «Fine-tuning: cómo se mide y lo que salió» y cierra el módulo.
Labs
Nueve laboratorios. Los dos primeros son de datos y no tocan el modelo; es donde estaba el fallo y es donde hay que empezar.
Lab 1 · Mirar el corpus antes de culpar al modelo
Objetivo: encontrar el agujero con una consulta, no con una intuición.
El punto de partida es una afirmación que parecía cerrada: «el condicionamiento por Elo abre el eje pero no da fuerza». Antes de aceptarla, cuenta cuántas veces ha visto el modelo cada token de cabecera. El flujo empaquetado lo pone fácil, porque el segundo token de cada partida es el Elo de las blancas:
import collectionsimport numpy as npfrom rukh.tokenize.uci_vocab import UciTokenizer
tok = UciTokenizer()tokens = np.load("data/tokens-v4/uci/train/tokens.npy", mmap_mode="r")starts = np.load("data/tokens-v4/uci/train/starts.npy")counts = collections.Counter(tokens[starts + 1].tolist())for token_id, n in sorted(counts.items()): print(f"{tok.ids[token_id]:>9s} {n:>9,d}")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. No hay una sola partida por debajo de 1800 en mil seiscientos ochenta millones de
tokens. Los doce tokens anteriores existen en el vocabulario, tienen su fila en la tabla de
embeddings, y esa fila es la que dejó nn.init.normal_(std=0.02).
De paso el histograma enseña otra cosa que no se buscaba: el corpus está dominado por <w2500>,
con 5 969 302 partidas, casi un tercio del total. Es la Elite Database, que entró entera en P2
y arrastra la distribución hacia arriba. Un corpus no es plano porque nadie lo aplane.
- Comprueba también el manifiesto del conjunto balanceado de P1
(
data/elo-bins/manifest.json): su bin más bajo es1800, que es la misma frase dicha desde otro sitio. - Y mira el filtro:
configs/data/lichess-2025-01-02.yaml,min_elo: 1800, aplicado a los dos jugadores.
Lab 2 · Descargar la mitad que faltaba
Objetivo: un segundo corpus que se diferencie del primero solo en el rango de fuerza, y un descargador que sobreviva al anfitrión.
Primero el filtro. FetchConfig gana un max_elo y el constructor de SQL lo aplica a los dos
jugadores, por la misma razón que min_elo: un 1200 emparejado con un 2400 no es una partida de
1200, es un desajuste.
if cfg.max_elo is not None: conditions.insert(1, f"WhiteElo <= {cfg.max_elo} AND BlackElo <= {cfg.max_elo}")- Test: con
max_eloaparecen las dos condiciones; sin él, ninguna; un techo por debajo del suelo es un error de configuración. - Test sobre las dos configuraciones que se envían:
low.max_elo + 1 == high.min_elo, y todo lo demás idéntico. Las dos mitades del eje tienen que embaldosar el rango sin solaparse ni dejar hueco, y eso es una propiedad que se puede comprobar en CI sin red.
Después, el descargador. La versión de P1 leía los parquet remotos con DuckDB sobre hf:// y dejó
de funcionar: miles de peticiones de rango contra un anfitrión que contesta 429. Lo que se hace
ahora es bajar cada shard entero con huggingface_hub —que cachea, reanuda y espera bien—,
filtrarlo en local y borrarlo.
- El filtro se construye una vez (
where_clause) y se usa en dos sitios: la consulta que imprime el ensayo y cada shard descargado. Lo documentado es lo que corre. limitpara la descarga, no solo el resultado: con 505 547 partidas útiles en el primer shard, 1,2 millones salen de tres en vez de setenta y dos.- Un mes ya descargado no se vuelve a descargar, y un mes interrumpido reanuda por el shard en el que se quedó. Los dos casos tienen test.
- Un fichero de cero bytes —lo que deja un
COPYque murió a medias— no cuenta como mes descargado. Y un filtro que no deja pasar nada es un error, no un parquet vacío: un corpus de cero filas aguas abajo parece un corpus.
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 —el tope de 1,2 millones de partidas se llena antes de los
setenta y dos—, unos diez minutos, y deja data/raw-low/; la conversión, cinco minutos más, deja
data/uci-low/ con 1 147 074 partidas de enero y 1 145 401 de febrero. La configuración escribe en
carpetas propias a propósito: data/uci sigue significando «lo que entrenó los modelos publicados».
Lab 3 · El corpus plano y el afinado condicionado
Objetivo: un corpus cuya forma sea el experimento, y un afinado que no herede el calendario del preentrenamiento.
EloBinsConfig pasa de un directorio a una lista de fuentes, y de bins de 100 a un ancho de banda
configurable con extremos. La lista es lo que permite que las dos mitades del eje vivan aparte en
disco y se junten solo aquí, en el paso cuyo trabajo es enseñar la cabecera.
elo_bins: sources: [data/uci-low, data/uci] bin_width: 200 min_bin: 1000 max_bin: 2600 n_per_bin: 150000- Test: dos corpus se convierten en un eje; el muestreo sale plano por muy sesgada que esté la fuente (quince partidas de 1200 contra cinco de 2000 dan cinco y cinco); las bandas fuera del rango se descartan; el ancho de banda aparece en la fórmula que publica el manifiesto.
El muestreo entero es una consulta, y las tres partes que la componen son las tres decisiones:
def bin_expression(bin_width: int) -> str: """`bin = media(white_elo, black_elo) // ancho * ancho`.""" return f"((white_elo + black_elo) // 2) // {bin_width} * {bin_width}"
def sample_bins(sources, target, n_per_bin, seed, bin_width=100, min_bin=None, max_bin=None): """Quedarse con `n_per_bin` partidas de cada banda, elegidas por un hash.""" files = ", ".join(f"'{p.as_posix()}'" for p in sources) con.execute( f""" COPY ( SELECT * EXCLUDE (rk) FROM ( SELECT *, -- La semilla desplaza el **hash**, no el id: `hash(game_id + seed)` -- desbordaba BIGINT con los ids del extremo alto del rango. row_number() OVER (PARTITION BY bin ORDER BY hash(game_id) + {seed}) AS rk FROM ( SELECT *, {bin_expression(bin_width)} AS bin FROM read_parquet([{files}], hive_partitioning = false, union_by_name = true) ) WHERE {band_filter} ) WHERE rk <= {int(n_per_bin)} ORDER BY bin, rk ) TO '{target.as_posix()}' (FORMAT PARQUET, COMPRESSION ZSTD) """ ) rows = con.execute( f"SELECT bin, count(*) FROM read_parquet('{target.as_posix()}') GROUP BY bin ORDER BY bin" ).fetchall() # Contado de lo que se escribió, no de lo que se pidió: es lo que permite que el manifiesto # diga que una banda se quedó corta en vez de dar a entender que todas están llenas. return {str(int(b)): int(n) for b, n in rows}El // ancho * ancho es truncar, no redondear: la misma convención que elo_bin en el
tokenizador, así que una media de 1899 cae en la banda de 1800. Dos redondeos distintos entre el
corpus y el token de cabecera pondrían una partida en una banda y la etiquetarían con otra, y nada
lo diría.
read_parquet([...]) con una lista de ficheros es lo que permite que las dos mitades del eje
vivan en directorios separados y se junten solo aquí, en el único paso cuyo trabajo es enseñar qué
significa una cabecera. Y union_by_name = true está por si los dos corpus se convirtieron con
versiones distintas del paso uci y el orden de las columnas no coincide: unir por posición en ese
caso da un parquet en el que el Elo de las negras está en la columna del resultado.
Y el conteo final es lo que hace honesta la tabla de la parte anterior: 150 000 partidas en cada banda de 1000 a 2200, 146 232 en la de 2400 y 23 191 en la de 2600. El corpus queda plano donde puede y se estrecha donde se estrecha el mundo, y el manifiesto lo dice en vez de rellenarlo.
Y el afinado necesita algo que el bucle no tenía: empezar desde unos pesos sin heredar el estado
del optimizador. --resume existe para continuar una corrida interrumpida y restaura los momentos
de Adam, el contador de pasos, el RNG y hasta el run de MLflow; un afinado quiere exactamente lo
contrario, porque es una corrida nueva, con sus datos, su tasa y su calendario, y los momentos
de un coseno de 48 000 pasos ya decaído pelearían contra el warmup que empieza ahora. --resume es
volver del café: la mesa está como la dejaste, con las notas de Adam, el contador de pasos y el run
abierto. init_from es empezar en otra empresa: te llevas lo que sabes y nada más.
def load_init_weights(init_from: str | None, model: nn.Module, where: torch.device) -> str | None: """Carga **solo los pesos** de `init_from`: donde empieza un afinado.
Del checkpoint se lee `model_state` y nada más. El optimizador, el contador de pasos y el run de MLflow se ignoran a propósito: esto es donde empieza un afinado, no donde se quedó una corrida. """ if init_from is None: return None path = resolve_run(paths.resolve(init_from)) if not path.is_file(): raise FileNotFoundError(f"checkpoint de init_from no encontrado: {path}") payload = load_checkpoint(path, map_location=where) load_state(model, payload["model_state"]) log.info("inicializado desde %s (paso %s), optimizador nuevo", path, payload.get("step", "?")) return path.as_posix()model = MoveDecoder(model_cfg).to(where)if resume is not None and cfg.init_from is not None: raise ValueError("--resume continúa una corrida; init_from empieza una: elige")# Los pesos se cargan **antes** de poner los adaptadores, porque el checkpoint conoce los nombres# de módulo de un decoder normal y envolver los renombra.initialised_from = load_init_weights(cfg.init_from, model, where)if cfg.lora is not None: if resume is not None: raise ValueError( "--resume no puede continuar una corrida de LoRA: sus checkpoints llevan los pesos " "fundidos, que ya no dicen dónde acababa el adaptador. Vuelve a empezar con init_from." ) apply_lora(model, cfg.lora)El orden de esas tres cosas —cargar, comprobar la contradicción, envolver— no es casual, y las dos excepciones están ahí porque las dos combinaciones parecen razonables hasta que se piensan.
- Tests:
init_fromtoma los pesos y deja el optimizador a cero, con su propia carpeta, su propio contador y su propio run; conlr = 0el afinado es el checkpoint del que partió;init_fromy--resumea la vez son una contradicción y se rechazan.
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, porque las dos últimas no tienen con qué llenar la cuota y el manifiesto
lo dice en vez de rellenarlo. El afinado son 3 800 pasos, 19,5 minutos, y termina en
checkpoints/medium-elo-*/step-3800.pt; best.pt se queda en el paso 200 y el bucle avisa de
por qué (la parte anterior lo explica). El afinado de maestros es el mismo comando con
pipeline-masters.yaml para tokenizar la Elite y medium-masters.yaml para entrenar: 20,7
minutos.
Lab 4 · LoRA escrita a mano
Objetivo: un adaptador que sea LoRA de verdad, y las pruebas que lo demuestran.
class LoRALinear(nn.Module): """Un `nn.Linear` congelado más una corrección de rango bajo por cada rodaja adaptada."""
def __init__(self, base: nn.Linear, r: int, alpha: int, dropout: float, slices) -> None: super().__init__() if not slices: raise ValueError("un LoRALinear necesita al menos una rodaja de salida") self.base = base self.base.weight.requires_grad_(False) if self.base.bias is not None: self.base.bias.requires_grad_(False) self.r = r self.scale = alpha / r self.slices = tuple(slices) self.drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity() # Se construyen **donde ya vive el peso que corrigen**. `apply_lora` corre *después* de # mover el modelo al dispositivo (el bucle carga el checkpoint primero, y cargar necesita # los nombres de módulo de un decoder normal), así que un factor creado en CPU por defecto # se encontraría una activación de CUDA en la primera pasada. Todos los tests de este # fichero corren en CPU, que es donde el fallo es invisible. where = {"device": base.weight.device, "dtype": base.weight.dtype} self.a = nn.ParameterList( nn.Parameter(torch.empty(r, base.in_features, **where)) for _ in range(len(self.slices)) ) # `B` arranca en **cero exacto**: el modelo adaptado *es* el modelo base en el paso 0. self.b = nn.ParameterList( nn.Parameter(torch.zeros(stop - start, r, **where)) for start, stop in self.slices ) for a in self.a: nn.init.kaiming_uniform_(a, a=math.sqrt(5))
def forward(self, x): out = self.base(x) delta = out.new_zeros(out.shape) for (start, stop), a, b in zip(self.slices, self.a, self.b, strict=True): delta[..., start:stop] = F.linear(F.linear(self.drop(x), a), b) return out + self.scale * delta
@torch.no_grad() def merged_weight(self): """`W + (alpha / r) B A`, cada corrección escrita en su propia rodaja de salida.""" weight = self.base.weight.detach().clone() for (start, stop), a, b in zip(self.slices, self.a, self.b, strict=True): weight[start:stop] += self.scale * (b @ a).to(weight.dtype) return weightY el que pone los adaptadores, que es donde se decide qué no se entrena:
def apply_lora(model: nn.Module, cfg: LoraConfig) -> int: """Congela `model`, envuelve las matrices configuradas y devuelve los parámetros entrenables.
Todo lo que está fuera de los adaptadores se congela, **incluidos los embeddings y la norma final**: una corrida de LoRA que además entrene la tabla de embeddings no es una corrida de LoRA, y el número que devuelve esta función dejaría de significar lo que dice. """ cfg.check() for param in model.parameters(): param.requires_grad_(False) plan = _target_slices(cfg, model.cfg.d_model, model.cfg.ff) for block in model.blocks: for module_name, slices in plan.items(): base = _get_module(block, module_name) if not isinstance(base, nn.Linear): raise TypeError(f"{module_name} es un {type(base).__name__}, no nn.Linear") _set_module(block, module_name, LoRALinear(base, cfg.r, cfg.alpha, cfg.dropout, slices)) return sum(p.numel() for p in model.parameters() if p.requires_grad)
def _target_slices(cfg: LoraConfig, d_model: int, ff: int): """Agrupa los objetivos por el módulo en el que viven, como rangos de salida suyos.""" by_module: dict[str, list[tuple[int, int]]] = {} for name in cfg.targets: module_name, lo, hi = TARGETS[name] width = ff if module_name == "mlp.fc" else d_model by_module.setdefault(module_name, []).append((lo * width, hi * width)) return {name: tuple(sorted(ranges)) for name, ranges in by_module.items()}Los tests son la parte interesante, porque cada uno comprueba una afirmación distinta del método:
- Un adaptador sin entrenar es la identidad.
Bempieza en cero, así que aplicar LoRA no cambia ni un logit hasta que llega un gradiente. - Solo se entrenan los factores. El recuento que devuelve
apply_loracoincide con la suma de los parámetros conrequires_grad, y el conjunto de esos parámetros es exactamente el de lasAy lasB. - El recuento es la fórmula de la card.
n_capas × n_objetivos × 2 × r × d_model, sin sorpresas. - La proyección fusionada da a cada objetivo su propio subespacio, y la franja de la clave no se mueve cuando solo se adaptan consulta y valor.
- Fundir reproduce exactamente el modelo adaptado.
W + BAplegado en el peso da los mismos logits que el envoltorio calculándolo. Es la propiedad que permite publicar un adaptador de 1,6 MB y aun así exportarlo, cuantizarlo y servirlo como un modelo cualquiera. - Un modelo fundido tiene el state dict de un decoder normal, sin
.base.weightni.a.0. - El adaptador va y vuelve por safetensors y reproduce los logits en un modelo limpio.
- Los gradientes llegan a los factores y a nada más: la tabla de embeddings sale sin
grad.
Hay un detalle del guardado que merece su propia función. merge_lora muta, que es lo que
quiere una exportación y lo que no quiere un bucle de entrenamiento: un checkpoint escrito a mitad
de corrida no puede dejar el modelo incapaz de dar otro paso. Por eso existe merged_state_dict,
que hace el plegado sobre una copia de los números y deja los envoltorios en su sitio. Lo que sale
tiene los nombres de módulo de un MoveDecoder normal, que es lo que esperan el cargador, el
exportador y el publicador.
@torch.no_grad()def merge_lora(model: nn.Module) -> int: """Pliega cada adaptador en el peso que corrige y devuelve el `nn.Linear` a su sitio.
Después de esto el modelo es indistinguible de uno afinado entero: mismos nombres de módulo, mismo state dict, mismo grafo ONNX. Esa es la propiedad que permite publicar un adaptador de 1,6 MB y aun así exportarlo, cuantizarlo y servirlo como cualquier otro modelo. """ merged = 0 for name, adapter in list(lora_modules(model)): base = adapter.base base.weight.copy_(adapter.merged_weight()) base.weight.requires_grad_(True) _set_module(model, name, base) merged += 1 return merged
@torch.no_grad()def merged_state_dict(model: nn.Module) -> dict[str, Tensor]: """El state dict que el modelo *tendría* si se plegaran los adaptadores, sin plegarlos.""" adapters = dict(lora_modules(model)) merged: dict[str, Tensor] = {} for name, tensor in model.state_dict().items(): owner, _, leaf = name.rpartition(".") base_owner, _, base_leaf = owner.rpartition(".") if base_leaf == "base" and base_owner in adapters: value = adapters[base_owner].merged_weight() if leaf == "weight" else tensor merged[f"{base_owner}.{leaf}"] = value.detach().cpu() continue if any(name.startswith(f"{p}.{f}.") for p in adapters for f in ("a", "b")): continue # los factores del adaptador viven en su propio fichero, no aquí merged[name] = tensor.detach().cpu() return mergeduv run pytest -m unit -q tests/unit/test_lora.pyLos ocho tests de arriba son ese fichero. Corren en CPU sobre un decoder de juguete en unos segundos, y son la definición operativa de «esto es LoRA»: si uno falla, lo que has escrito es otra cosa.
Lab 5 · La envoltura HF y la prueba contra peft
Objetivo: que el decoder entre en el ecosistema sin dejar de ser el decoder, y usarlo para comprobar el laboratorio anterior.
class RukhForCausalLM(PreTrainedModel, GenerationMixin): config_class = RukhConfig base_model_prefix = "decoder" _tied_weights_keys = {"decoder.lm_head.weight": "decoder.tokens.weight"}
def __init__(self, config): super().__init__(config) self.decoder = MoveDecoder(config.decoder_config()) self.post_init()- Tests de fidelidad: los mismos logits que
MoveDecoder; los pesos se comparten, no se copian; la pérdida es la del decoder; las etiquetas no se vuelven a desplazar; la config va y vuelve por disco. - Test de que la config no se confunde con un par encoder-decoder:
GenerationConfigse construye sin explotar. - Y el que cierra el Lab 4:
peftcuenta los mismos parámetros entrenables, y con la mismaAcopiada de un lado a otro los dos optimizan el mismo número paso a paso.
Tres cosas que hay que saber para que save_pretrained funcione con este modelo, y que no salen en
ningún tutorial: declarar _tied_weights_keys como un diccionario {copia: origen}; llamar a
post_init() al final del constructor (sin él no existe all_tied_weights_keys y cualquier guardado
falla con un AttributeError sobre un atributo que uno no ha escrito); y no llamar decoder a un
método de la config.
uv sync --extra cu128 --extra hf --group devuv run pytest -m unit -q tests/unit/test_hf_wrapper.pyuv run python labs/m4/lora_check.pypeft y transformers viven en el extra hf, que el entorno base no instala: el decoder no los
necesita para nada salvo para esta comparación. lora_check.py imprime las cuatro comprobaciones
de la parte anterior, y la cuarta es la tabla en la que los dos optimizadores dan el mismo número
paso a paso con |delta| = 0.00e+00.
Lab 6 · ¿Faltan partidas o no hay diferencia?
Objetivo: no volver a pagar una noche de Stockfish sin saber antes si serviría de algo.
Dos intervalos solapados se leen igual en el informe y pueden significar cosas opuestas. La escalera no mide Elo directamente: mide una tasa de puntos —victorias más medio empate, entre partidas— y el Elo es una transformación de ella. Una tasa es una proporción, y las proporciones tienen una fórmula para esto:
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) ** 2El barrido que produce artifacts/eval/medium-elo-elo-sweep/results.json es la medición cara del
módulo: seis condiciones a unos
26 minutos cada una, y después el control sobre el modelo sin afinar en los dos extremos del
eje, que es lo que impide cantar victoria (la parte anterior cuenta qué contestó).
uv run rukh eval sweep --model checkpoints/medium-elo/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/best.pt --elos 1200,2100 --config configs/eval/greedy-sweep.yaml --stage medium-v4Sobre el barrido de este hito, pidiéndole solo las condiciones que nombra el criterio:
uv run python labs/m4/games_needed.py --only 1500,2000,2400
<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 baja como el cuadrado de la diferencia: la mitad de diferencia son cuatro veces más partidas. Por eso una comparación floja es tan cara.
- Compáralo con las 160 partidas que ya se jugaron: la fila de 284 estaba a una hora de máquina y la de 24 420, a sesenta.
- 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 0,155 de tasa, que son unos 110 puntos de Elo. Ese es el instrumento, y conviene saber qué no puede ver antes de concluir que no hay nada.
Lab 7 · Estilo con adaptadores
Objetivo: dos adaptadores que cambien algo que se vea, y medir qué cuesta.
El eje de estilo es la primera jugada de las blancas. Es la elección más aburrida posible y es la
correcta: se ve en la demo. Cargas un adaptador y el modelo abre 1. e4; cargas el otro y abre
1. d4. Un adaptador que moviera algo real pero invisible sería mejor aprendizaje automático y peor
enseñanza.
Las rebanadas se cortan con campos tipados, no con SQL en un YAML:
styles: - name: e4 first_move: e2e4 min_elo: 1800 n_games: 200000El min_elo no es decorativo: si las partidas de 1. d4 vinieran de jugadores más flojos, «el
adaptador cambió la apertura» y «el adaptador lo empeoró» serían la misma medición.
- Tests: la rebanada de primera jugada solo contiene partidas que empiezan por ella; el prefijo no se cuela en otra jugada; los prefijos de ECO seleccionan familias; el suelo de Elo filtra; el tope limita; el manifiesto registra el predicado que una card publicada tendrá que citar; y una comilla en un campo no puede salirse del predicado.
- El entrenamiento reutiliza el bucle del decoder con
lora:en la config. Los checkpoints guardan los pesos fundidos —para que el exportador y el publicador no se enteren de que hubo LoRA— y el adaptador se escribe al lado como su propio fichero pequeño. - Un adaptador no se puede reanudar con
--resume: sus checkpoints llevan pesos fundidos, que ya no dicen dónde acababa el adaptador. El bucle lo rechaza diciéndolo.
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.yamluv run python labs/m4/lora_spectrum.py --adapter checkpoints/lora-e4Cada adaptador son 1 500 pasos y unos siete minutos, y deja adapter.safetensors con
adapter_config.json al lado de sus checkpoints fundidos. lora_spectrum.py es la figura de la
parte anterior: los ocho valores singulares de la corrección, y el noveno a cero exacto.
Y aquí está la parte del laboratorio que no es entrenar. Un adaptador de 1,6 MB que para llegar al navegador necesita 221 MB de ONNX no ha ahorrado nada: el método es barato y el formato se lo come. La salida es sacar la corrección de los pesos y meterla en el grafo.
class AdaptableDecoder(nn.Module): """`forward(idx, lora_a, lora_b)` -> the last step's logits, with the adapter applied."""
def forward(self, idx: Tensor, lora_a: Tensor, lora_b: Tensor) -> Tensor: for layer, slot in enumerate(self.slots): slot.a = lora_a[layer] slot.b = lora_b[layer] logits, _ = self.model(idx) return logits[:, -1, :]Los factores viajan apilados por capas —A de forma (capas, rangos, r, d) y B de
(capas, rangos, d, r)— y se exportan con rukh export --adapter-inputs. El navegador cambia de
estilo subiendo 1,6 MB, no descargando otro modelo.
- Comprueba lo primero que hay que comprobar: con los factores a cero, el grafo da exactamente los mismos logits que la exportación normal. Si no lo diera, el fichero adaptable no podría sustituir al ordinario y habría que publicar los dos.
- Comprueba lo segundo: con dos adaptadores distintos, las respuestas difieren, y el fichero no se ha tocado entre las dos.
- Y lo tercero, que es la paridad de siempre: el mismo adaptador cargado en PyTorch y alimentado al grafo tiene que elegir la misma jugada en mil posiciones de validación.
- Un detalle de implementación que no es cosmético: la corrección se arma con
catsobre todo el ancho de salida en vez de escribirse en una rodaja de un tensor de ceros. La asignación por índice exporta comoScatterND, que onnxruntime web ejecuta en CPU aunque el resto del grafo vaya por WebGPU.
uv run rukh export --ckpt checkpoints/medium-v4/best.pt --out artifacts/onnx/medium-lora --fp16 --int8 --check-parity --positions 1000 --adapter-inputs --adapter checkpoints/lora-e4Unos quince minutos, y las tres comprobaciones de arriba salen en la consola con su número: 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
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.
Primero las mismas partidas, escritas como las leería un humano. rukh.data.pgn_text reconstruye
el SAN con python-chess desde el mismo corpus UCI:
[WhiteElo "2015"] [BlackElo "2028"]1. e4 c6 2. d4 d5 3. exd5 cxd5 4. Bd3 Nf6 5. h3 Nc6 6. c3 e5 ... 1-0Dos detalles del formato que parecen menores y no lo son:
- El movetext se vuelve a jugar desde la raíz en vez de leerse de la pila de jugadas, porque
SAN depende de la posición en la que se escribe: la misma jugada es
Nf3oNgf3según dónde esté el otro caballo, y solo la posición de ese momento lo sabe. - El prompt nunca termina en espacio. Un BPE escribe una jugada como
" Nc6", con el espacio pegado; un prompt que ya lo lleva empuja al modelo hacia la grafía que casi nunca vio en entrenamiento, y eso saldría en la tabla como un modelo peor en vez de como un prompt peor.
Después el afinado, que es deliberadamente código de otros: transformers para el modelo,
peft para el adaptador, trl para el bucle. El proyecto tiene su propia LoRA y su propio bucle y
no los necesita aquí; lo que necesita es la experiencia del camino del ecosistema, que es el que un
lector va a usar de verdad.
- El presupuesto se elige generoso con Qwen, no justo con nosotros:
r = 16sobre las cuatro proyecciones de atención (el doble del rango que usan nuestros adaptadores) y 1 500 pasos de hasta 24 M de tokens de ajedrez, para un modelo que ya sabe hablar. Si aun así pierde por mucho, nadie puede decir que se le dejó sin comer. - Cuatro bits se intenta y no se exige. Si
bitsandbytesno carga, la corrida cae a bf16 y lo escribe en su informe, con la razón. Fingir que se demostró QLoRA cuando la cuantización no se usó sería peor que no intentarlo. - Y hay un detalle de contabilidad que cambia el titular:
bitsandbytesguarda dos valores de 4 bits por byte, así quenumel()sobre un peso cuantizado devuelve la mitad de los parámetros que representa. Contarlo sin corregir haría que la línea de «solo el 0,1 % es entrenable» saliera el doble de favorable de lo que es.
Y por último la evaluación, que es la parte que hace válida la comparación. play_game se partió
en dos para esto: un protocolo Player y un DecoderPlayer que es el camino original palabra por
palabra. El Qwen afinado entra como otro Player y juega contra la misma escalera, con los mismos
rivales, el mismo tiempo por jugada, los mismos colores y la misma adjudicación.
- El protocolo obliga a decir dos cosas a la vez: qué jugada se hace y si la propuesta sin
máscara era legal. La partida tiene que terminar, así que una propuesta ilegal se rescata con la
primera jugada legal, pero el rescate no puede esconder que ocurrió — es la misma regla que
illegal_proposalsen el decoder. - El rescate no vuelve a preguntarle al modelo. Insistir hasta que conteste algo legal mediría un modelo distinto del que describe la tasa de legalidad.
uv 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
por la misma escalera tarda unos 40 minutos, más que la del decoder, porque cada jugada es una
generación de texto. Si bitsandbytes no carga, la corrida cae a bf16 y lo escribe en su run.json.
Lab 9 · Publicar y encender el selector
Objetivo: que lo medido salga del disco, y que la demo no prometa más de lo que se midió.
Un adaptador no es un modelo y su repositorio no debe parecerlo. Sin pesos, sin ONNX, sin vocabulario: tres ficheros, y una card que empieza por el modelo base y por el comando que lo carga, porque sin el checkpoint base exacto el adaptador es un montón de números sin modelo que corregir.
uv run rukh eval --model checkpoints/medium-elo/step-3800.pt --config configs/eval/greedy.yaml --stage medium-elouv run rukh export --ckpt checkpoints/medium-elo/step-3800.pt --out artifacts/onnx/medium-elo --fp16 --int8 --check-parityuv run rukh publish model --ckpt checkpoints/medium-elo/step-3800.pt --repo chorcat/rukh-medium-elo --stage medium-elo --onnx artifacts/onnx/medium-elo --dry-runuv run rukh publish adapter --run checkpoints/lora-e4 --repo chorcat/rukh-lora-e4 --base chorcat/rukh-medium --effect artifacts/publish/effects/lora-e4.json --dry-runuv run rukh publish qwen --run checkpoints/qwen3-pgn-qlora --repo chorcat/rukh-qwen3-pgn-qlora --dry-runLo mismo para medium-masters y para lora-d4. Con --dry-run la carpeta entera queda en
artifacts/publish/ y la card se puede leer antes de subir nada; sin él hace falta HF_TOKEN.
- La card publica lo que el adaptador cambió y lo que costó, que son dos tablas y no una.
Lo que cambió: la probabilidad que el modelo le da a
e2e4desde la posición inicial, leída de la softmax sin muestrear —59,64 % → 99,85 %— y la entropía de la primera jugada, 1,7695 → 0,0209 bits. Lo que costó: legalidad, top-1 y puzles medidos en los dos, con la misma suite y la misma semilla. Un estilo que cambia la apertura es fácil; un estilo que cambia la apertura y nada más es la afirmación que vale, y solo es una afirmación si el «nada más» está medido. - Publicar un adaptador con el Elo del modelo base sería publicar el número de otro, así que cuando una medición no existe la card dice «no medido» en vez de tomarla prestada. Hay un test de eso.
- El recuento de parámetros de la card es la fórmula sobre el modelo base real, no el tamaño del fichero del test.
- Salen cinco ficheros, no tres: los mismos factores dos veces.
adapter.safetensorses el que leeload_adapteren PyTorch;web/adapter.bines el mismo contenido como un búfer plano defloat32con elalpha/rya aplicado, que es lo que descarga el navegador, yweb/adapter.jsonlleva sus formas porque un búfer de números no dice nada de sí mismo. Los dos los escribe la misma función a partir de los mismos tensores, así que no pueden separarse.
Y en la demo, el selector «Elo objetivo» lleva desde P2 en pantalla y desactivado. Encenderlo es una línea; lo que hay que decidir es qué ofrece.
export const ELO_TARGETS = [1200, 1500, 1800, 2000, 2100, 2400] as const;Seis, no veintisiete. El vocabulario tiene veintisiete cabeceras y el modelo contesta a todas, pero un control que el jugador puede mover es la afirmación de que moverlo hace algo, y esa afirmación es tan ancha como el barrido que la respalda. Ofrecer las veintisiete sería prometer veintisiete mediciones y tener seis. Una carta de restaurante solo lista los platos que la cocina ha probado; una con veintisiete entradas de las que se han cocinado seis no es una carta más larga, es una carta que miente en veintiuna líneas.
- E2E: con una etapa condicionada el selector está activo, sin pista, con exactamente esas seis opciones y empezando en 1800; con las demás, desactivado y con la pista visible.
- La etapa de prueba condicionada es el mismo grafo de juguete con la bandera puesta, para que el
E2E recorra el camino activado sin descargar 221 MB. Lo que se prueba es el cableado que lee la
bandera; si los pesos detrás están de verdad condicionados es una pregunta para
rukh eval sweepy ninguna prueba de navegador podría contestarla.
El selector de estilo se enciende igual y se comprueba mejor, porque de este sí puede decir algo
un navegador. El E2E carga el modelo una vez, lee la distribución que publica para una posición,
cambia de estilo, la vuelve a leer, y comprueba tres cosas: que cambia; que al quitar el adaptador
vuelve el mismo número, no uno parecido; y que en toda la prueba se ha pedido un solo
.onnx. Esa última aserción es el hito entero en una línea.
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 estas seis frases son cómo se llegó:
- El corpus antes que el modelo. Los doce tokens de Elo por debajo de 1800 nunca habían
recibido un gradiente porque
min_elo: 1800filtraba a los dos jugadores. Medido con unCountersobre el segundo token de cada partida empaquetada: 18 942 740 partidas, 0 de 12 cabeceras vistas alguna vez. No hacía falta ni cargar el modelo, y es el primer sitio donde mirar la próxima vez que una condición «no funcione». - El eje dejó de ser ruido, y se ve sin jugar una partida. La entropía analítica de la primera jugada pasa a ser monótona en las seis condiciones (1,596 → 1,992 bits) donde el modelo viejo tenía un escalón de 1,17 bits justo en el corte de los datos, con el signo del ruido: menos decidido por debajo del suelo que en cualquier cabecera que conocía. Por encima de 1800 los dos modelos coinciden: no hubo olvido catastrófico.
- El criterio 1 de
GOAL.mdno se cumple, y está medido por qué. Entre<w1500>y<w2000>la diferencia de tasa es −0,013 y harían falta 24 420 partidas por condición: no faltan partidas, no hay diferencia. El control sobre el modelo sin afinar recorre 161 de los 219 puntos del eje con cabeceras que nunca entrenó, el mismo montaje medido dos veces se mueve 60 puntos (1498 y 1558, 1,18 σ), y con 160 partidas el instrumento no ve nada por debajo de unos 110 Elo. Tres herramientas —partidas necesarias, corrida de control, suelo de reproducibilidad— y las tres se reutilizan. - La explicación obvia se comprobó y salió que no. Muestrear la cola a temperatura 1,0 en vez de jugar la moda ensancha la brecha (219 → 270) pero ensancha más los intervalos (123 → 195): el cociente baja de 1,78 a 1,38 y en tasa la diferencia pasa de 4,40 σ a 3,85 σ. El muestreo cuesta unos 400 Elo y saca al modelo del rango de la escalera. La cabecera mueve el estilo, no la fuerza táctica, y ya se sabe por qué: predecir el siguiente token nunca premia calcular mejor.
- LoRA escrita a mano es LoRA, y 393 216 números cambian una decisión sin costar nada. La
misma configuración con
apply_loray conpeftoptimiza el mismo número seis pasos seguidos con diferencia cero;lora-e4sube1. e4del 59,64 % al 99,85 % y deja legalidad, top-1 y puzles donde estaban, con 199 líneas distintas en 200 auto-partidas (fijó la primera jugada y no tocó las once siguientes). ConAyBcomo entradas del grafo, cambiar de estilo en el navegador cuesta 1,6 MB y no 221, y el E2E lo comprueba pidiendo un solo.onnx. - La representación es la mitad del resultado. Un Qwen3 de 600 M afinado con las mismas
partidas pierde 160 de 160: un tercio de sus respuestas son jugadas bien escritas que la posición
no permite y tres de cada cien no son jugadas, dos errores que el decoder no puede cometer. Y el
afinado de maestros enseña el reverso: estrechar el corpus ensancha el repertorio arriba
(1,7695 → 1,8850 bits) y borra el eje entre
<w2100>y<w2400>(0,010 bits contra 0,107).
Lo siguiente es M5, donde el modelo deja de imitar y empieza a preferir: un modelo de
recompensa, pares de jugadas puntuadas por el motor, DPO y GRPO. Es también donde se ataca lo que
este módulo no pudo: la fuerza táctica no sale de la cabecera, y la pregunta de M5 es de dónde sí.
La envoltura PreTrainedModel que has construido aquí es lo que deja esa puerta abierta: con ella,
DPOTrainer y GRPOTrainer funcionan sobre tu decoder. M5 terminó escribiendo los dos a mano —una
completación de una sola jugada colapsa las dos pérdidas a unas pocas líneas— pero esa fue una
elección, y las elecciones necesitan que las dos opciones existan.
La cheatsheet del módulo, con su pregunta y su respuesta corta, está justo debajo.
// cheatsheet M4
20 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¿Cómo se ve en los pesos que un token nunca se entrenó?
- Como confusión, no como debilidad. La entropía de la distribución del modelo sobre las veinte primeras jugadas legales —analítica, sin muestreo— es de **2,86 bits en `<w1200>`** y **2,94 en `<w1500>`** para `medium-v4`, frente a **1,77 en `<w1800>`**: un escalón de 1,17 bits exactamente donde el corpus se cortaba. Con un prefijo aleatorio el modelo está *menos* decidido que con cualquier cabecera que sí conoce, que es la firma del ruido. Tras el afinado con el corpus balanceado la misma medida sale monótona y con el signo correcto —1,60 · 1,65 · 1,74 · 1,88 · 1,99 bits de 1200 a 2400— porque un jugador de club tiene un repertorio estrecho y uno fuerte lo tiene ancho.
- 04¿Por qué el corpus de afinado es plano si el mundo real no lo es?
- Porque la frecuencia de una condición y su contenido son cosas distintas y aquí solo interesa la segunda. Un corpus con la forma de Lichess le enseña al modelo que `<w1500>` es **raro**, que no es lo mismo que enseñarle qué **significa**; el modelo tiene que saber qué viene después de cada token de Elo, no con qué probabilidad aparece. `data/elo-bins-v2` tiene 150 000 partidas por banda de 200 Elo de 1000 a 2600 y sale plano de `<w1000>` a `<w2400>` (entre 59 335 y 87 662 por bin de 100). Donde no se puede, se dice: la banda de 2600 tiene 23 191 partidas porque no hay más en dos meses de Lichess, y el manifiesto publica el reparto real.
- 05¿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,5 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.
- 06¿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.
- 07¿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).
- 08¿Qué complica adaptar una proyección `qkv` fusionada?
- Que no hay un módulo `q_proj` que envolver: hay una sola `nn.Linear` de 768 × 2304 que produce consulta, clave y valor pegadas. Adaptar «consulta y valor» significa adaptar dos rangos contiguos de salida de esa matriz, y cada uno tiene que recibir **su propia** `A` y su propia `B`. Compartir una `A` entre los dos sería más barato y no sería LoRA: el punto del método es que cada matriz adaptada obtiene su propio subespacio de rango `r`. Por eso `LoRALinear` toma una tupla de rangos de salida en vez de envolver la capa entera, y hay un test de que la franja de la clave no se mueve cuando solo se adaptan consulta y valor.
- 09¿Qué hace falta para que un modelo propio funcione con `transformers` y `peft`?
- Una envoltura que **contenga** el modelo, no que lo reimplemente: así hay una sola definición del paso hacia delante y el test de que las dos dan los mismos logits comprueba que el envoltorio es fiel. Y tres cosas que no salen en los tutoriales: `_tied_weights_keys` es un **diccionario** `{copia: origen}` en `transformers` 5 (con una lista, `save_pretrained` revienta con `'list' object has no attribute 'keys'`); hay que llamar a `post_init()` al final del constructor o no existe `all_tied_weights_keys` y cualquier guardado falla con un `AttributeError` sobre un atributo que uno nunca escribió; y la config no puede tener un miembro llamado `decoder`, porque `transformers` lo lee como la mitad decodificadora de un par encoder-decoder e intenta llamarle `to_dict()`. Y un cuarto que es nuestro: aquí `labels` **no** se vuelve a desplazar, porque el flujo empaquetado ya guarda `y` un paso por delante de `x`.
- 10«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 `<2600>` 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 ningún par contiguo está separado. 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.
- 11Si 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.
- 12¿Cómo se mide la diversidad de un modelo y por qué dos números?
- Porque fallan de maneras distintas. La **entropía de líneas** juega N aperturas del modelo contra sí mismo y mide la entropía de Shannon de las líneas distintas; es la que pide el spec y **depende del muestreo**: leída al ajuste casi determinista con el que se comparan las etapas (T = 0,05, top-k 1) cualquier decoder juega una sola partida y saca exactamente 0, lo cual es verdad y dice más del muestreador que de los pesos. La **entropía de la primera jugada** es analítica —la distribución del modelo sobre las veinte primeras jugadas legales, renormalizada, sin muestreo— y por eso no tiene varianza entre tiradas y es la comparable entre etapas tal cual; su techo es log₂(20) = 4,32 bits. La primera se publica siempre con la temperatura a la que se leyó.
- 13¿De cuántas maneras puede fallar un modelo que escribe SAN, y por qué importa separarlas?
- De cuatro, frente a la única del decoder. El decoder tiene un vocabulario que **es** el conjunto de jugadas, así que solo puede equivocarse de una forma: una jugada legal en la posición equivocada. Un modelo de texto puede además escribir algo que no es una jugada (`Nf9`, `O-O-O-O`, una frase), una jugada bien formada que esta posición no permite, o SAN **ambigua** que dos piezas podrían satisfacer y que no desambiguó (`Nd2` en vez de `Nbd2`). Se cuentan por separado y nunca sumadas en «ilegal», porque sumarlas escondería justo lo que cuesta la representación —que es la mitad de lo que enseña la comparación con un LLM general.
- 14¿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.
- 15¿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.
- 16¿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 el Elo de base-más-adaptador frente a la base sola. Publicar un adaptador con el Elo del modelo base sería publicar el número de otro.
- 17Dos intervalos se solapan: ¿faltan partidas o no hay diferencia?
- Es aritmética, no opinión, y se contesta antes de gastar más máquina. La escalera no mide Elo sino una **tasa de puntos** (victorias más medio empate, entre partidas), y el Elo es una transformación de ella. Una tasa es una proporción, así que su error típico es `sqrt(p(1-p)/n)` y dos intervalos del 95 % dejan de tocarse cuando la distancia entre las tasas supera `1,96 (se₁ + se₂)`; despejando, cerca de la mitad hacen falta **`n > 3,84 / (Δp)²` partidas por condición**. En M4 el par que pide el criterio, `<w1500>` → `<w2000>`, tiene Δ = −0,013 y necesitaría **24 420 partidas** por condición: sesenta horas de Stockfish para estrechar el intervalo alrededor de una diferencia que no está. El par siguiente, `<w2000>` → `<w2400>`, tiene Δ = +0,116 y necesita 284: eso es una hora y sí se paga. Las dos filas dicen «no separado» en el informe y significan lo contrario. Leído al revés, la misma fórmula dice qué puede ver una tirada: con 160 partidas por condición no se detecta nada por debajo de unos **110 puntos de Elo**, y un resultado negativo sin esa frase no es un resultado, es una impresión.
- 18¿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 221 MB por estilo en `medium`, o sea 442 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.
- 19¿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.
- 20¿Por qué la cabecera ordena la entropía y no ordena el Elo?
- Porque son dos lecturas distintas de la misma distribución. La entropía de la primera jugada lee la softmax **entera**; el Elo se juega con `temperature: 0.05, top_k: 1`, o sea con la **moda**. Y la diferencia entre un 1200 y un 2400 no está en la moda —los dos hacen la recaptura obvia— sino en la cola: cada cuánto eligen algo malo. El argmax tira la cola. Los propios datos lo apuntan: lo que lee la distribución entera es monótono seis de seis, y lo que lee la moda —puzles, Elo— es plano, mientras que el top-1 (que también lee la moda) se mueve apenas 2,8 puntos. Es una hipótesis contrastable, se contrastó, y **salió que no**. Muestreando a temperatura 1,0 la brecha en Elo se ensancha (219 → 270) pero los intervalos se ensanchan más (123 → 195), así que el cociente que decide si una diferencia se puede afirmar baja de 1,78 a 1,38; y en tasa de puntos la diferencia encoge, de 4,40 σ a 3,85 σ. La causa: muestrear cuesta unos 400 Elo, más que todo lo que separa a las seis condiciones, y eso tira al modelo por debajo del rango para el que la escalera está calibrada. La regla que deja es mejor que la hipótesis: **un instrumento tiene un rango, y un tratamiento que saca al sujeto de ese rango esconde el efecto en vez de revelarlo**.