rukh · lab

// 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.

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

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

LabRutaRelojDejaAtajo
Lab 1¿Cuántas partidas cuesta la pregunta?imprescindiblesegundosla tabla de precios, en pantallano hay
Lab 2El control que tiene que dar 0,5000imprescindible~1 min0,5000 clavado; si no, el instrumento está rotono hay
Lab 3Agrupar las dos direcciones de un enfrentamientocuesta máquina~4 min por direcciónla tabla agrupada y el residuo del triángulono hay
Lab 4Cuánto se mueve la recompensa con la profundidad (necesita la mitad del script del Lab 8)imprescindible~2 minlas tablas de la galería de reward hackingno hay
Lab 5Construir pares con las jugadas del propio modelocuesta máquina~35 min el corpus entero; el recorte de 500, minutosdata/pairs-onpolicy/pairs.parquet y su manifiestorukh pull rukh-pairs-onpolicy
Lab 6DPO con dos fuentes de pares, y la trampa de no igualarlascuesta máquina~4 min cada brazo, los dos con 6 386 parescheckpoints/medium-v4-dpo-{on,off}policy/dpo.ptrukh pull medium-v4-dpo-onpolicy
Lab 7Ventajas de grupo a manosolo observar—la resta de la media del grupo, hecha a lápizno hay
Lab 8Romper la recompensa a propósitoimprescindible~2 minlos pares que la recompensa rota invierteno hay
Lab 9El reward model, y por qué su número depende de la semillacuesta máquina~4 min por semillacheckpoints/rm-<fecha>/reward.pt y run.json (checkpoints/rm/ si lo bajas)rukh pull rm
Lab 10GRPO a dos tasas, y elegir sin restarcuesta máquina~12 min cada tasa + ~4 por dirección del duelocheckpoints/medium-v4-grpo*/grpo.ptrukh pull medium-v4-grpo
Lab 11La suite, la exportación y el Hub, para DPO y para GRPOcuesta máquina~26 min por evaluación + 12 por exportación, dos de cadalas filas de la tabla y los repos del Hubno 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.

labs/m5/games_needed_match.py
"""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 an
interval** 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 one
cannot 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 a
match 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.**
"""

labs/m5/games_needed_match.pylíneas 1-19 · p5

labs/m5/games_needed_match.py
import argparse
import math
import 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()

labs/m5/games_needed_match.pylíneas 21-33 · p5

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.

labs/m5/games_needed_match.py
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)

labs/m5/games_needed_match.pylíneas 36-56 · p5

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.

labs/m5/games_needed_match.py
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"
)

labs/m5/games_needed_match.pylíneas 59-80 · p5

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.

Terminal
uv run rukh eval match --a checkpoints/medium-v4-<fecha>/best.pt --b checkpoints/medium-v4-<fecha>/best.pt --games 100

El 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.

labs/m5/pooled_match.py
"""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 the
same question, and M5 ran both for all three pairs because the first three matches all went to
whoever 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 moved
from +48 to +26 in favour of the same model, which is well inside the noise. The **verdict** flipped
from "this is a measurement" to "this is not". A binary read off the edge of an interval is far less
stable than the number it was read from, and the honest thing to do with two directions is to pool
them:
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 strength
per model, which for M5's three models they are not.
"""

labs/m5/pooled_match.pylíneas 1-24 · p5

labs/m5/pooled_match.py
from __future__ import annotations
import argparse
import itertools
import json
import math
import sys
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
Z = 1.959963985

labs/m5/pooled_match.pylíneas 26-38 · p5

labs/m5/pooled_match.py
def 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)

labs/m5/pooled_match.pylíneas 41-62 · p5

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.

labs/m5/pooled_match.py
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 found

labs/m5/pooled_match.pylíneas 65-75 · p5

La 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.

labs/m5/pooled_match.py
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"),
}

labs/m5/pooled_match.pylíneas 78-114 · p5

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.

labs/m5/pooled_match.py
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"
)

labs/m5/pooled_match.pylíneas 117-146 · p5

Cada par sale con sus dos direcciones y el agrupado: la línea agrupada sola no enseñaría que las direcciones discrepaban.

labs/m5/pooled_match.py
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()

labs/m5/pooled_match.pylíneas 148-183 · p5

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.

labs/m5/reward_hacking.py
"""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 exactly
specifiable and therefore exactly gameable. The usual way this gets taught is a story about a boat
in 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 against
the 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 which
move each reward crowned, and all six crowned the same move in every position -- which is obvious
in hindsight, since every one of them is monotone in the engine's score. The top of the ranking is
not where reward hacking lives.
What it measures instead is what the optimiser actually consumes. GRPO does not see the reward: it
sees ``(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 the
opposite 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 a
gate with nothing to gate proves nothing.
"""

labs/m5/reward_hacking.pylíneas 1-33 · p5

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.

labs/m5/reward_hacking.py
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
import chess
import chess.engine
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
from rukh.data.scoring import MATE_SCORE # noqa: E402
from rukh.engine import find_stockfish # noqa: E402
from rukh.train.grpo import group_advantages # noqa: E402
from rukh.train.rewards import ( # noqa: E402
RewardWeights,
illegal_value,
mate_bonus,
normalised_delta_cp,
repetition_penalty,
)
WEIGHTS = RewardWeights()
CP_SCALE = WEIGHTS.cp_scale

labs/m5/reward_hacking.pylíneas 35-59 · p5

Los 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.

labs/m5/reward_hacking.py
@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"),
),
)

labs/m5/reward_hacking.pylíneas 62-89 · p5

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.

labs/m5/reward_hacking.py
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]

labs/m5/reward_hacking.pylíneas 92-115 · p5

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.

labs/m5/reward_hacking.py
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())

labs/m5/reward_hacking.pylíneas 118-153 · p5

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.

labs/m5/reward_hacking.py
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"
)

labs/m5/reward_hacking.pylíneas 313-374 · p5

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

Terminal
uv run rukh train dpo --config configs/train/dpo-onpolicy.yaml
uv run rukh train dpo --config configs/train/dpo-offpolicy.yaml

Las 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 torch
from 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

Terminal
uv run python labs/m5/reward_hacking.py --depth 12

La 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.

labs/m5/reward_hacking.py
# --- 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))

labs/m5/reward_hacking.pylíneas 156-187 · p5

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.

labs/m5/reward_hacking.py
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))

labs/m5/reward_hacking.pylíneas 190-199 · p5

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.

labs/m5/reward_hacking.py
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
)

labs/m5/reward_hacking.pylíneas 202-216 · p5

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.

labs/m5/reward_hacking.py
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))

labs/m5/reward_hacking.pylíneas 219-234 · p5

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.

labs/m5/reward_hacking.py
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)

labs/m5/reward_hacking.pylíneas 237-249 · p5

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.

labs/m5/reward_hacking.py
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,
}

labs/m5/reward_hacking.pylíneas 252-277 · p5

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.

labs/m5/reward_hacking.py
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 found

labs/m5/reward_hacking.pylíneas 280-310 · p5

profile 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.

labs/m5/reward_hacking.py
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()

labs/m5/reward_hacking.pylíneas 377-419 · p5

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).

Terminal
uv run rukh train reward --config configs/train/rm.yaml

El 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.

Terminal window
uv run rukh train grpo --config configs/train/grpo.yaml --steps 1500
sed 's/^lr: 1.0e-6/lr: 5.0e-6/' configs/train/grpo.yaml > configs/train/grpo-fast.yaml
uv run rukh train grpo --config configs/train/grpo-fast.yaml --steps 1500 --run-name medium-v4-grpo-fast
uv run rukh eval match --a checkpoints/medium-v4-grpo-fast/grpo.pt --b checkpoints/medium-v4-grpo/grpo.pt --games 400 --seed 7
uv run rukh eval match --a checkpoints/medium-v4-grpo/grpo.pt --b checkpoints/medium-v4-grpo-fast/grpo.pt --games 400 --seed 7
uv run python labs/m5/pooled_match.py
Terminal window
uv 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.yaml
uv run rukh train grpo --config configs/train/grpo-fast.yaml --steps 1500 --run-name medium-v4-grpo-fast
uv run rukh eval match --a checkpoints/medium-v4-grpo-fast/grpo.pt --b checkpoints/medium-v4-grpo/grpo.pt --games 400 --seed 7
uv run rukh eval match --a checkpoints/medium-v4-grpo/grpo.pt --b checkpoints/medium-v4-grpo-fast/grpo.pt --games 400 --seed 7
uv run python labs/m5/pooled_match.py

La 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.472
illegal: 0.0000 -> 0.0000
kl: 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.

Terminal
uv run rukh eval --model checkpoints/medium-v4-dpo-onpolicy/dpo.pt --config configs/eval/greedy.yaml --stage medium-v4-dpo-onpolicy-greedy
uv run rukh eval --model checkpoints/medium-v4-grpo/grpo.pt --config configs/eval/greedy.yaml --stage medium-v4-grpo-greedy
uv run rukh export --ckpt checkpoints/medium-v4-dpo-onpolicy/dpo.pt --out artifacts/onnx/medium-dpo --fp16 --int8 --check-parity
uv run rukh export --ckpt checkpoints/medium-v4-grpo/grpo.pt --out artifacts/onnx/medium-grpo --fp16 --int8 --check-parity
uv 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-run
uv 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-run
uv run rukh publish reward --run checkpoints/rm-<fecha> --repo chorcat/rukh-rm --dry-run
uv run rukh data publish --name rukh-pairs-onpolicy --dry-run

Son 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

  1. M0 El tallerEntorno reproducible(hecho)
  2. M1 Datos y tokensDatos y tokenización(hecho)
  3. M2 El decoderPreentrenar un GPT(hecho)
  4. M3 El encoderEmbeddings y clasificar(hecho)
  5. M4 Fine-tuningSFT y LoRA(hecho)
  6. M5 AlineamientoPreferencias: DPO, GRPO(hecho)
  7. M6 Evaluar y publicarMedir y desplegar(por delante)
  8. 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.
Todas las cheatsheets, imprimibles →