// M5 · lección 03
Alineamiento: los labs
Once labs con sus comandos, en el orden en que se hicieron: cuántas partidas cuesta la pregunta, el control que tiene que dar 0,5000, agrupar las dos direcciones, la recompensa con la profundidad, pares del propio modelo, DPO con dos fuentes, ventajas de grupo a mano, el reward model, GRPO y romper la recompensa a propósito.
Parte 3 de 3 del módulo «Alineamiento». Viene de «Alineamiento: cómo se mide y lo que salió» y cierra el módulo.
Labs
Once laboratorios, en el orden en que se hicieron. Los tres primeros no entrenan nada: el hito empezó cambiando de instrumento y comprobándolo, que es lo que permitió creerse el resto. Los tres últimos son los que producen lo que está en el Hub: el reward model, los dos GRPO y la evaluación que decide cuál se publica.
Lab 1 · ¿Cuántas partidas cuesta la pregunta?
Antes de jugar nada, el precio. Dos maneras de contestar «¿este modelo es 50 Elo mejor?», y la cuenta que dice cuál cuesta ocho veces menos.
uv run python labs/m5/games_needed_match.py// Ejercicio
Cambia la diferencia buscada de 50 a 20 Elo y vuelve a correrlo. ¿Cuántas partidas pide el enfrentamiento directo? ¿Y la resta de dos escaleras? ¿Es el mismo factor 8,1 o cambia con la diferencia?
// SoluciónVer la solución
El factor se mantiene cerca de 8 porque las dos fórmulas escalan igual, con el cuadrado de la
diferencia: al bajar de 50 a 20 Elo las dos multiplican sus partidas por unas 6,3 veces —(50/20)² = 6,25— y la razón entre ellas apenas se mueve. Lo que sí cambia, y mucho, es el valor absoluto:
20 Elo son unas 1 160 partidas en un enfrentamiento, casi una vez y media las 800 que el hito jugó
por arista. Por eso la tabla al revés es la útil: con el presupuesto que tienes, ¿cuál es la
diferencia más pequeña que podrías ver?
Lab 2 · El control que tiene que dar 0,5000
El instrumento se estrena midiendo algo cuya respuesta ya se sabe.
uv run rukh eval match --a checkpoints/medium-v4/best.pt --b checkpoints/medium-v4/best.pt --games 100// Ejercicio
Corre el control. Luego corre el mismo comando con --games 101. ¿Qué pasa, y por qué está puesto
así a propósito?
// SoluciónVer la solución
Falla con un ValueError: «a mirrored match needs an even number of games». Cada apertura se
juega dos veces con los colores cambiados, así que un número impar significa que alguien jugó una
partida de más con un color. Podría redondearse hacia abajo en silencio y nadie se enteraría — y
esa es exactamente la razón de no hacerlo. Un reparto de colores torcido es un sesgo que no
aparece en ninguna métrica, así que el sitio donde tiene que doler es al escribir el comando.
Lab 3 · Agrupar las dos direcciones de un enfrentamiento
uv run rukh eval match --a <A> --b <B> --games 400 --seed 7uv run rukh eval match --a <B> --b <A> --games 400 --seed 7uv run python labs/m5/pooled_match.pyCorre las dos direcciones de un par cualquiera y luego el agrupador, y compara el intervalo agrupado con el de cada dirección. El ejercicio de este lab —cuánto se estrecha el intervalo al pasar de 400 a 800 partidas, y por qué no a la mitad— está en la parte 2, junto a la tabla agrupada de las aristas.
Lab 4 · Mirar cuánto se mueve la recompensa con la profundidad
La comprobación que no se ve mirando ordenaciones.
uv run python labs/m5/reward_hacking.py --depth 10--depth fija la profundidad con la que el motor puntúa las candidatas de las tres tablas de
ordenación (por defecto 12). El bloque final, el de la misma jugada a cuatro profundidades, lleva
sus 4, 6, 10 y 14 escritos en el script y no lo mueve el flag: es la comprobación de cuánto depende
del flag todo lo demás.
// Ejercicio
Fíjate en la fila «reescalada sola» del último bloque. Entre profundidad 10 y 14 el motor da el mismo 9 998 y la recompensa pasa de 1,000 a 0,667. Si el motor dijo lo mismo, ¿qué cambió?
// SoluciónVer la solución
El grupo. Esa recompensa normaliza por la dispersión de las candidatas que tiene delante, y a profundidad 14 el motor encuentra el mate en otra candidata más: el mínimo del grupo sube, la dispersión se encoge, y la misma jugada con la misma evaluación vale otra cosa. Es la única recompensa de la galería cuyo valor depende de con quién la compares, y por eso dos corridas suyas no se pueden comparar ni entre sí.
Lab 5 · Construir pares con las jugadas del propio modelo
uv run rukh data onpolicy --config configs/data/onpolicy.yaml --max-positions 500Tres funciones, y las tres son la parte del experimento que se puede hacer injusta sin darse cuenta:
def score_move(engine, board, move, depth: int) -> int: """Centipeones **para el bando que juega `move`**, después de jugarla.
Al motor se le pregunta por la posición a la que lleva la jugada, donde mueve el *rival*, así que la puntuación vuelve desde el punto de vista del rival y hay que darle la vuelta. Equivocarse aquí construye un conjunto que prefiere la peor candidata de cada grupo, y la pérdida de DPO lo aprende sin quejarse. """ board.push(move) try: info = engine.analyse(board, chess.engine.Limit(depth=depth)) return int(info["score"].pov(not board.turn).score(mate_score=MATE_SCORE)) finally: board.pop()
def sample_candidates(model, tok, board, history, count, temperature, top_k): """Jugadas legales distintas que el modelo propone aquí, de su propia distribución.
Las propuestas ilegales se **descartan, no se rescatan**: un par on-policy va de ordenar las jugadas que el modelo *jugaría*, y la demo enmascara las ilegales antes de que lleguen al tablero. Cuántas veces propone una es otra medida, y la suite ya la hace. """ cfg = SampleConfig(temperature=temperature, top_k=top_k, mask_illegal=False) generator = model_generator(model, cfg) seen: dict[str, chess.Move] = {} for _ in range(count * 4): # se sortea de más: los duplicados son el caso común move, _ = pick_move(model, tok, board, history, cfg, generator) if move is not None and move in board.legal_moves: seen.setdefault(move.uci(), move) if len(seen) >= count: break return list(seen.values())
def make_pair(engine, board, moves, depth, min_delta_cp) -> dict | None: """Puntuar las candidatas y quedarse con los extremos, si están lo bastante lejos.""" if len(moves) < 2: return None scored = sorted( ((score_move(engine, board, move, depth), move) for move in moves), reverse=True ) (cp_chosen, chosen), (cp_rejected, rejected) = scored[0], scored[-1] if cp_chosen - cp_rejected < min_delta_cp: return None return {"chosen": chosen.uci(), "rejected": rejected.uci(), "cp_chosen": cp_chosen, "cp_rejected": cp_rejected, "candidates": len(moves)}Ese for _ in range(count * 4) es el ejercicio de abajo escrito en el código: se sortea cuatro
veces más de lo que hace falta porque los duplicados son el caso común y no la excepción, que es
justamente la razón de la temperatura 1,0.
Y los dos return None son los dos motivos distintos por los que una posición no da par. Menos de
dos jugadas distintas es falta de diversidad; una diferencia por debajo del margen es falta de
desacuerdo, y no es un fallo: es el modelo diciendo que todo lo que estaba considerando ahí era
más o menos igual de bueno. Fabricar una preferencia con eso sería enseñarle ruido, y tiene la
misma forma que el grupo plano de GRPO.
El motor va limitado por profundidad y no por tiempo, por lo que M6 midió: un Stockfish limitado por reloj hace irreproducible la medición, y un conjunto de datos es una cosa que debería poder reconstruirse. Y las posiciones son las mismas que usan los pares off-policy: mismos prefijos, mismo equilibrio por fase, mismas partidas. Lo único que cambia entre los dos conjuntos es de dónde salieron las dos jugadas, que es el motivo entero de correr los dos.
// Ejercicio
Mira el manifest.json que escribe. ¿Qué fracción de posiciones no produjo par, y por qué dos
razones distintas? Luego baja temperature a 0,3 —la temperatura a la que se leen todos los
números publicados del proyecto— y vuelve a correrlo. ¿Qué pasa con skipped_all_same?
// SoluciónVer la solución
Sobre las 13 838 posiciones completas, el 27,1 % no dio par porque el modelo propuso una sola jugada distinta, y el 26,8 % porque las dos extremas estaban a menos de 100 cp. Son dos fallos distintos: el primero es falta de diversidad, el segundo es falta de desacuerdo.
Con temperature: 0.3 el primero se dispara: a esa temperatura el modelo propone su moda cuatro
veces de cada cuatro en la gran mayoría de las posiciones, y no hay nada que comparar. Es la misma
lección con la que terminó M4 desde el otro lado: el muestreo casi determinista es lo que hace que
los números publicados sean estables, y es exactamente lo que impide construir un par.
Lab 6 · DPO con dos fuentes de pares, y la trampa de no igualarlas
uv run rukh train dpo --config configs/train/dpo-onpolicy.yamluv run rukh train dpo --config configs/train/dpo-offpolicy.yamlLa segunda config no lee data/pairs/ sino data/pairs-offpolicy-matched/: el conjunto original
submuestreado a 6 386 con semilla fija, para que los dos brazos vean la misma cantidad de datos. El
ejercicio de este lab —qué medirías si no los igualaras— está en la parte 2, justo donde se toma
esa decisión. Lo que no se puede igualar, y hay que declarar, es la mezcla: un 29 % de mates en los
pares fuera de política frente a un 2,9 % en los del propio modelo.
Lab 7 · Ventajas de grupo a mano
Sin GPU y sin motor, para ver el paso que todo el mundo reconstruye mal.
import torchfrom rukh.train.grpo import group_advantages, grpo_loss
# Un grupo con dispersión y uno sin ella.print(group_advantages([0.98, 0.74, 1.21, 0.31, 0.62, 0.05, 0.88, 0.45]))print(group_advantages([0.74] * 8))
# La KL, en las dos direcciones.policy = torch.log_softmax(torch.randn(4, 8), dim=-1)reference = torch.log_softmax(torch.randn(4, 8), dim=-1)_, kl = grpo_loss(policy, reference, torch.tensor([0, 1, 2, 3]), torch.zeros(4), beta_kl=1.0)print(float(kl))// Ejercicio
Multiplica por diez todas las recompensas del primer grupo. ¿Cambian las ventajas? ¿Y si les sumas diez a todas?
// SoluciónVer la solución
No cambian en ninguno de los dos casos, y por razones distintas. Sumar una constante no cambia nada porque la media se mueve con ella: es la misma invariancia que tiene Bradley-Terry, vista en otro sitio. Multiplicar por diez no cambia nada porque la desviación se multiplica por diez también. La ventaja de grupo es invariante a traslaciones y a escala, lo cual es justo lo que la hace utilizable sin calibrar la recompensa — y justo lo que hace que un grupo de ocho pifias y un grupo de ocho jugadas excelentes produzcan ventajas del mismo tamaño.
Lab 8 · Romper la recompensa a propósito
uv run python labs/m5/reward_hacking.py --depth 12// Ejercicio
Añade una sexta recompensa rota a REWARDS: la sana, pero con WEIGHTS.mate subido de 0,25 a
2,0. ¿Invierte algún par? ¿Qué le pasa a la columna «cabeza» en la posición «hay mate»?
// SoluciónVer la solución
No invierte nada —el mate ya era la mejor jugada por evaluación, así que pagarlo más no cambia el orden— y la cabeza se dispara, porque toda la masa de ventaja del grupo se va a la candidata que mata y las demás se aplastan. Es el mismo daño que hace «sin suelo» por el otro extremo: una recompensa puede romper un grupo tirando de arriba o empujando desde abajo, y en las dos el síntoma es el mismo — deja de haber señal para ordenar lo que queda en medio.
Lab 9 · El reward model, y por qué su número depende de la semilla
Un encoder de casillas con una cabeza escalar, entrenado con la pérdida de Bradley-Terry sobre los
pares fuera de política. Cuatro minutos, desde cero: la parte 2 midió que partir del encoder de M3
estorba (62 % frente a 69 % a la misma talla), así que encoder_ckpt: null.
uv run rukh train reward --config configs/train/rm.yamlEl bucle es corto, y lo que añade sobre la pérdida de la parte 1 son dos cosas: la traducción de «(prefijo, jugada)» a la posición a la que lleva la jugada, que es lo que lee el encoder de M3, y un reparto que no miente.
def position_after(prefix: str, move_uci: str) -> tuple[str, bool] | None: """El FEN después de jugar `move_uci` sobre `prefix`, y si quien movió fue el blanco.
`None` cuando el prefijo o la jugada no se pueden jugar. Un reward model pregunta por la **consecuencia** de una jugada, así que aquí es donde un par deja de ser dos cadenas y pasa a ser dos tableros. """ board = chess.Board() for uci in prefix.split(): move = chess.Move.from_uci(uci) if move not in board.legal_moves: return None board.push(move) white_moved = board.turn == chess.WHITE move = chess.Move.from_uci(move_uci) if move not in board.legal_moves: return None board.push(move) return board.fen(), white_moved
def margins(model, chosen, rejected, sign, point_of_view: str): """`r(chosen) - r(rejected)`, orientado para que positivo signifique «ganó la preferida».
Con `point_of_view="white"` el modelo contesta siempre la pregunta de las blancas y el giro vive aquí, en una constante que la pérdida conoce. Con `"mover"` el modelo tiene que deducir del tablero quién acaba de mover y contestar por él, y el signo es siempre +1. Son dos tareas distintas, y la corrida dice cuál entrenó. """ difference = model(chosen) - model(rejected) return difference * sign if point_of_view == "white" else differenceEl reparto va por partida y no por par, con la misma función pura de M3: dos pares de la misma partida son dos jugadas separadas por unos pocos plies en la misma posición, así que un modelo que memorizó uno tiene casi todo el otro.
Y la razón de que este módulo publique un desglose por bandas en vez de un número:
MATE_GAP = 2000.0"""Por encima de esto el par lleva una puntuación de mate, que no es una diferencia que unafunción de evaluación pueda leer."""
BANDS = ((100, 200, "100-200"), (200, 400, "200-400"), (400, 800, "400-800"), (800, MATE_GAP, "800-2000"), (MATE_GAP, float("inf"), "mate"))
def band_accuracy(scored, deltas) -> list[RewardBand]: """Acierto según cuánto separó el motor las dos jugadas, con una banda de mate aparte.""" margin = np.asarray(scored, dtype=float) gap = np.asarray(deltas, dtype=float) rows = [] for low, high, label in BANDS: mask = (gap >= low) & (gap < high) if mask.any(): rows.append(RewardBand(band=label, pairs=int(mask.sum()), accuracy=float((margin[mask] > 0).mean()))) return rowsLa banda de mate va separada porque una diferencia de ±10 000 no es una diferencia que una
evaluación pueda leer: meterla dentro de «800+» halagaría a la banda alta con pares que no son
evaluaciones. Y el acierto es margin > 0 y no >= 0: un empate cuenta como fallo, porque un
modelo que puntúa dos jugadas exactamente igual no ha expresado ninguna preferencia.
Hay dos corridas de referencia y conviene saber cuál es cuál. La que está en el Hub como
chorcat/rukh-rm es rm-20260920-223655, la repetición con la semilla 42 de la config publicada
—74,21 % global, 75,77 % sobre los pares decidibles, 484 pares de mate en validación—, y es la
carpeta a la que resuelve checkpoints/rm (D-131). La salida de abajo es la de la semilla 1
(checkpoints/rm-seed1-20260920-212134/), que es la corrida cuyas bandas dibuja la figura de la
parte 2 y de la que salen las dos correlaciones publicadas (D-119):
accuracy: 74.76 % on held-out pairs 77.30 % over the pairs an evaluation can decide 100-200 cp 556 pairs 77.52 % 200-400 cp 242 pairs 74.79 % 400-800 cp 104 pairs 80.77 % 800-2000 cp 10 pairs 90.00 % mate cp 356 pairs 68.26 %margin vs delta cp: Pearson -0.128, Spearman -0.067 without mates: Pearson +0.058, Spearman +0.075// Ejercicio
Corre la misma config con seed: 1, 2 y 3 (cambia también run_name, o las tres corridas se
pisarán el nombre). Anota el acierto global y el de la banda de mate de cada una. ¿Cuánto se mueve
el titular? ¿Qué banda lo mueve?
// SoluciónVer la solución
En la referencia, las tres semillas dieron 74,76 %, 75,19 % y 74,71 % de acierto global, con la banda de mate en 68,26 %, 71,69 % y 69,57 % y con 356, 438 y 368 pares de mate en validación. La semilla reparte la partición y entrena el modelo, así que cuántos pares de mate le tocan al conjunto de validación mueve el titular, y la banda de mate —algo más de un cuarto de los pares de validación, entre el 27 % y el 33 % según la semilla, y siempre la peor— es la que lo mueve. Comparar dos corridas con semillas distintas no dice nada del modelo; y dos con la misma semilla tampoco se repiten exactas: la config publicada, corrida dos veces sin cambiar nada, dio 72,91 % y 74,21 % (D-113). Ese 1,3 es el suelo de reproducibilidad de la GPU, y cualquier diferencia que publiques tiene que ser mayor que él.
Lab 10 · GRPO a dos tasas, y elegir sin restar
Sin pares y sin reward model: las posiciones se leen del fichero de pares y las etiquetas se
ignoran. Ocho candidatas por posición, la recompensa verificable con el motor a profundidad 8 y
una caché en disco para que la segunda visita a una posición sea gratis. La corrida de referencia
son 1 500 pasos; grpo.yaml deja 300 para que la primera vuelta tarde minutos.
uv run rukh train grpo --config configs/train/grpo.yaml --steps 1500sed 's/^lr: 1.0e-6/lr: 5.0e-6/' configs/train/grpo.yaml > configs/train/grpo-fast.yamluv run rukh train grpo --config configs/train/grpo-fast.yaml --steps 1500 --run-name medium-v4-grpo-fastuv run rukh eval match --a checkpoints/medium-v4-grpo-fast/grpo.pt --b checkpoints/medium-v4-grpo/grpo.pt --games 400 --seed 7uv run rukh eval match --a checkpoints/medium-v4-grpo/grpo.pt --b checkpoints/medium-v4-grpo-fast/grpo.pt --games 400 --seed 7uv run python labs/m5/pooled_match.pyLa salida de rukh train grpo tiene dos líneas de recompensa, y la que puntúa es la segunda:
reward: +0.8657 -> +0.8937 (group best) +0.8250 -> +0.8529 (engine best -- this is the score)flat: 0.385 -> 0.472illegal: 0.0000 -> 0.0000kl: 0.12169 against the frozen start// Ejercicio
Las dos corridas dan estas cifras contra la base: la lenta +44 Elo (23 a 66) y la rápida +68 (46 a 90). Antes de mirar el agrupado de sus enfrentamientos directos: ¿cuál publicarías, y con qué argumento?
// SoluciónVer la solución
Si respondiste «la rápida, por 24 puntos», acabas de restar dos mediciones contra un tercero, que es exactamente lo que la parte 1 desaconseja y lo que yo hice una vez antes de darme cuenta (D-126). Enfrentadas entre sí y agrupando las dos direcciones, las dos corridas empatan: −0,87 Elo con intervalo de −22,2 a +20,4. Con un empate medido, el desempate no es el Elo sino el coste, y ahí la lenta gana en todo: menos grupos planos (0,472 frente a 0,560), menos KL contra el inicio (0,122 frente a 0,555) y menos propuestas ilegales (1,66 veces la base frente a 2,08). Se publica la lenta. La regla completa está en la parte 2.
Lab 11 · La suite, la exportación y el Hub
Lo que cierra el módulo no es un enfrentamiento sino la fila de la tabla única, medida con la misma config y la misma semilla que todas las etapas anteriores, y el modelo en el Hub con esa fila en su card.
uv run rukh eval --model checkpoints/medium-v4-dpo-onpolicy/dpo.pt --config configs/eval/greedy.yaml --stage medium-v4-dpo-onpolicy-greedyuv run rukh eval --model checkpoints/medium-v4-grpo/grpo.pt --config configs/eval/greedy.yaml --stage medium-v4-grpo-greedyuv run rukh export --ckpt checkpoints/medium-v4-dpo-onpolicy/dpo.pt --out artifacts/onnx/medium-dpo --fp16 --int8 --check-parityuv run rukh publish model --ckpt checkpoints/medium-v4-dpo-onpolicy/dpo.pt --repo chorcat/rukh-medium-dpo --stage medium-v4-dpo-onpolicy-greedy --onnx artifacts/onnx/medium-dpo --dry-runuv run rukh publish reward --run checkpoints/rm --repo chorcat/rukh-rm --dry-runuv run rukh data publish --name rukh-pairs-onpolicy --dry-runCada evaluación canónica tarda unos 26 minutos y una exportación unos 12; --dry-run deja la
carpeta entera en artifacts/publish/ sin tocar la red. El --stage del publish tiene que ser
el mismo con el que se corrió la evaluación, o la card sale sin números; y si el checkpoint no
es el que se midió, el publicador se niega en vez de publicar los números de otro modelo.
// Ejercicio
Antes de quitar --dry-run, lista la carpeta staged con ls -la. ¿Cuánto tiene que pesar
model.safetensors de un modelo de 115 millones de parámetros en fp32? ¿Y el del reward model?
// SoluciónVer la solución
Unos 460 MB (115 M × 4 bytes) y unos 152 MB (37,9 M × 4). La referencia encontró una vez 25 KB donde debían estar los 152 MB del reward model: un test cuyo aislamiento no funcionaba había escrito su modelo de juguete en la carpeta real (D-129). Contar bytes es la comprobación más barata que existe, y la única que lo habría pillado.
Lo que te llevas
Tres cosas, y las tres son sobre cómo medir, no sobre alineamiento. Eso no es un accidente del módulo: es lo que pasa cuando el instrumento y el experimento se construyen a la vez.
Para medir una diferencia, mide la diferencia. Cada absoluto trae su ruido entero a la resta, y la resta acaba más ancha que las medidas que entraron. En este módulo la regla falló tres veces por tres caminos distintos —restando dos escaleras, restando dos enfrentamientos contra la base en un triángulo que no cierra, y eligiendo entre dos GRPO por lo que cada uno hizo contra un tercero— y las tres veces el enfrentamiento directo dio otra respuesta. Dos de las tres las cometí yo después de escribir la regla.
Antes de explicar una diferencia pequeña, mide cuánto se mueve tu montaje cuando no cambias nada. La escalera repite 1498 y 1558. El reward model repite 72,91 % y 74,21 % con la misma semilla y la misma partición. Un veredicto de «separado del cero» se dio la vuelta al intercambiar los lados mientras la estimación apenas se movía. Ninguna de las tres se descubre mirando el modelo.
Y antes de publicar una métrica, describe la política que la maximizaría. Si esa política no es la que quieres, la métrica no es la que quieres. Esa pregunta encontró en nuestro propio bucle un fallo que ninguna de las cinco recompensas rotas a propósito habría enseñado: la recompensa que GRPO optimiza se maximiza dejando de ser diverso, y la curva de tres puntos que lo demuestra tiene el mecanismo en la columna de al lado.
El modelo, mientras tanto, mejoró: los tres métodos baten a su base con el intervalo separado del cero, que es la primera vez en el proyecto. Lo que cambió no fue el modelo, fue cómo se mira.
Lo siguiente es M6, el cierre de la primera fase: la tabla única con todas las etapas, las model cards desde MLflow, y la demo final. Después empieza la fase agéntica, donde el modelo deja de ser lo único que hay y pasa a ser una herramienta que algo más usa.
La cheatsheet del módulo, con su pregunta y su respuesta corta, está justo debajo.
// cheatsheet M5
30 preguntas para llevarte
- 01¿Qué cambia el alineamiento que no cambiaba el afinado?
- De dónde sale la señal. Preentrenar y afinar usan la misma pérdida —entropía cruzada sobre el siguiente token— y toda su información viene de partidas que alguien jugó: enseñan **qué se juega**, nunca premian **calcular mejor**. El alineamiento cambia el objetivo: la señal pasa a ser «de estas dos jugadas, esta es mejor» (un par de preferencia) o «esta jugada vale 0,84» (una recompensa). Ninguna de las dos frases aparece en ninguna partida, y las dos se pueden construir. Por eso M4 terminó midiendo que afinar cambia el comportamiento y no la competencia, y por eso este módulo existe.
- 02¿Por qué el primer trabajo de M5 fue un instrumento y no un modelo?
- Porque el criterio del módulo —+50 Elo con intervalo— no se podía comprobar con lo que había. La escalera de Stockfish repite con un suelo de unos 40 Elo de una sigma (D-107: la misma configuración con la misma semilla dio 1498 y 1558, porque el rival juega por tiempo y `UCI_LimitStrength` aleatoriza a propósito). Restar dos medidas de ±56 da una diferencia de ±80: más ancha que cualquiera de las dos que entraron. Buscar 50 con eso no es cuestión de más partidas, es el instrumento equivocado.
- 03¿Cuánto más barato es un enfrentamiento directo, y de dónde sale ese número?
- 8,1 veces, y sale de dos fórmulas de potencia. Distinguir dos proporciones que difieren en Δp pide `n > 3,84 / (Δp)²` partidas **en cada lado**; detectar una ventaja en un enfrentamiento pide `n > z²·p(1−p)/(p−0,5)²` **en total**. Para +50 Elo la primera sale 8,1 veces más cara. Y se usa al revés, que es como se usa de verdad: con 200 partidas la diferencia más pequeña visible es 48 Elo, con 400 es 34, con 50 es 95. `labs/m5/games_needed_match.py` imprime la tabla antes de que se juegue nada.
- 04¿Por qué un enfrentamiento se ahorra ruido además de partidas?
- Porque desaparece el tercero. En una escalera, los dos modelos juegan contra un rival cuyo comportamiento cambia entre corridas, y ese ruido entra entero en cada medida y otra vez en la resta. En un enfrentamiento los dos juegan **la misma** partida: mismas posiciones de libro, colores espejados, y el resultado depende solo de los dos modelos. No es una optimización, es quitar una variable.
- 05¿Qué es un control de instrumento y qué encontró el de M5?
- Medir algo cuya respuesta ya sabes. Un modelo contra una copia exacta de sí mismo tiene que dar 0,5000 clavado; salió **0,975 con 999 jugadas ilegales**. Eran dos errores en `infer/game.py`: la partida empezaba desde el tablero con el libro de aperturas puesto pero esas jugadas nunca se le reproducían al jugador, y al rival —si guardaba estado— no se le contaba ninguna jugada, porque `Opponent` no tiene `observe` y nadie preguntaba si lo tenía. Tras el arreglo, 0,5000 exacto. Costó un minuto de máquina y evitó publicar un +40 que habría sido un artefacto.
- 06¿Qué promete Bradley-Terry y qué no?
- Promete convertir preferencias en puntuaciones: `P(a gana a b) = σ(r(a) − r(b))`, así que la pérdida es `−log σ(r_chosen − r_rejected)`. Lo que **no** promete es una escala. En la fórmula solo aparecen diferencias, así que sumar cien a todas las puntuaciones no cambia nada: el «0,8» de un entrenamiento y el «1,4» de otro son dos reglas sin origen y compararlas no significa nada. Hay un test que lo fija, porque un modelo que se rompiera con un desplazamiento constante no lo notaría nadie aguas abajo.
- 07¿Por qué DPO no necesita reward model?
- Porque su referencia es **implícita**: una copia congelada de los pesos de partida, que no se entrena ni se publica. DPO parte de escribir el óptimo del refuerzo con preferencias en función de la política y de esa referencia, y entrenar la política directamente sobre los pares. Se ahorran un entrenamiento y una red de valor. `β` mide cuánto se le deja alejarse: es la fianza frente a la foto del piso el día de entrar. Rukh entrena con `β` 0,1 y no ha medido otros valores.
- 08¿Qué mira uno en un entrenamiento DPO: el margen o el acierto?
- El acierto. El margen puede crecer con la ordenación todavía mal, porque dice «`chosen` gana a `rejected`» y no dice «`chosen` es buena». De ahí también `nll_weight`: un poco de pérdida de siguiente token sobre la jugada preferida ancla la política, porque DPO puro puede subir el margen mientras el modelo se olvida de jugar.
- 09¿Qué diferencia hay entre un par on-policy y uno off-policy, y por qué importa?
- De dónde salen las dos jugadas. Off-policy: de otro sitio —las evaluaciones multi-PV de Lichess—, y dicen qué jugada es mejor tanto si el modelo iba a considerarla como si no. On-policy: del propio modelo, muestreando candidatas en la posición y puntuándolas con el motor. Importa porque **un modelo solo pierde partidas con las jugadas que juega**. En Rukh los dos conjuntos salieron muy distintos: los on-policy tienen un 2,9 % de pares de mate frente al 29 % de los off-policy, así que no son dos fuentes de lo mismo, son dos distribuciones de dificultad.
- 10¿Cómo se compara DPO sobre dos conjuntos de pares sin hacer trampa?
- Igualando todo menos lo que se estudia. Mismas posiciones de partida, misma configuración de entrenamiento línea por línea, y **el mismo número de pares**: los on-policy salieron 6 386, así que el conjunto off-policy se submuestrea a 6 386 con semilla fija. Si un brazo ve 2,2 veces más datos, lo que se mide es la cantidad, no la fuente. Y la comparación final no es cada uno contra la base por separado: es un enfrentamiento directo entre los dos, por la misma razón que todo lo demás del módulo.
- 11¿Por qué no se puede calcular `on − off` restando lo que cada uno hizo contra la base?
- Porque el triángulo no cierra. Agrupadas las dos direcciones, el DPO fuera de política está +65 sobre la base y el de dentro +57, así que `on` debería quedar unos ocho puntos por debajo de `off`; enfrentados de frente con 800 partidas, `on` gana por +37. El residuo, lo que las tres aristas no consiguen sumar, es de 45 Elo con un error típico de 18: 2,6 σ, y no es que los intervalos sean generosos. Un solo número de fuerza por modelo supone que la fuerza es un orden total, y un emparejamiento no está obligado a respetarlo: el modelo fuera de política juega más afilado, y eso le va bien contra la base y mal contra un modelo más limpio. La regla: si te importa cuál de dos gana, enfréntalos; no restes (D-120).
- 12¿Qué le costó al modelo alinearse, y cómo se supo?
- Jugadas ilegales, y se supo gratis porque `rukh eval match` ya las contaba para el control del instrumento. La base propone entre el 1,30 % y el 1,47 % de ilegales sobre sus propias jugadas; el DPO dentro de política, 2,42-2,58 %; el de fuera, 2,90-3,80 %: alinear dobló la tasa aunque los dos se entrenaron con `nll_weight: 0.1`, que existe precisamente para anclar la política. El mecanismo: los pares de fuera de política empujan probabilidad hacia tokens donde la política casi no tenía masa, y los de dentro solo la mueven entre jugadas que ya consideraba. GRPO paga 1,66 veces la tasa de su base, a la par que el DPO de dentro y menos que el de fuera, porque su gradiente solo mueve masa entre jugadas que ya eran legales. Mide el coste en la misma corrida en la que mides el beneficio (D-121).
- 13¿Qué hace GRPO que PPO hace más caro?
- La línea base. Para saber si una recompensa fue buena hace falta algo contra lo que compararla; PPO entrena una red de valor entera para estimarla. GRPO hace que la política proponga un **grupo** de respuestas a la misma entrada y usa la media del grupo, que ya estaba pagada. La ventaja de cada candidata es `(r − media) / (desviación + ε)`. Sin red de valor, sin segundo entrenamiento.
- 14¿Qué pasa cuando un grupo de GRPO no tiene dispersión?
- Que no aporta nada y el motor ya está pagado. Si las ocho candidatas puntúan igual, la media cae encima de todas y todas las ventajas son cero: el paso es un no-op. Por eso `GrpoResult` cuenta los grupos planos aparte de los útiles — el número dice qué parte del presupuesto se lleva la propia confianza del modelo, y no hay forma de saberlo por adelantado. En la generación de pares on-policy pasó lo mismo por otro camino: en 3 743 de 13 838 posiciones (27 %) el modelo propuso una sola jugada distinta.
- 15¿Por qué la KL se calcula con `exp(d) − d − 1` y no con `d` a secas?
- Porque el estimador k3 es **no negativo por construcción** y `d = logp_ref − logp_policy` no lo es: es negativo la mitad de las veces, precisamente en las muestras donde la política se alejó de la referencia. Como término de penalización, la versión ingenua le pagaría al modelo por lo que se supone que castiga. Además k3 tiene menos varianza. Hay un test que lo dispara en las dos direcciones a propósito.
- 16¿Qué hace que una recompensa sea «verificable» y qué no le da eso?
- Que la calcula un programa y se puede comprobar, en vez de estimarla un modelo entrenado con opiniones. El ajedrez es de los pocos sitios donde se puede escribir entera: legalidad, mate, repetición y la evaluación del motor. Lo que gana es que no se equivoca como se equivoca un modelo aprendido. Lo que **no** gana es inmunidad al reward hacking: sigue siendo un diseño humano, y por tanto exactamente especificable y exactamente hackeable.
- 17¿Por qué la legalidad es una puerta y no un término de la suma?
- Porque un término se puede comprar. `+2 si es legal` suena a preferencia fuerte y es un **precio**: cualquier otro término que valga más de dos lo paga. Con una calidad sin tope, ese precio lo alcanza cualquier posición de más de dos peones. Y hay una trampa peor que Rukh cometió: la puerta estaba en `0,0`, que cae **dentro** del intervalo legal `[−0,25, 1,25]`, así que una jugada imposible puntuaba por encima de una legal que se mete en repetición (D-115). Una puerta que lo que separa puede saltarse no es una puerta: el valor tiene que derivarse del mínimo legal, no escribirse como constante.
- 18¿Dónde vive el reward hacking, si no es en qué jugada gana?
- En la forma del grupo. La galería de M5 midió seis recompensas —la sana y cinco rotas— sobre el mismo grupo de candidatas y las seis **coronan la misma jugada** en las tres posiciones: todas son monótonas en la evaluación del motor, así que el máximo no se mueve. El daño está en lo que consume el optimizador, que no es la recompensa sino `(r − media)/desviación`. Sin tope en la calidad, una sola candidata catastrófica se lleva el 46,5 % de toda la masa de ventaja del grupo y aplasta a las demás en el mismo número pequeño: el grupo deja de ser una comparación.
- 19¿Por qué las exhibiciones de una galería tienen que diferir en una sola cosa?
- Porque si no, no se puede atribuir nada. La primera versión escribió las cinco recompensas rotas por separado y cuatro se dejaron el término de repetición por el camino sin querer: todas reportaban la misma inversión y ninguna era suya. Reescritas como «la sana con exactamente un término sustituido», cada inversión señala a su propio fallo. Vale para una galería didáctica y vale para cualquier ablación (D-117).
- 20¿Qué pasa con una recompensa si cambias la profundidad del motor?
- Que cambia, y en todas. La misma jugada en un rey y torre contra rey vale 597 a profundidad 4 y 9 996 a profundidad 6, no porque la jugada cambie sino porque el motor encuentra el mate. Medido como rango entre profundidades: la recompensa sana se mueve 0,99 —todo su intervalo legal—, la que no tiene suelo se mueve **47,0**, y la que paga el mate por el marcador del motor, 3,49. Lo único estable es el bono de mate plano, porque le pregunta al tablero y no al motor. La regla: fija la profundidad, escríbela en el run y no compares dos runs con profundidades distintas (D-118).
- 21¿Por qué el acierto del reward model depende de la semilla?
- Porque la semilla no reparte solo los lotes: reparte la **partición**. `split_examples` separa por partida con la misma semilla que el entrenamiento, así que cambiarla cambia cuántos pares de mate caen en validación —484 con la semilla 42, 356 con la 1—. Y los pares de mate son donde el modelo peor va, así que el titular sube cuando el sorteo reparte menos mates (D-113). Cuando una semilla mueve el resultado, lo primero es mirar si mueve el modelo o mueve la regla de medir.
- 22¿Por qué el reward model falla justo en los mates?
- Porque un mate es un hecho táctico y lo que el modelo lee es una evaluación estática de la posición resultante. La banda de mate es la peor en las cuatro corridas (68,3 % a 71,7 %) y es algo más de un cuarto de los pares de validación; la mejor es la de 400-800 cp (80,2 % a 85,4 %). Lo que **no** se puede decir es que el acierto suba monótonamente con la distancia: las bandas 100-200 y 200-400 se cruzan según la semilla, y la de 800-2000 tiene entre 10 y 22 pares, así que su 90 % y su 50 % son la misma ausencia de dato. Y esa banda de mate es exactamente la que una recompensa verificable contesta sin error, que es el puente al resto del módulo.
- 23¿Qué significa que la correlación del reward model sea negativa?
- Que hay dos poblaciones mezcladas. Pearson entre el margen del modelo y la distancia en centipeones sale **−0,128** con todos los pares y **+0,058** quitando los de mate: el signo lo cambia el subconjunto, no el modelo. Los pares de mate tienen por construcción la distancia más grande y el margen más pequeño (+0,744 frente a +1,272), así que algo más de un cuarto de los puntos, pegados a una esquina, tuercen la recta entera. Una correlación sobre una población con dos regímenes no describe ninguno de los dos (D-119).
- 24¿Ayuda partir de un encoder preentrenado para un reward model?
- Aquí no: resta unos 6,5 puntos de acierto frente a inicializar al azar. El encoder de M3 aprendió a rellenar jugadas enmascaradas y se le está pidiendo ordenar posiciones por lo buenas que son; lo que trae de casa estorba. Lo que sí manda es la capacidad: 12 capas × 512 sube donde ninguna otra palanca subía. «Preentrenado» no es una propiedad, es una relación entre lo que aprendió y lo que le vas a pedir.
- 25¿Por qué `head()` no vale para recortar un dataset?
- Porque es un recorte, no una muestra, y solo coinciden si el fichero está desordenado. Los pares de Rukh están equilibrados por fase **en bloques**, así que las primeras 40 filas son 65 % aperturas frente al 33 % del fichero entero (D-114). En un hito que compara dos conjuntos de pares, eso convierte la comparación de fuentes en una comparación de mezclas de fase. La corrección es `sample(shuffle=True, seed=...)`, que cuesta lo mismo.
- 26¿Por qué el motor se limita por profundidad y no por tiempo?
- Para que el dataset y la corrida se puedan rehacer. P4 midió que un Stockfish limitado por tiempo hace irreproducible una medición: la misma escalera dos veces dio 1498 y 1558 (D-107). Un límite por profundidad da la misma respuesta en una máquina ocupada y en una libre. El precio es que la profundidad pasa a ser un parámetro del experimento que hay que escribir en el run, porque cambiarla cambia los números (D-118).
- 27En GRPO, ¿qué hay que mirar además de que suba la recompensa?
- La KL y la proporción de ilegales, los tres a la vez. Una recompensa verificable **siempre** puede subir: está escrita, y lo que está escrito se puede optimizar. Que suba sola no distingue «juega mejor» de «encontró la esquina que puntúa bien». Si sube y la KL se queda pequeña y los ilegales no empeoran, es una mejora; si sube y la KL se dispara, es un síntoma.
- 28¿Qué forma tiene la mejora de GRPO con la tasa de aprendizaje, y qué la explica?
- Una U invertida. A 400 pasos, la recompensa medida contra el mejor movimiento disponible sube +0,0088 a 1e-6, +0,0267 a 5e-6 y vuelve a caer a +0,0078 a 2e-5, mientras `flat_share`, la proporción de grupos de validación donde las ocho candidatas son la misma jugada, va 0,370 → 0,453 → 0,490 desde 0,387: monótona en las tres. La política compra recompensa con determinismo y pasado un punto la compra deja de salir a cuenta; es sobreoptimización con el mecanismo a la vista en la columna de al lado. Y la recompensa referida al grupo, la que entrena, a 1e-6 **baja** (−0,0032) mientras la del motor sube: con la métrica original esa corrida se habría archivado como fracasada, y es la mejor de las tres (D-124). A 1 500 pasos ni la tasa pequeña conserva la diversidad (0,385 → 0,472), así que no hay una corrida limpia y otra sucia: hay una curva, y dónde pararla es una decisión.
- 29¿Por qué el criterio de +50 Elo se queda a 0,26 y no se juegan las 56 partidas que faltan?
- Porque el presupuesto se fijó antes de mirar. Con 800 partidas el brazo fuera de política daba +67 con el extremo inferior en 45; se jugaron 800 más, con otra semilla de libro y en las dos direcciones, y con 1 600 salió +65,25 con intervalo de 49,74 a 80,76. La cuenta dice que unas 56 partidas más lo cruzarían, media hora, y no se juegan: jugar hasta que el número cruce el umbral es ajustar el experimento al criterio, como reclutar pacientes hasta que el fármaco salga significativo. Lo que sí se afirma es que los tres métodos baten a su base separados del cero (D-127). Y de GRPO se publicó la corrida de lr 1e-6: enfrentada a la rápida de 5e-6 empató (−0,87 Elo, IC −22 a +20 con 800 partidas) y cuesta menos en todo lo demás, 0,472 de grupos planos contra 0,560, KL 0,122 contra 0,555 e ilegales 1,66× la base contra 2,08× (D-126).
- 30¿Por qué el caché del motor es una decisión de diseño y no una optimización?
- Porque el motor es el coste entero de la corrida: ocho candidatas por posición, cientos de pasos, y el modelo es casi un detalle al lado. Un caché `(FEN, jugada) → centipeones` convierte la segunda visita en una búsqueda en un diccionario, y se guarda entre corridas. Va indexado por el **FEN** y no por la lista de jugadas, así que una posición a la que se llega por transposición es la misma entrada.