// M5 · lección 09
Alineamiento: los labs
Once labs con sus comandos y los tres scripts de labs/m5 enteros: cuántas partidas cuesta la pregunta, el control que tiene que dar 0,5000, agrupar las dos direcciones con el residuo del triángulo, y la galería que rompe la recompensa de cinco maneras y mide qué rompe cada una.
La lección que cierra el módulo: los once labs que produjeron los números de la lección anterior, y
los tres scripts de labs/m5 enteros.
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. Los tres últimos producen lo que está en el Hub.
// Antes de empezarQué cuesta cada lab, y cuál puedes saltarte
| Lab | Ruta | Reloj | Deja | Atajo |
|---|---|---|---|---|
| Lab 1¿Cuántas partidas cuesta la pregunta? | imprescindible | segundos | la tabla de precios, en pantalla | no hay |
| Lab 2El control que tiene que dar 0,5000 | imprescindible | ~1 min | 0,5000 clavado; si no, el instrumento está roto | no hay |
| Lab 3Agrupar las dos direcciones de un enfrentamiento | cuesta máquina | ~4 min por dirección | la tabla agrupada y el residuo del triángulo | no hay |
| Lab 4Cuánto se mueve la recompensa con la profundidad (necesita la mitad del script del Lab 8) | imprescindible | ~2 min | las tablas de la galería de reward hacking | no hay |
| Lab 5Construir pares con las jugadas del propio modelo | cuesta máquina | ~35 min el corpus entero; el recorte de 500, minutos | data/pairs-onpolicy/pairs.parquet y su manifiesto | rukh pull rukh-pairs-onpolicy |
| Lab 6DPO con dos fuentes de pares, y la trampa de no igualarlas | cuesta máquina | ~4 min cada brazo, los dos con 6 386 pares | checkpoints/medium-v4-dpo-{on,off}policy/dpo.pt | rukh pull medium-v4-dpo-onpolicy |
| Lab 7Ventajas de grupo a mano | solo observar | — | la resta de la media del grupo, hecha a lápiz | no hay |
| Lab 8Romper la recompensa a propósito | imprescindible | ~2 min | los pares que la recompensa rota invierte | no hay |
| Lab 9El reward model, y por qué su número depende de la semilla | cuesta máquina | ~4 min por semilla | checkpoints/rm-<fecha>/reward.pt y run.json (checkpoints/rm/ si lo bajas) | rukh pull rm |
| Lab 10GRPO a dos tasas, y elegir sin restar | cuesta máquina | ~12 min cada tasa + ~4 por dirección del duelo | checkpoints/medium-v4-grpo*/grpo.pt | rukh pull medium-v4-grpo |
| Lab 11La suite, la exportación y el Hub, para DPO y para GRPO | cuesta máquina | ~26 min por evaluación + 12 por exportación, dos de cada | las filas de la tabla y los repos del Hub | no hay |
- imprescindible
- Segundos o pocos minutos, y el lab siguiente da por hecho que lo corriste.
- cuesta máquina
- Tiempo real de GPU, red o motor. El reloj es el de la RTX 5090 de referencia.
- solo observar
- No se ejecuta nada: se lee la salida y se responde a la pregunta.
Lab 1 · ¿Cuántas partidas cuesta la pregunta?
// Viene delección 1 · Alineamiento: el instrumento y la teoría justa
Antes de jugar nada, el precio de las dos maneras de contestar «¿este modelo es 50 Elo mejor?».
El script no importa nada de rukh, así que se puede correr antes de tener modelos.
"""How many games a head-to-head match needs, and what a match of N games can see.
Lab 1 of M5, and the first thing that runs in the milestone. `GOAL.md` asks for **+50 Elo with aninterval** over the base, and there are two ways to measure that:
* run the Stockfish ladder twice and subtract the two absolute ratings;* play the two models against each other and read the difference directly.
They are not equally good and the difference is not a matter of taste:
uv run python labs/m5/games_needed_match.py
The second one is about eight times cheaper, and it also drops a source of noise the first onecannot avoid -- P4 measured that two identical ladder runs of the same model give 1498 and 1558(D-107), because Stockfish plays on a clock and `UCI_LimitStrength` randomises on purpose. In amatch both models play the same game, so there is no third party whose mood has to be averaged.
The rule this leaves: **to measure a difference, measure the difference.**"""import argparseimport mathimport sys
if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace")
Z = 1.959963985 # two-sided 95 %
parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("--edges", default="10,25,35,50,75,100", help="Elo edges to price, in Elo.")parser.add_argument("--ladder-base", type=float, default=0.45, help="Score the ladder sits at.")args = parser.parse_args()El reconfigure es fontanería de Windows: sin él, una consola con página de códigos heredada
revienta al imprimir una tilde. Z = 1.959963985 es el número de desviaciones típicas que deja
fuera el 5 % de una campana con dos colas; escribir Z*Z en vez de 3,84 permite cambiar el nivel de
confianza sin reescribir nada.
def score_for(elo: float) -> float: """The expected score of a player ``elo`` points stronger than its opponent.""" return 1.0 / (1.0 + 10.0 ** (-elo / 400.0))
def match_games(elo: float) -> int: """Games of a head-to-head match needed to separate this edge from zero.""" p = score_for(abs(elo)) return math.ceil(Z * Z * p * (1 - p) / (p - 0.5) ** 2)
def ladder_games(elo: float, base: float) -> int: """Games **per side** needed to separate two ladder estimates this far apart.
The ladder measures a score against fixed rungs, so an edge in Elo has to be converted into the score it moves *there*, which depends on where on the curve the model sits. """ slope = math.log(10) / 400 * base * (1 - base) # d(score) / d(Elo) delta = abs(elo) * slope spread = 2 * math.sqrt(base * (1 - base)) return math.ceil((Z * spread / delta) ** 2)score_for es la curva logística de siempre, de Elo a puntuación esperada, y match_games la
potencia de una proporción contra 0,5. ladder_games es otra cuenta: la escalera mide contra
escalones fijos, así que una ventaja en Elo mueve más o menos puntuación según dónde esté el modelo
en la curva (su derivada en base). Un modelo que anda por el 0,45 mueve más puntuación por Elo que
uno por el 0,95.
El factor 2 del spread quiere ser la resta con la que abre el módulo, y se pasa. Cada escalera
estima una puntuación con varianza base·(1-base)/n; la resta de dos estimaciones independientes
suma las dos varianzas, así que la varianza se duplica y la desviación típica crece √2, no 2.
Con spread = 2·√(base·(1-base)) la varianza sale multiplicada por 4 y la escalera pide el doble de
partidas de las necesarias. Con √2 en su sitio, +50 cuesta 375 partidas por lado, 750 en total,
frente a 185 de enfrentamiento: unas cuatro veces, no 8,1. La conclusión no cambia —medir la
diferencia sigue siendo mucho más barato, y la cuenta aún no incluye el ruido propio de la escalera—,
pero el número que se cita es el cuatro.
print(f"{'ventaja':>9} {'tasa h2h':>9} {'partidas h2h':>13} {'escalera (por lado)':>20} ratio")for text in args.edges.split(","): elo = float(text) match, ladder = match_games(elo), ladder_games(elo, args.ladder_base) print( f"{elo:>+8.0f} {score_for(elo):>9.4f} {match:>13,} {ladder:>20,} " f"{2 * ladder / match:>4.1f}x" )
print( "\nY al reves: con N partidas de enfrentamiento, que ventaja se puede ver.\n" f"{'partidas':>9} {'ventaja minima':>15}")for games in (50, 100, 200, 400, 800): # p - z sqrt(p(1-p)/n) > 0.5, resuelto para p y pasado a Elo. p = 0.5 + Z / (2 * math.sqrt(games)) * math.sqrt(1 + Z * Z / games) / (1 + Z * Z / games) print(f"{games:>9,} {400 * math.log10(p / (1 - p)):>14.0f} Elo")
print( "\nun negativo con pocas partidas no dice que no haya efecto:\n" "dice que el instrumento no lo habria visto")La salida imprime la tabla de precios y, debajo, la misma cuenta al revés, que es como se usa. Sus dos últimas líneas dejan escrita la conclusión del lab: un negativo con pocas partidas no dice que no haya efecto, dice que el instrumento no lo habría visto.
// Ejercicio 01De 50 a 20 Elo: ¿cuántas partidas más?
Cambia la diferencia buscada de 50 a 20 Elo —--edges 20— y vuelve a correrlo. ¿Cuántas
partidas pide el enfrentamiento directo? ¿Y la resta de dos escaleras? ¿Es el mismo factor que con
+50 o cambia con la diferencia?
// SoluciónVer la solución
El factor no se mueve —el script imprime 8.1x otra vez, que con la varianza bien contada son
unas cuatro veces— 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
// Viene delección 5 · La arena: dos modelos jugando entre sí
El instrumento se estrena midiendo algo cuya respuesta ya se sabe.
uv run rukh eval match --a checkpoints/medium-v4-<fecha>/best.pt --b checkpoints/medium-v4-<fecha>/best.pt --games 100El código está entero en la lección 5. La primera vez salió 0,975 con 999 jugadas ilegales, y ese número imposible destapó en un minuto dos errores de fontanería.
// Ejercicio 02El mismo control, con 101 partidas
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 antes de cargar ningún modelo: el comando imprime error: --games must be even and at least 2 (colours are mirrored) y sale con código 2. Si llamas a play_match desde Python, la misma
guarda es un ValueError: «a mirrored match needs an even number of games». Con un número
impar, alguien juega una partida de más con un color. Redondear hacia abajo en silencio
escondería un sesgo que no aparece en ninguna métrica, así que tiene que doler al escribir el
comando.
Lab 3 · Agrupar las dos direcciones de un enfrentamiento
// Viene delección 5 · La arena: dos modelos jugando entre sí
Compara el intervalo agrupado con el de cada dirección (el ejercicio de este lab está en la lección 8, junto a la tabla de aristas). El agrupador junta las dos direcciones de cada par y comprueba si las tres aristas de un triángulo cierran. Su docstring lo llama «Lab 2», con la numeración de una ordenación anterior de los labs; aquí es el 3.
"""Pool the two directions of a match, and show how little the verdict deserves to be trusted.
Lab 2 of M5. `rukh eval match` plays A against B; playing B against A is a different sample of thesame question, and M5 ran both for all three pairs because the first three matches all went towhoever was passed as A and that had to be ruled out.
What came back was worth a lab of its own. For the pair `DPO on-policy` against `DPO off-policy`:
* on as A: +48 Elo, interval 19 to 80 -- **separated from zero**;* off as A: -26 Elo for off, interval -57 to +3 -- **includes zero**.
Same two models, same four hundred games, same opening book, the sides swapped. The estimate movedfrom +48 to +26 in favour of the same model, which is well inside the noise. The **verdict** flippedfrom "this is a measurement" to "this is not". A binary read off the edge of an interval is far lessstable than the number it was read from, and the honest thing to do with two directions is to poolthem:
uv run python labs/m5/pooled_match.py
It reads the `results.json` of every match under `artifacts/eval/` and, for each unordered pair,reports the pooled score, the pooled Elo and its interval over all the games of both directions.It also prints the triangle check: whether the three edges are consistent with a single strengthper model, which for M5's three models they are not."""from __future__ import annotations
import argparseimport itertoolsimport jsonimport mathimport sysfrom pathlib import Path
if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace")
Z = 1.959963985def elo(score: float) -> float: """The same conversion `rukh.eval.match` uses, so the two agree by construction.""" if score <= 0.0: return -1200.0 if score >= 1.0: return 1200.0 return 400.0 * math.log10(score / (1.0 - score))
def elo_interval(score: float, games: int, spread: float) -> tuple[float, float]: """Interval in Elo, from the standard error of the mean score.
The derivative of the Elo curve at ``score`` turns an interval in score into one in Elo. Doing it this way rather than bootstrapping is deliberate: the two directions are separate files with separate game lists, and a closed form over the pooled mean needs no access to either. """ if games <= 1 or not 0.0 < score < 1.0: return (float("nan"), float("nan")) standard_error = spread / math.sqrt(games) slope = 400.0 / (math.log(10.0) * score * (1.0 - score)) half = Z * standard_error * slope return (elo(score) - half, elo(score) + half)elo es la misma conversión que rukh.eval.match, copiada para que el lab no dependa del paquete.
elo_interval, en cambio, no usa bootstrap: sale en forma cerrada, del error típico de la media
por la derivada de la curva de Elo en esa puntuación, porque las dos direcciones son dos ficheros
distintos y una fórmula sobre la media agrupada no necesita ninguno de los dos.
def load(root: Path) -> list[dict]: """Every match report under ``root``, with the fields this lab needs.""" found = [] for path in sorted(root.rglob("results.json")): try: data = json.loads(path.read_text("utf-8")) except (OSError, json.JSONDecodeError): continue if {"a", "b", "score", "played"} <= data.keys(): found.append(data) return foundLa carga se salta cualquier results.json sin las cuatro claves que necesita, así que se puede
lanzar sobre la carpeta entera de evaluaciones sin filtrar nada a mano.
def pooled(entries: list[dict], first: str, second: str) -> dict | None: """Every game between these two, oriented so the score is ``first``'s, in one summary.""" scores: list[float] = [] directions = [] for entry in entries: if {entry["a"], entry["b"]} != {first, second}: continue flip = entry["a"] != first games = [1.0 - game["score"] if flip else game["score"] for game in entry["played"]] scores.extend(games) directions.append( { "as": "B" if flip else "A", "games": len(games), "score": sum(games) / len(games), "elo": entry["elo"] * (-1 if flip else 1), "separated": entry["separated"], } ) if not scores: return None mean = sum(scores) / len(scores) variance = sum((value - mean) ** 2 for value in scores) / max(len(scores) - 1, 1) low, high = elo_interval(mean, len(scores), math.sqrt(variance)) return { "first": first, "second": second, "games": len(scores), "score": mean, "elo": elo(mean), "low": low, "high": high, "separated": low > 0 or high < 0, "directions": directions, "standard_error": math.sqrt(variance) / math.sqrt(len(scores)), "slope": 400.0 / (math.log(10.0) * mean * (1.0 - mean)) if 0 < mean < 1 else float("nan"), }El trabajo lo hace el flip: si el informe tiene los modelos al revés, cada puntuación se convierte
en 1.0 - score, y las 800 partidas quedan como una sola muestra orientada hacia el mismo modelo.
El standard_error y la slope no se imprimen aquí; los necesita el triángulo.
def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", default="artifacts/eval", help="Where the match reports live.") args = parser.parse_args()
entries = load(Path(args.root)) if not entries: raise SystemExit(f"no match reports under {args.root}") names = sorted({name for entry in entries for name in (entry["a"], entry["b"])})
print("Cada par, en las dos direcciones y agrupado.\n") summaries: dict[frozenset[str], dict] = {} for first, second in itertools.combinations(names, 2): summary = pooled(entries, first, second) if summary is None: continue summaries[frozenset((first, second))] = summary print(f"== {first} contra {second} ==") for direction in summary["directions"]: mark = "separado" if direction["separated"] else "incluye el cero" print( f" como {direction['as']} {direction['games']:>4} partidas " f"{direction['score']:.4f} {direction['elo']:+6.0f} Elo {mark}" ) verdict = "separado" if summary["separated"] else "incluye el cero" print( f" agrupado {summary['games']:>4} partidas {summary['score']:.4f} " f"{summary['elo']:+6.0f} Elo IC {summary['low']:+.0f} a {summary['high']:+.0f}" f" {verdict}\n" )Cada par sale con sus dos direcciones y el agrupado: la línea agrupada sola no enseñaría que las direcciones discrepaban.
if len(names) < 3: return print( "El triángulo: si la fuerza fuera un solo número por modelo, las tres aristas cerrarían.\n" ) for trio in itertools.combinations(names, 3): edges = [summaries.get(frozenset(pair)) for pair in itertools.combinations(trio, 2)] if any(edge is None for edge in edges): continue # Orient every edge as "later name minus earlier name" so the three add up to zero when # a single strength per model explains them. values, errors = [], [] for edge, pair in zip(edges, itertools.combinations(trio, 2), strict=True): sign = 1.0 if edge["first"] == pair[0] else -1.0 values.append(sign * edge["elo"]) errors.append(edge["standard_error"] * edge["slope"]) # (a-b) + (b-c) - (a-c) is zero for any consistent set of strengths. residual = values[0] + values[2] - values[1] spread = math.sqrt(sum(error**2 for error in errors)) print(f" {' / '.join(trio)}") print( f" residuo {residual:+.0f} Elo, error típico {spread:.0f} " f"-> {abs(residual) / spread:.1f} sigma" ) print( " " + ( "consistente con un solo número por modelo." if abs(residual) < 2 * spread else "NO es consistente: el emparejamiento importa, no solo la fuerza." ) )
if __name__ == "__main__": main()Y el triángulo: si la fuerza de un modelo fuera un solo número, (a−b) + (b−c) − (a−c) daría cero
para cualquier terna. El residuo es lo que no cierra, y su error típico suma en cuadratura los de
las tres aristas, cada uno pasado a Elo con su pendiente. Con los modelos del hito, unos 45 Elo a
2,6 sigmas.
Lab 4 · Mirar cuánto se mueve la recompensa con la profundidad
// Viene delección 4 · GRPO y la recompensa que un motor verifica
--depth fija la profundidad de las tres tablas de ordenación (12 por defecto). El bloque final
lleva sus 4, 6, 10 y 14 escritos en el script: es la comprobación de cuánto depende de la bandera
todo lo demás.
Es el script más largo de labs/, así que va en dos mitades. Esta es el montaje —posiciones,
grupo de candidatas y bloque de profundidades—; las seis recompensas están en el lab 8, y lo que
midió, en la lección 8. El docstring dice «Lab 3», de la misma ordenación anterior: aquí son el 4 y
el 8.
"""The gallery: five reward designs that are wrong, and what each one actually breaks.
Lab 3 of M5. A verifiable reward is written down by a person, which means it is exactlyspecifiable and therefore exactly gameable. The usual way this gets taught is a story about a boatin a video game spinning in circles, and the trouble with a story is that you cannot check it.
So this lab does not tell you about reward hacking. It takes the reward `rukh.train.rewards`ships, breaks it in five ways that each look reasonable on the page, and measures all six againstthe same candidate group in the same positions.
uv run python labs/m5/reward_hacking.py
**The first version of this lab asked the wrong question and the run said so.** It compared whichmove each reward crowned, and all six crowned the same move in every position -- which is obviousin hindsight, since every one of them is monotone in the engine's score. The top of the ranking isnot where reward hacking lives.
What it measures instead is what the optimiser actually consumes. GRPO does not see the reward: itsees ``(r - mean) / std`` over the group. So the three columns are
* **corona** -- the move on top, kept because the negative result is part of the lesson;* **cabeza** -- the advantage gap between the best candidate and the second best, which is how much signal is left to tell a good move from a decent one;* **peor** -- the share of the group's whole advantage mass absorbed by its single worst candidate, which is how much of the step one blunder gets to decide.
And then the inversions: pairs of candidates that the sound reward and a broken one put in theopposite order. An inversion is the hack in its smallest possible form -- one position, two moves,a reason you can read.
The candidate group is built the way GRPO's actually is, illegal proposals included, because agate with nothing to gate proves nothing."""El docstring cuenta el fracaso de la primera versión (las seis recompensas coronaban la misma
jugada) y por qué el lab pasó a medir (r − media) / desviación sobre el grupo, de donde salen las
columnas cabeza, peor e inversiones. La columna corona se queda porque el resultado negativo
también enseña.
from __future__ import annotations
import argparseimport sysfrom dataclasses import dataclass
import chessimport chess.engine
if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from rukh.data.scoring import MATE_SCORE # noqa: E402from rukh.engine import find_stockfish # noqa: E402from rukh.train.grpo import group_advantages # noqa: E402from rukh.train.rewards import ( # noqa: E402 RewardWeights, illegal_value, mate_bonus, normalised_delta_cp, repetition_penalty,)
WEIGHTS = RewardWeights()CP_SCALE = WEIGHTS.cp_scaleLos noqa: E402 le dicen al linter que los imports van después del reconfigure a propósito. Y
fíjate de dónde importa: group_advantages de rukh.train.grpo y los términos de
rukh.train.rewards. El lab usa las mismas funciones que el entrenamiento, y por eso lo que mide
significa algo.
@dataclass(frozen=True)class Case: """A position, the moves that led to it, and the mistake it is here to expose."""
name: str about: str fen: str = "" moves: tuple[str, ...] = ()
CASES = ( Case( name="una pifia a mano", about="an ordinary position with a 900-centipawn blunder available", fen="r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 4 4", ), Case( name="hay mate", about="a back-rank mate in one, beside moves that merely keep winning", fen="6k1/5ppp/8/8/8/8/5PPP/R5K1 w - - 0 1", ), Case( name="ganando y repitiendo", about="White is a queen up with no mate in one, and the repetition is on the board", fen="6k1/5pp1/7p/8/8/8/5PPP/3Q2K1 w - - 0 1", moves=("d1d2", "g8h7", "d2d1", "h7g8"), ),)Tres posiciones, cada una para que aparezca un fallo distinto: una con una pifia de 900 centipeones
disponible, una con mate en uno junto a jugadas que siguen ganando, y una con una dama de ventaja y
la repetición ya en el tablero. Esta última se construye con moves, porque la repetición es una
propiedad del historial y un FEN suelto no la expresa.
def illegal_candidates(board: chess.Board, count: int = 2) -> list[str]: """Moves a model could plausibly propose here and that cannot be played.
Generated rather than written down, because the first version of this lab hard-coded two per position and both turned out to be **legal** -- ``Ra1a7`` and ``Qd1d8`` are perfectly ordinary moves. The gallery then reported inversions that were an artefact of the labelling. Asking ``python-chess`` is the only way to be sure.
They start from a square that really does hold one of the mover's pieces, so they look like proposals rather than noise: that is the shape of the illegal moves a decoder actually emits. """ legal = {move.uci() for move in board.legal_moves} found: list[str] = [] for from_square in chess.scan_forward(board.occupied_co[board.turn]): for to_square in chess.SQUARES: if from_square == to_square: continue uci = chess.square_name(from_square) + chess.square_name(to_square) if uci not in legal and board.piece_at(to_square) is None: found.append(uci) break if len(found) >= count: break return found[:count]Las ilegales no se escriben a mano porque las dos de la primera versión, Ta1a7 y Dd1d8, eran
legales en esas posiciones, y la galería reportó inversiones que solo eran un error de etiquetado.
Las que se generan salen de una casilla con una pieza del bando que mueve, para que parezcan
propuestas de un decoder y no ruido.
def board_of(case: Case) -> chess.Board: board = chess.Board(case.fen) if case.fen else chess.Board() for uci in case.moves: board.push_uci(uci) return board
def analyse(engine: chess.engine.SimpleEngine, board: chess.Board, depth: int) -> dict[str, int]: """Centipawns for the mover, for every legal move.""" scores: dict[str, int] = {} for move in board.legal_moves: board.push(move) info = engine.analyse(board, chess.engine.Limit(depth=depth)) scores[move.uci()] = int(info["score"].pov(not board.turn).score(mate_score=MATE_SCORE)) board.pop() return scores
def candidate_group(board: chess.Board, scores: dict[str, int]) -> list[tuple[str, int | None]]: """A group shaped like one GRPO would draw: good, middling, awful, repeating and illegal.
Hand-picked rather than sampled, because the point is to give every failure mode somewhere to go. A real group is drawn from the policy and is usually far less interesting, which is its own reason these bugs survive a smoke test. """ ranked = sorted(scores.items(), key=lambda pair: pair[1], reverse=True) picked: dict[str, int | None] = {} for uci, cp in (ranked[0], ranked[1], ranked[len(ranked) // 2], ranked[-1]): picked[uci] = cp for uci, cp in ranked: if repetition_penalty(board, chess.Move.from_uci(uci)) > 0: picked[uci] = cp break for uci in illegal_candidates(board): picked.setdefault(uci, None) return list(picked.items())candidate_group construye un grupo con la forma de uno de GRPO: la mejor, la segunda, una del
medio, la peor, una que repite si la hay y dos ilegales. Está elegido a mano para que cada modo de
fallo tenga dónde aparecer. Un grupo sorteado de la política real casi nunca es tan interesante, y
por eso estos fallos sobreviven a una prueba de humo.
DEPTH_CASE = Case( name="rey y torre contra rey", about="every move wins; only the search depth decides which ones the engine calls a mate", fen="7k/8/6K1/8/8/8/8/R7 w - - 0 1",)DEPTH_MOVE = "g6f6"
def depth_check(binary: object, depths: tuple[int, ...]) -> None: """The same move, four search depths: how much of each reward is really about the position.
This is the half of mistake 3 that no ranking can show, and the answer turned out to be worse than the one the lab was written to give. ``Kf6`` in a king-and-rook endgame is scored 597 at depth 4 and 9997 at depth 6 -- not because the move changed, but because the engine found the mate. So the reward of a fixed move moves with the analysis budget, and it moves for **every** design that reads the engine's number, including the sound one.
The only term that does not move is the flat mate bonus, because it asks the board whether this move is checkmate rather than asking the engine how it feels about the position. Which gives the rule the lesson keeps: **pin the depth, record it in the run, and never compare two runs that used different ones** -- the same conclusion P4 reached about the clock (D-107). """ board = board_of(DEPTH_CASE) move = chess.Move.from_uci(DEPTH_MOVE) print("== el mismo movimiento a cuatro profundidades ==") print(f" {DEPTH_CASE.name}, {DEPTH_MOVE}") print(f" {DEPTH_CASE.about}") header = " " + f"{'recompensa':<22}" + "".join(f"{d:>9}" for d in depths) + f"{'rango':>9}" print(header) values: dict[str, list[float]] = {label: [] for label in REWARDS} engine_says: list[int] = [] with chess.engine.SimpleEngine.popen_uci(str(binary)) as engine: # type: ignore[arg-type] for depth in depths: scores = analyse(engine, board, depth) best = max(scores.values()) group = candidate_group(board, scores) span = float(best - min(cp for _uci, cp in group if cp is not None)) engine_says.append(scores[DEPTH_MOVE]) for label, function in REWARDS.items(): values[label].append( float(function(board, move, scores[DEPTH_MOVE], best, span)) # type: ignore[operator] ) shown = "".join(f"{cp:>9}" for cp in engine_says) print(f" {'(el motor dice)':<22}{shown}") for label, series in values.items(): spread = max(series) - min(series) row = "".join(f"{value:>9.3f}" for value in series) print(f" {label:<22}{row}{spread:>9.3f}") print() print( " Ninguna es estable, y el rango dice cuanto: la sana se mueve 0.99, que es todo su\n" " intervalo legal; la que no tiene suelo se mueve 47.0, cuarenta y siete veces ese\n" " intervalo. La unica pieza que no se mueve es el bono de mate plano, porque le\n" " pregunta al tablero y no al motor.\n" ) print( " Y hay una fila que se mueve sin que el motor se mueva: la reescalada sola pasa de\n" " 1.000 a 0.667 entre profundidad 10 y 14 con el mismo 9998 delante. No cambio la\n" " jugada ni la posicion ni la evaluacion: cambio el grupo, que es lo unico que esa\n" " recompensa mira. De ahi la regla: fija la profundidad, escribela en el run y no\n" " compares dos runs con profundidades distintas.\n" )El bloque de profundidades: Kf6 en un final de rey y torre contra rey, a cuatro profundidades.
values guarda una serie por recompensa y se imprime el rango de cada una; la tabla está en la
lección 8.
// Ejercicio 03El motor dijo lo mismo y la recompensa cambió: ¿qué cambió?
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 ejecuciones suyas no se pueden comparar ni entre sí.
Lab 5 · Construir pares con las jugadas del propio modelo
// Viene delección 3 · DPO: la referencia implícita, y de dónde salen las dos jugadas
El código está entero en la lección 3, con su giro de signo, su sorteo de más y sus dos motivos de descarte.
// Ejercicio 04Los pares que no salieron, y las dos razones
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: el modelo propone su moda cuatro veces de cada
cuatro en casi todas las posiciones. El muestreo casi determinista que hace estables los números
publicados es lo mismo que impide construir un par.
Lab 6 · DPO con dos fuentes de pares, y la trampa de no igualarlas
// Viene delección 3 · DPO: la referencia implícita, y de dónde salen las dos jugadas
uv run rukh train dpo --config configs/train/dpo-onpolicy.yamluv run rukh train dpo --config configs/train/dpo-offpolicy.yamlLas dos configuraciones están en la lección 3, y las dos traen en checkpoint: la carpeta de la
máquina de referencia: cámbiala por tu medium-v4 en las dos antes de lanzarlas, porque
rukh train dpo no tiene bandera para eso. La segunda lee data/pairs-offpolicy-matched/, el
conjunto original submuestreado a 6 386 pares con el script de cinco líneas de esa lección, que hay
que correr antes del segundo comando. Con los dos brazos igualados en pares, cada uno tarda unos
cuatro minutos. El ejercicio de este lab, qué medirías si no los igualaras, está allí.
Lab 7 · Ventajas de grupo a mano
// Viene delección 4 · GRPO y la recompensa que un motor verifica
Sin GPU ni motor, para ver con tus números el paso que más se malinterpreta: a la política la mueve la distancia de cada recompensa a la media del grupo.
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 05Multiplica por diez las recompensas del grupo, y súmales diez
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. Sumar una constante mueve la media con ella (la misma invariancia de Bradley-Terry), y multiplicar por diez multiplica también la desviación. Esa doble invariancia permite usar la ventaja sin calibrar la recompensa, y también hace que un grupo de ocho pifias y uno de ocho jugadas excelentes produzcan ventajas del mismo tamaño.
Lab 8 · Romper la recompensa a propósito
// Viene delección 4 · GRPO y la recompensa que un motor verifica
uv run python labs/m5/reward_hacking.py --depth 12La otra mitad del script: las seis recompensas y lo que la galería mide de ellas. El comentario que las gobierna arregla el segundo error del lab.
# --- the six rewards --------------------------------------------------------------------------## Every variant is the sound reward with **exactly one** term replaced. That constraint is what# makes the inversion column readable: the first version of this lab wrote each broken reward from# scratch, so four of them silently dropped the repetition term too and every one of them reported# the same inversion. A gallery whose exhibits differ in more than one way explains nothing.## ``cp`` is ``None`` for a move that cannot be played, which is the moment each design has to say# what it believes. ``span`` is the group's own spread, which only the self-scaling one looks at.
def _quality(cp: int, best: int) -> float: return normalised_delta_cp(cp, best, CP_SCALE)
def _mate(board: chess.Board, move: chess.Move) -> float: return mate_bonus(board, move)
def _repetition(board: chess.Board, move: chess.Move) -> float: return repetition_penalty(board, move)
def _assemble(quality: float, mate: float, repetition: float) -> float: return WEIGHTS.quality * quality + WEIGHTS.mate * mate - WEIGHTS.repetition * repetition
def sound(board: chess.Board, move: chess.Move, cp: int | None, best: int, span: float) -> float: """What `rukh.train.rewards.reward` computes: gated, capped, flat mate, repetition paid.""" if cp is None: return illegal_value(WEIGHTS) return _assemble(_quality(cp, best), _mate(board, move), _repetition(board, move))Cada variante es la recompensa sana con un solo término sustituido, y eso hace legible la columna de inversiones.
Los ayudantes _quality, _mate, _repetition y _assemble son las piezas que cada variante
recombina, y sound calcula línea por línea lo mismo que rukh.train.rewards.reward.
def uncapped(board: chess.Board, move: chess.Move, cp: int | None, best: int, span: float) -> float: """**Mistake 1: no floor under the quality term.** Only the quality term changes.
``(cp - best) / cp_scale`` reads like the same thing as the clamped version and is not: it has no bottom, so one catastrophic candidate sets the group's standard deviation by itself and every other advantage is divided by it. Watch the ``peor`` column, not the crown. """ if cp is None: return illegal_value(WEIGHTS) return _assemble((cp - best) / CP_SCALE, _mate(board, move), _repetition(board, move))Fallo 1: sin suelo en el término de calidad. (cp - best) / cp_scale parece la versión
recortada, pero no tiene fondo: una sola candidata catastrófica fija la desviación del grupo y todas
las demás ventajas se dividen entre ella. Lo enseña la columna peor. Y rompe la puerta sin
tocarla: las dos ilegales salen en +0,18, por encima de una legal que saca −0,15.
def legality_as_a_bonus( board: chess.Board, move: chess.Move, cp: int | None, best: int, span: float) -> float: """**Mistake 2: legality is a term in the sum instead of a gate.** Only the gate changes.
``+2 when legal`` reads as a strong preference and is in fact a *price*, which any other term worth more than two can pay. An illegal move has no engine score, so this design does the generous thing and treats the missing quality as no loss at all -- which is precisely how the bug gets in. """ legal = move in board.legal_moves quality = _quality(cp, best) if cp is not None else 0.0 return (2.0 if legal else 0.0) + _assemble( quality, _mate(board, move) if legal else 0.0, _repetition(board, move) if legal else 0.0 )Fallo 2: la legalidad como término de la suma. +2 si es legal parece una preferencia fuerte y
es un precio, que cualquier término que valga más de dos puede pagar. Y como una ilegal no tiene
evaluación del motor, esta versión trata su calidad ausente como «ninguna pérdida»: por ahí entra
el fallo.
def mate_by_engine_score( board: chess.Board, move: chess.Move, cp: int | None, best: int, span: float) -> float: """**Mistake 3: paying for the engine's mate score instead of for the mate.** Mate term only.
``MATE_SCORE - distance`` is right there in the data and it is tempting. Two things go wrong and both are visible below: the term stops being bounded, so a single mating candidate takes the whole group's advantage mass; and the number it pays **depends on the search depth**, because a deeper engine reports a shorter mate for the same move. A reward that changes when the analysis budget changes is a reward that cannot be reproduced -- which is the same trap P4 fell into with a time-limited engine (D-107), arriving from a different direction. """ if cp is None: return illegal_value(WEIGHTS) mate = (abs(cp) / 1000.0) if abs(cp) >= MATE_SCORE - 100 else 0.0 return _assemble(_quality(cp, best), mate, _repetition(board, move))Fallo 3: pagar por el marcador de mate del motor. MATE_SCORE − distancia rompe dos cosas: el
término deja de estar acotado, así que la candidata que mata se lleva toda la masa de ventaja; y lo
que paga depende de la profundidad, porque un motor más profundo reporta un mate más corto para la
misma jugada.
def no_repetition_penalty( board: chess.Board, move: chess.Move, cp: int | None, best: int, span: float) -> float: """**Mistake 4: rewarding the position instead of the progress.** Repetition term only.
Drop it and a winning model discovers something true: repeating costs nothing. The evaluation after the repetition is the evaluation before it, so the reward is the same, and shuffling is a strictly safe way to keep collecting it. Nothing in the numbers is wrong, which is what makes this the most dangerous of the five. """ if cp is None: return illegal_value(WEIGHTS) return _assemble(_quality(cp, best), _mate(board, move), 0.0)Fallo 4: premiar la posición en vez del progreso. Sin el término de repetición, un modelo que va ganando descubre que repetir no cuesta nada: la evaluación es la misma, la recompensa también, y dar vueltas es una forma segura de seguir cobrándola. Ningún número está mal, y por eso es la más peligrosa de las cinco: es el agente que, premiado por no equivocarse, aprende a no hacer nada.
def self_scaling( board: chess.Board, move: chess.Move, cp: int | None, best: int, span: float) -> float: """**Mistake 5: a reward that rescales itself to whatever it is looking at.** Quality only.
Normalising by the spread of *this* group instead of by a fixed constant makes every group look equally informative, so eight excellent moves and eight blunders produce advantages of the same size and the model is told, with equal confidence, to prefer the least bad blunder. It also stops the number being comparable between two runs, so nothing can tell a run it has stopped improving. """ if cp is None: return illegal_value(WEIGHTS) return _assemble( 1.0 - (best - cp) / max(span, 1.0), _mate(board, move), _repetition(board, move) )
REWARDS = { "sana": sound, "sin suelo": uncapped, "legalidad como bonus": legality_as_a_bonus, "mate por marcador": mate_by_engine_score, "sin penalizar repetir": no_repetition_penalty, "reescalada sola": self_scaling,}Fallo 5: una recompensa que se reescala a lo que tiene delante. Normalizar por la dispersión de este grupo hace que todos parezcan igual de informativos: ocho pifias producen ventajas del mismo tamaño que ocho jugadas excelentes, y al modelo se le pide con la misma confianza que prefiera la pifia menos mala. Además, el número deja de ser comparable entre ejecuciones.
def profile( board: chess.Board, group: list[tuple[str, int | None]], function: object, best: int) -> tuple[str, float, float, dict[str, float]]: """Crowned move, head gap, worst-candidate dominance, and the advantage of each candidate.""" scored = [cp for _uci, cp in group if cp is not None] span = float(best - min(scored)) if scored else 1.0 paid = { uci: float(function(board, chess.Move.from_uci(uci), cp, best, span)) # type: ignore[operator] for uci, cp in group } order = sorted(paid, key=lambda uci: paid[uci], reverse=True) advantages = dict( zip(paid, group_advantages([paid[uci] for uci in paid]).tolist(), strict=True) ) mass = sum(abs(value) for value in advantages.values()) or 1.0 head = advantages[order[0]] - advantages[order[1]] worst = min(advantages.values()) return order[0], head, abs(worst) / mass, advantages
def inversions(reference: dict[str, float], other: dict[str, float]) -> list[tuple[str, str]]: """Pairs the two rewards order the opposite way round. Ties on either side do not count.""" names = list(reference) found = [] for index, first in enumerate(names): for second in names[index + 1 :]: sound_says = reference[first] - reference[second] other_says = other[first] - other[second] if sound_says * other_says < 0: found.append((first, second) if sound_says > 0 else (second, first)) return foundprofile calcula las ventajas con el group_advantages del entrenamiento: la galería no simula lo
que GRPO haría, se lo pregunta. inversions solo cuenta una inversión cuando el producto de las dos
diferencias es estrictamente negativo, porque un empate no es una opinión contraria.
def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--depth", type=int, default=12, help="Engine depth per legal move.") args = parser.parse_args()
binary = find_stockfish() if binary is None: raise SystemExit("Stockfish not found; set RUKH_STOCKFISH")
print("La galeria: el mismo grupo de candidatas, seis recompensas, lo que cada una rompe.\n") with chess.engine.SimpleEngine.popen_uci(str(binary)) as engine: for case in CASES: board = board_of(case) scores = analyse(engine, board, args.depth) best = max(scores.values()) group = candidate_group(board, scores) shown = ", ".join(f"{uci}({'ilegal' if cp is None else cp})" for uci, cp in group) print(f"== {case.name} ==") print(f" {case.about}") print(f" grupo: {shown}") print(f" {'recompensa':<22} {'corona':<7} {'cabeza':>8} {'peor':>7} inversiones") reference: dict[str, float] = {} for label, function in REWARDS.items(): crowned, head, worst, advantages = profile(board, group, function, best) if not reference: reference = advantages flips = "" else: pairs = inversions(reference, advantages) listed = ", ".join(f"{a}>{b}" for a, b in pairs[:3]) flips = (listed + (" ..." if len(pairs) > 3 else "")) if pairs else "-" print(f" {label:<22} {crowned:<7} {head:>8.3f} {worst:>6.1%} {flips}") print()
depth_check(binary, (4, 6, 10, 14)) print( "Lo que mide, en una linea: la corona casi nunca cambia -- todas son monotonas en la\n" "evaluacion -- y el dano esta en la forma del grupo, no en su cima." )
if __name__ == "__main__": main()El bucle principal imprime las tres posiciones y llama al bloque de profundidades. Su última frase resume el lab: la corona casi nunca cambia, y el daño está en la forma del grupo.
// Ejercicio 06Una sexta recompensa rota: el mate a 2,0
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, así que pagarlo más no cambia el orden— y la cabeza se dispara, porque la masa de ventaja se va a la candidata que mata y las demás se aplastan. Es el daño de «sin suelo» por el otro extremo: tirando de arriba o empujando desde abajo, 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
// Viene delección 2 · El reward model: una cabeza escalar sobre el encoder de M3
Un encoder de casillas con una cabeza escalar, entrenado con Bradley-Terry sobre los pares fuera de política. Cuatro minutos, desde cero, porque partir del encoder de M3 estorba (62 % frente a 69 % a la misma talla).
uv run rukh train reward --config configs/train/rm.yamlEl bucle entero está en la lección 2. Hay dos ejecuciones 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 resolverá checkpoints/rm cuando llegue resolve_run en M6. La salida de abajo es
la de la semilla 1 (checkpoints/rm-seed1-20260920-212134/), la ejecución cuyas bandas dibuja la
figura de la lección 8 y de la que salen las dos correlaciones publicadas:
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 07Tres semillas, un titular
Corre la misma config con seed: 1, 2 y 3. No se pisan, porque cada ejecución escribe en
checkpoints/<run_name>-<fecha>/, pero cambia también run_name (rm-seed1, …) o solo la hora
de la carpeta te dirá cuál es cuál. 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 el número de pares de mate en validación mueve el titular, y la banda de mate, siempre la peor, es la que lo mueve. Dos semillas distintas no dicen nada del modelo, y dos ejecuciones con la misma tampoco se repiten: la config publicada dio 72,91 % y 74,21 %. Ese 1,3 es el suelo de reproducibilidad de la GPU, y cualquier diferencia que publiques tiene que superarlo.
Lab 10 · GRPO a dos tasas, y elegir sin restar
// Viene delección 4 · GRPO y la recompensa que un motor verifica y lección 5 · La arena: dos modelos jugando entre sí
Las posiciones se leen del fichero de pares y las etiquetas se ignoran. Ocho candidatas por
posición, el motor a profundidad 8 y una caché en disco. La ejecución de referencia son 1 500 pasos;
grpo.yaml deja 300 para que la primera vuelta tarde minutos. La línea checkpoint: de grpo.yaml
tiene que apuntar ya a tu medium-v4, como pidió la lección 4; la copia rápida que escribe la segunda línea la
hereda.
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.pyuv run rukh train grpo --config configs/train/grpo.yaml --steps 1500(Get-Content configs/train/grpo.yaml) -replace '^lr: 1.0e-6', 'lr: 5.0e-6' | Set-Content 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 08Dos intervalos que se solapan: ¿cuál publicas?
Las dos ejecuciones 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, el error que el propio hito cometió. Enfrentadas entre sí, con las dos direcciones agrupadas, empatan: −0,87 Elo con intervalo de −22,2 a +20,4. Con un empate medido desempata el coste, y la lenta gana en todo: menos grupos planos (0,472 frente a 0,560), menos KL (0,122 frente a 0,555) y menos ilegales (1,66 veces la base frente a 2,08). Se publica la lenta.
Lab 11 · La suite, la exportación y el Hub
// Viene delección 6 · Publicar lo alineado: un reward model no es una política
El módulo se cierra con la fila de la tabla única, medida con la misma config y 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 export --ckpt checkpoints/medium-v4-grpo/grpo.pt --out artifacts/onnx/medium-grpo --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 model --ckpt checkpoints/medium-v4-grpo/grpo.pt --repo chorcat/rukh-medium-grpo --stage medium-v4-grpo-greedy --onnx artifacts/onnx/medium-grpo --dry-runuv run rukh publish reward --run checkpoints/rm-<fecha> --repo chorcat/rukh-rm --dry-runuv run rukh data publish --name rukh-pairs-onpolicy --dry-runSon dos modelos, DPO y GRPO, y cada uno pasa por lo mismo: la evaluación canónica (unos 26
minutos), la exportación a ONNX (unos 12) y la publicación. Cada publish model lee la carpeta que
escribió su export y la fila que escribió su eval, así que va detrás de los dos. --dry-run
deja la carpeta en artifacts/publish/ sin tocar la red. Para publicar en tu cuenta, cambia chorcat por tu
usuario en los --repo (lección 6) y quita --dry-run. Recuerda de la lección 6 que el --stage del
publish tiene que ser el mismo de la evaluación.
// Ejercicio 09Cuánto tiene que pesar el `model.safetensors`
Antes de quitar --dry-run, lista la carpeta preparada con ls -la (Get-ChildItem -Force en
PowerShell). ¿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). Si ves unos KB, es el modelo de juguete de un test mal aislado, como el de 25 KB que cuenta la lección 6.
Lo que te llevas
El aprendiz que empezó el módulo imitando partidas ajenas ahora juega mejor que antes de que llegara el entrenador, y se puede demostrar: DPO con pares de Lichess, DPO con sus propias dudas y GRPO con una recompensa que el motor verifica le ganan los tres a su versión anterior. También ha enseñado por qué el entrenador tiene que vigilarlo: en cuanto la nota se puede subir sin jugar mejor —proponiendo siempre la misma jugada, repitiendo posiciones—, aprende a agradar al juez. Le pasa lo mismo a un asistente alineado con preferencias cuando descubre que las respuestas largas o aduladoras puntúan más.
Del módulo salen tres reglas de medida, y ninguna es exclusiva del ajedrez:
- Para medir una diferencia, mide la diferencia. La regla falló aquí tres veces por tres caminos —restando dos escaleras, restando dos enfrentamientos en un triángulo que no cierra y eligiendo entre dos GRPO por lo que hicieron contra un tercero—, y las tres veces el enfrentamiento directo dio otra respuesta. Dos de las tres, 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, 72,91 % y 74,21 %; un veredicto se dio la vuelta al intercambiar los lados.
- Antes de publicar una métrica, describe la política que la maximizaría. Esa pregunta encontró que la recompensa que GRPO optimiza se maximiza dejando de ser diverso.
Lo siguiente es M6, el cierre de la primera fase: todas las etapas medidas el mismo día, las model cards 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.
// el recorrido · lo conseguido
- M0 El tallerEntorno reproducible(hecho)
- M1 Datos y tokensDatos y tokenización(hecho)
- M2 El decoderPreentrenar un GPT(hecho)
- M3 El encoderEmbeddings y clasificar(hecho)
- M4 Fine-tuningSFT y LoRA(hecho)
- M5 AlineamientoPreferencias: DPO, GRPO(hecho)
- M6 Evaluar y publicarMedir y desplegar(por delante)
- FASE 2 El entrenadorAgentes, RAG, MCP
lo que tienes ahora
Tres métodos que mejoran a su base con el margen de error separado del cero (DPO +65 y +57, GRPO +44 Elo) y una galería de trampas a la recompensa, medidas.
siguiente parada · M6 Evaluar y publicar
Toca competir en serio: torneos con rating oficial, una ficha de jugador que cuenta la verdad y partidas en público.
// cheatsheet M5
Doce 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. Es el mismo salto que convierte un LLM base en un asistente: el RLHF.
- 02¿Cuánto más barato es un enfrentamiento directo, y de dónde sale ese número?
- Unas cuatro veces, y sale de dos fórmulas de potencia. Distinguir dos proporciones que difieren en Δp pide `n > z²·2p(1−p)/(Δp)²` partidas **en cada lado** (el 2 es la varianza de la resta); detectar una ventaja en un enfrentamiento pide `n > z²·p(1−p)/(p−0,5)²` **en total**. Para +50 Elo son 375 partidas por lado en la escalera, 750 en total, frente a 185 de frente, y eso sin contar el ruido propio de la escalera. El script del lab imprime `8.1x` porque pone un 2 donde la desviación típica de la resta pide √2, y al elevarlo al cuadrado cuenta dos veces la varianza. 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.
- 03¿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.
- 04¿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.
- 05¿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 tira esa referencia: con un `β` grande basta alejarse poco para que la pérdida quede satisfecha. Cumple el papel de la penalización KL del RLHF clásico y de GRPO. Rukh entrena con `β` 0,1 y no ha medido otros valores.
- 06¿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.
- 07¿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.
- 08¿Qué hace GRPO que PPO hace más caro?
- El baseline. 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.
- 09¿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.
- 10¿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. 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.
- 11¿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.
- 12En GRPO, ¿qué hay que mirar además de que suba la recompensa?
- Que suba la recompensa buena, la medida contra la mejor jugada del motor (`engine best`) y no contra el mejor del grupo, y a la vez la proporción de grupos planos y la KL. Una recompensa verificable **siempre** puede subir: está escrita, y lo que está escrito se puede optimizar. La del grupo sube también cuando el modelo colapsa, porque ocho veces la misma jugada es su propio mejor; los grupos planos son el detector de ese colapso. Si la del motor sube, los planos no se disparan y la KL se queda pequeña, es una mejora; si los planos o la KL se disparan, es un síntoma. Los ilegales solo dicen algo con `restrict_to_legal: false`: con `true`, el de las ejecuciones publicadas, salen siempre 0. Y para elegir entre dos ejecuciones, ni esto: se enfrentan.