rukh · lab

// M5 · lección 04

GRPO y la recompensa que un motor verifica

La recompensa verificable con sus cuatro reglas y su test de cotas sobre entradas aleatorias, y GRPO: el grupo como baseline, el estimador k3, la caché de motor, y la métrica de entrenamiento que se maximiza colapsando la política.

  • tiempo de trabajo215 min
  • además, ejecución sin supervisión+ 90 min de GPU y red
  • nivel avanzado
  • actualizado el23 de septiembre de 2026

Qué vas a construir

El otro camino: sin pares y sin reward model. Para cada posición la política propone ocho jugadas, una función las puntúa, y cada una se empuja arriba o abajo según cómo le fue frente a la media de su propio grupo. No hay red de valor ni nada que entrenar dos veces.

Son dos ficheros y sus tests. Primero la recompensa, porque GRPOGRPOGroup Relative Policy Optimization: la política propone un grupo de respuestas a la misma entrada, una función las puntúa, y cada una se empuja arriba o abajo según cómo le fue frente a la media de su propio grupo. El grupo es el baseline, así que no hace falta una red de valor. Su consecuencia menos obvia: un grupo cuyas respuestas puntúan igual no aporta gradiente ninguno. no necesita un reward model pero sí una función de recompensa, y el ajedrez es de los pocos sitios donde se puede escribir y comprobar, como un problema de matemáticas con solución conocida o un programa con sus tests. Como nada de ella se aprende, lo que puede fallar es el diseño, y eso ordena la lección. Cómo se juzga lo que entrenes aquí está en «Cómo vamos a medir»: la recompensa que imprime el entrenamiento no decide; decide el enfrentamiento contra la base.

La recompensa verificable: cuatro reglas, y el fallo que evita cada una

El docstring del fichero son las cuatro reglas, y cada una existe porque su contraria produce un fallo concreto. El fichero tiene un solo pliegue, el de las importaciones; si prefieres no teclearlo, trae el fichero entero ahora, antes de empezarlo: git checkout p5 -- src/rukh/train/rewards.py.

src/rukh/train/rewards.py
"""Verifiable rewards for a chess move, and the reason each one is capped.
GRPO does not need a reward *model*: it needs a reward *function*, and a chess move is one of the
rare places where that function can be written down and checked. A move is legal or it is not; a
position is worth a number Stockfish will tell you; a mate is a mate. Nothing here is learned,
so nothing here can be wrong in the way a learned reward is wrong -- it can only be **designed**
wrong, which is a different problem and the one this module is about.
Four rules shape every function below.
**Legality is a gate, not a term.** An illegal move scores zero and skips the rest. Adding a
legality *bonus* to a weighted sum lets the model trade legality for something else, and the whole
point of a verifiable reward is that some things are not tradeable.
**Everything is capped.** A reward with no ceiling is an invitation: the optimiser will find the
direction that grows without bound and go there, and what comes back is a model that maximises the
number instead of playing chess. Every function here returns a value in a closed interval, and
there is a test that says so over thousands of random inputs.
**Sign and scale are fixed by hand, not by the data.** `delta_cp` is normalised by a constant the
caller passes, not by the spread of the batch, because a reward that rescales itself makes two
runs incomparable and hides the moment the model stops improving.
**Every function is pure.** Board in, number out, no engine call inside. The engine scores are
passed in, which is what lets the tests run in milliseconds on the CPU and what keeps the reward
from becoming a second place where the analysis budget is decided.
"""

src/rukh/train/rewards.pylíneas 1-27 · p5

  • La legalidad es una puerta, no un término. Un bonus por ser legal dentro de una suma ponderada le permite al modelo cambiar legalidad por otra cosa, y una recompensa verificableRecompensa verificableUna recompensa que calcula un programa y se puede comprobar, en vez de estimarla un modelo entrenado con opiniones. El ajedrez es uno de los pocos sitios donde se puede escribir entera: una jugada es legal o no, un mate es un mate, y el motor pone un número a la posición. Lo que gana es que no puede equivocarse como se equivoca un modelo aprendido; lo que no gana es inmunidad al reward hacking, porque sigue siendo un diseño humano. existe para que haya cosas que no se cambian por nada. El carné de conducir es una puerta: no hay coche lo bastante bueno que compense «menos dos puntos por no tenerlo».
  • Todo está acotado. El optimizador encuentra la dirección que crece sin límite y se va por ahí. Es el efecto cobra: el gobierno colonial de Delhi pagó por cada cobra muerta, sin tope, y la gente se puso a criar cobras.
  • El signo y la escala se fijan a mano. delta_cp se normaliza por una constante, no por la dispersión del lote; una recompensa que se reescala sola hace incomparables dos ejecuciones.
  • Las funciones son puras. Tablero y números entran, número sale; las puntuaciones del motor vienen de fuera, así que los tests corren en milisegundos.

Un aviso: donde el docstring dice que una jugada ilegal «scores zero», el código ya no hace eso. Devuelve illegal_value(weights), −1,25 con los pesos por defecto, y el porqué está dos bloques más abajo.

Las importaciones se pueden saltar al leer; al teclear hacen falta, así que despliégalas al escribir el fichero, salvo que lo trajeras entero al empezarlo.

// FontaneríaLas importaciones de rewards.py
src/rukh/train/rewards.py
from __future__ import annotations
import chess
from pydantic import Field
from rukh.config import BaseConfig
__all__ = [
"ILLEGAL_MARGIN",
"RewardBreakdown",
"RewardWeights",
"illegal_value",
"legal_bounds",
"legality_gate",
"mate_bonus",
"normalised_delta_cp",
"reward",
"repetition_penalty",
]

src/rukh/train/rewards.pylíneas 29-47 · p5

BaseConfig rechaza claves desconocidas, así que un peso mal escrito en un YAML falla al cargar y no al entrenar.

src/rukh/train/rewards.py
ILLEGAL_MARGIN = 1.0
"""How far below every legal move an illegal one scores. See ``illegal_value``.
A flat ``0.0`` was the obvious choice and was wrong, and the gallery in `labs/m5` is what caught
it: with the default weights a legal move that walks into a repetition scores ``-0.25``, so a
zero for illegal moves put **illegal above legal** for exactly the moves the repetition term was
written to discourage. A gate that the thing it gates can score better than is not a gate.
The margin is a whole unit rather than an epsilon so the ordering survives any reweighting that
keeps the other terms in their documented ranges."""

src/rukh/train/rewards.pylíneas 49-58 · p5

Con los pesos por defecto, una jugada legal puntúa en [−0,25, 1,25], y la puerta estaba en un 0,0 fijo, dentro de ese intervalo: una jugada imposible sacaba 0,0 y una legal que repite, −0,25. A la política se le decía que proponer algo injugable es mejor que repetir (lo encontró la galería de la lección 9). Ahora el valor se deriva de los pesos, con una unidad entera de margen. Una puerta solo es una puerta si queda por debajo de todo lo que deja pasar.

src/rukh/train/rewards.py
class RewardWeights(BaseConfig):
"""How much each verifiable rule is worth, and the constants that cap them.
The defaults are the ones `docs/spec/02` names, written down so a run can be reproduced from
its config alone. They are **not** tuned: tuning them against the Elo they produce would make
the reward a second model fitted to the evaluation, which is exactly the loop this module
warns about.
"""
quality: float = Field(default=1.0, ge=0)
"""Weight of the centipawn term: how close the move was to the best one available."""
mate: float = Field(default=0.25, ge=0)
"""Weight of the mate bonus. Small on purpose -- finding mate is already the best `delta_cp`,
so a large bonus would pay twice for the same move."""
repetition: float = Field(default=0.25, ge=0)
"""Weight of the repetition penalty, the only negative term."""
cp_scale: float = Field(default=200.0, gt=0)
"""Centipawns at which the quality term reaches zero. A move 200 cp worse than the best is as
bad as this reward can say; beyond that the difference stops mattering, which is true of chess
and keeps one catastrophe from dominating a whole group."""

src/rukh/train/rewards.pylíneas 61-80 · p5

mate: 0.25 es pequeño a propósito: encontrar mate ya es el mejor delta_cp posible, y un bono grande pagaría dos veces por la misma jugada. cp_scale: 200 es donde se agota el término de calidad, lo que impide que una sola catástrofe domine un grupo.

Lee entera la frase final del docstring: los pesos no están ajustados. Ajustarlos contra el Elo que producen convertiría la recompensa en un segundo modelo entrenado sobre la evaluación.

src/rukh/train/rewards.py
class RewardBreakdown(BaseConfig):
"""The reward and every term that went into it, because a scalar hides its own design."""
total: float
legal: bool
quality: float = 0.0
mate: float = 0.0
repetition: float = 0.0
@property
def terms(self) -> dict[str, float]:
return {"quality": self.quality, "mate": self.mate, "repetition": self.repetition}

src/rukh/train/rewards.pylíneas 83-94 · p5

src/rukh/train/rewards.py
def legality_gate(board: chess.Board, move: chess.Move) -> bool:
"""Whether the move can be played here at all. The gate every other term sits behind."""
return move in board.legal_moves

src/rukh/train/rewards.pylíneas 97-99 · p5

src/rukh/train/rewards.py
def normalised_delta_cp(cp_move: float, cp_best: float, scale: float = 200.0) -> float:
"""``1`` for the best move, ``0`` for one ``scale`` centipawns worse or more.
Both scores are from the **mover's** point of view, so the best available move is the largest.
Clamped at both ends: a move cannot be better than the best one, and one that is catastrophic
is not more informative than one that is merely losing -- letting it go to minus infinity
would let a single blunder decide the advantage of a whole group.
"""
if scale <= 0:
raise ValueError("scale must be positive")
loss = max(0.0, float(cp_best) - float(cp_move))
return max(0.0, 1.0 - loss / scale)

src/rukh/train/rewards.pylíneas 102-113 · p5

La puerta es una pertenencia a board.legal_moves. En el término de calidad, mira los dos recortes. Por arriba, una jugada no puede ser mejor que la mejor: normalised_delta_cp(100, 50) devuelve 1,0. Por abajo, un error de 943 centipeones y uno de 355 puntúan los dos cero con scale=200. Ese segundo recorte se paga con resolución: en la galería, esas dos jugadas sacan la misma ventaja.

src/rukh/train/rewards.py
def mate_bonus(board: chess.Board, move: chess.Move) -> float:
"""``1`` when the move mates, ``0`` otherwise. Deliberately binary.
Not "distance to mate": a shorter mate is not a better outcome, it is the same outcome sooner,
and paying for the difference teaches the model to prefer flashy mates over sure ones.
"""
copy = board.copy(stack=False)
copy.push(move)
return 1.0 if copy.is_checkmate() else 0.0

src/rukh/train/rewards.pylíneas 116-124 · p5

Binario a propósito. Pagar por la distancia al mate es tentador, pero le enseñaría al modelo a preferir mates vistosos antes que mates seguros. El copy(stack=False) es la copia barata: para preguntar si hay mate no hace falta el historial.

src/rukh/train/rewards.py
def repetition_penalty(board: chess.Board, move: chess.Move) -> float:
"""``1`` when the move walks into a repetition, ``0`` otherwise.
The one negative term, and the one that exists because of an observed failure rather than a
principle: a model rewarded for not losing discovers that shuffling never loses. The penalty
fires on a position seen before in this game, which is what `chess.Board.is_repetition(2)`
answers, rather than on the threefold claim -- by the time the draw is claimable the shuffling
has already happened and the reward arrives too late to discourage it.
"""
copy = board.copy()
copy.push(move)
return 1.0 if copy.is_repetition(2) else 0.0

src/rukh/train/rewards.pylíneas 127-138 · p5

El único término negativo, y el único que nace de una observación: un modelo premiado por no perder descubre que dar vueltas nunca pierde. Es el primo ajedrecístico del asistente que aprende que una respuesta evasiva nunca es incorrecta.

Salta con is_repetition(2), una posición ya vista, y no con la triple repetición: cuando las tablas ya se pueden reclamar, el paseo ya ha ocurrido. Aquí el copy() sí lleva el historial, porque la repetición es una pregunta sobre él.

src/rukh/train/rewards.py
def reward(
board: chess.Board,
move: chess.Move,
cp_move: float | None = None,
cp_best: float | None = None,
weights: RewardWeights | None = None,
) -> RewardBreakdown:
"""The whole reward of one move, with its terms.
``cp_move`` and ``cp_best`` come from outside -- the caller decides the analysis budget once,
for the whole group, instead of every reward call deciding it again. When they are missing the
quality term is zero and the reward falls back to what can be known without an engine, which
is legality, mate and repetition.
"""
weights = weights or RewardWeights()
if not legality_gate(board, move):
return RewardBreakdown(total=illegal_value(weights), legal=False)
quality = 0.0
if cp_move is not None and cp_best is not None:
quality = normalised_delta_cp(cp_move, cp_best, weights.cp_scale)
mate = mate_bonus(board, move)
repetition = repetition_penalty(board, move)
total = weights.quality * quality + weights.mate * mate - weights.repetition * repetition
return RewardBreakdown(
total=total, legal=True, quality=quality, mate=mate, repetition=repetition
)

src/rukh/train/rewards.pylíneas 141-168 · p5

La recompensa entera: la puerta con su return temprano, y después la suma ponderada. cp_move y cp_best vienen de fuera —la cuarta regla en la práctica—, y cuando faltan la recompensa cae a lo que se sabe sin motor: legalidad, mate y repetición.

src/rukh/train/rewards.py
def legal_bounds(weights: RewardWeights | None = None) -> tuple[float, float]:
"""The closed interval a **legal** move falls in."""
weights = weights or RewardWeights()
return (-weights.repetition, weights.quality + weights.mate)
def illegal_value(weights: RewardWeights | None = None) -> float:
"""What an illegal move scores: strictly below anything a legal move can reach.
This is the number that makes legality a gate rather than a preference. It is derived from the
weights instead of being a constant, because a constant is a promise that quietly stops being
true the first time somebody raises ``repetition``.
"""
return legal_bounds(weights)[0] - ILLEGAL_MARGIN
def bounds(weights: RewardWeights | None = None) -> tuple[float, float]:
"""The closed interval every reward falls in, which is what makes the design auditable."""
return (illegal_value(weights), legal_bounds(weights)[1])

src/rukh/train/rewards.pylíneas 171-189 · p5

legal_bounds es el intervalo de una jugada legal, illegal_value queda estrictamente por debajo, y bounds es el intervalo en el que cae toda recompensa. Con esta última se puede escribir un test que compruebe la promesa en vez de confiar en ella.

Los tests de la recompensa, enteros

El contrato: la mejor jugada saca lo máximo, una ilegal saca el suelo, y ninguna entrada produce un número fuera del intervalo prometido.

Los tests se leen al teclearlos y se pueden saltar en la primera lectura; el «Ejecútalo» de debajo los pasa, así que si también los saltas al teclear, tráelos: git checkout p5 -- tests/unit/test_rewards.py.

// Teststests/unit/test_rewards.py: la puerta, los recortes, el mate, la repetición y las cotas
tests/unit/test_rewards.py
"""The reward functions, written before anything trains on them.
A learned reward can be wrong in ways nobody notices. A verifiable one can only be **designed**
wrong, and a design is testable: the best move gets the most, an illegal move gets nothing, and no
input anywhere produces a number outside the interval the design promises.
"""
from __future__ import annotations
import random
import chess
import pytest
from rukh.train.rewards import (
RewardWeights,
bounds,
illegal_value,
legal_bounds,
legality_gate,
mate_bonus,
normalised_delta_cp,
repetition_penalty,
reward,
)
pytestmark = pytest.mark.unit
def test_legality_is_a_gate_and_not_a_term():
"""An illegal move scores the floor flat, with no other term able to buy it back."""
board = chess.Board()
illegal = chess.Move.from_uci("e2e5")
assert legality_gate(board, illegal) is False
got = reward(board, illegal, cp_move=1000, cp_best=0)
assert got.total == illegal_value()
assert got.legal is False
# Even with the best possible centipawn score behind it, nothing leaks through the gate.
assert got.terms == {"quality": 0.0, "mate": 0.0, "repetition": 0.0}
def test_an_illegal_move_scores_below_every_legal_one():
"""The bug the M5 gallery found: a flat zero put illegal *above* a legal repetition.
With the default weights a legal move that repeats scores -0.25, so a gate set at 0.0 was
telling the optimiser that proposing a move that cannot be played beats playing a legal one.
"""
for weights in (
RewardWeights(),
RewardWeights(repetition=2.0),
RewardWeights(quality=3.0, mate=1.0, repetition=0.5),
):
assert illegal_value(weights) < legal_bounds(weights)[0]
assert bounds(weights) == (illegal_value(weights), legal_bounds(weights)[1])

tests/unit/test_rewards.pylíneas 1-55 · p5

Los dos primeros son la puerta: con la mejor puntuación posible detrás, una ilegal sigue sacando el suelo; y el fallo de la puerta en cero queda fijado con tres juegos de pesos, incluido uno con repetition=2.0, que es donde una constante escrita a mano se habría roto en silencio.

tests/unit/test_rewards.py
def test_the_best_move_scores_one_and_the_scale_is_where_it_runs_out():
assert normalised_delta_cp(50, 50) == 1.0
assert normalised_delta_cp(-150, 50, scale=200) == 0.0
assert normalised_delta_cp(-50, 50, scale=200) == pytest.approx(0.5)
# Better than the best is not a thing; the clamp says so instead of producing 1.2.
assert normalised_delta_cp(100, 50, scale=200) == 1.0
# And a catastrophe is not more informative than a loss: both bottom out.
assert normalised_delta_cp(-100_000, 50, scale=200) == 0.0
def test_a_scale_of_zero_is_refused_rather_than_dividing():
with pytest.raises(ValueError, match="scale must be positive"):
normalised_delta_cp(0, 100, scale=0)
def test_mate_is_binary_and_does_not_pay_for_being_quick():
"""Fool's mate: `Qh5xf7#` ends it, and a shorter mate is the same outcome sooner."""
board = chess.Board()
for uci in ("f2f3", "e7e5", "g2g4"):
board.push_uci(uci)
mate = chess.Move.from_uci("d8h4")
assert mate_bonus(board, mate) == 1.0
assert mate_bonus(board, chess.Move.from_uci("g8f6")) == 0.0
def test_the_repetition_penalty_fires_on_the_second_visit_not_on_the_claim():
"""By the time a draw is claimable the shuffling already happened; the penalty is too late."""
board = chess.Board()
for uci in ("g1f3", "g8f6", "f3g1", "f6g8"):
board.push_uci(uci)
# The starting position is on the board again, so `Nf3` returns to a position already seen.
assert repetition_penalty(board, chess.Move.from_uci("g1f3")) == 1.0
assert repetition_penalty(board, chess.Move.from_uci("e2e4")) == 0.0
def test_the_reward_carries_its_terms_because_a_scalar_hides_its_design():
board = chess.Board()
got = reward(board, chess.Move.from_uci("e2e4"), cp_move=20, cp_best=30)
assert got.legal is True
assert got.quality == pytest.approx(1.0 - 10 / 200)
assert got.total == pytest.approx(got.quality)
assert set(got.terms) == {"quality", "mate", "repetition"}
def test_without_engine_scores_the_reward_falls_back_to_what_is_knowable():
"""No `cp` means no quality term -- not a guessed one."""
board = chess.Board()
got = reward(board, chess.Move.from_uci("e2e4"))
assert got.legal is True
assert got.quality == 0.0
assert got.total == 0.0

tests/unit/test_rewards.pylíneas 58-108 · p5

Los recortes se prueban con números absurdos —normalised_delta_cp(-100_000, 50) sigue dando 0,0—, el bono de mate con el mate del loco (la partida más corta que existe) y la repetición con Cf3 Cf6 Cg1 Cg8, para que Cf3 vuelva a una posición ya vista. El docstring del test del mate se equivoca de jugada: dice Qh5xf7#, que es el mate del pastor, y el código juega el del loco, f3 e5 g4 y Dh4# (d8h4). Lo que se comprueba es lo que hace el código.

tests/unit/test_rewards.py
def test_no_input_escapes_the_interval_the_design_promises():
"""A reward with no ceiling is an invitation; this is the test that keeps the promise.
Random legal moves from random positions, with centipawn scores drawn far outside anything an
engine would return, including the mate scores the pipeline uses.
"""
weights = RewardWeights()
low, high = bounds(weights)
rng = random.Random(7)
seen = 0
for _ in range(300):
board = chess.Board()
for _ in range(rng.randrange(0, 40)):
moves = list(board.legal_moves)
if not moves:
break
board.push(rng.choice(moves))
moves = list(board.legal_moves)
if not moves:
continue
move = rng.choice(moves)
cp_move = rng.choice([-10_000, -999, 0, 37, 10_000])
cp_best = rng.choice([-10_000, -12, 0, 500, 10_000])
total = reward(board, move, cp_move, cp_best, weights).total
assert low <= total <= high, (board.fen(), move.uci(), cp_move, cp_best, total)
seen += 1
assert seen > 200, "the walk should have produced plenty of legal positions"
def test_the_bounds_move_with_the_weights_instead_of_being_a_constant():
weights = RewardWeights(quality=2.0, mate=0.5, repetition=1.0)
assert legal_bounds(weights) == (-1.0, 2.5)
assert bounds(weights) == (-2.0, 2.5)
def test_the_weights_are_refused_when_they_would_break_the_design():
with pytest.raises(ValueError):
RewardWeights(quality=-1.0)
with pytest.raises(ValueError):
RewardWeights(cp_scale=0)

tests/unit/test_rewards.pylíneas 111-150 · p5

Y el que sostiene la segunda regla: trescientos paseos aleatorios de hasta cuarenta pliesPly (media jugada)Una jugada de un solo bando. 1. e4 e5 son dos plies y una jugada completa. Los filtros del recorte y las longitudes de secuencia del modelo se cuentan en plies porque es lo que ve el modelo: un token por ply., con puntuaciones muy fuera de lo que un motor devolvería, incluidos los ±10 000 de la convención de mate. Nada se sale del intervalo. El assert seen > 200 es la guarda del propio test: sin ella, si todos los paseos terminaran en posición final, el test quedaría en verde sin haber comprobado nada. Los dos últimos fijan que las cotas se mueven con los pesos y que un peso imposible se rechaza.

GRPO: el grupo es el baseline

Ahora el algoritmo, que cabe en la fórmula de la lección 1. src/rukh/train/grpo.py es el fichero más largo de la lección y tiene seis pliegues de fontanería (configuración, resultado, caché, ayudantes, arranque y cierre), todos necesarios para que el entrenamiento arranque. Si prefieres no teclearlos, trae el fichero entero ahora, antes de empezarlo, con git checkout p5 -- src/rukh/train/grpo.py, y lee la sección contra él.

A_i = (r_i − media(r)) / (desviación(r) + ε)

Esa media es el baselineBaseline de un grupoEl número contra el que se compara una recompensa para saber si fue buena o mala. Sin baseline, «0,8» no significa nada. PPO entrena una red de valor para estimarla; GRPO usa la media del propio grupo, que no cuesta nada y no hay que entrenar. El precio es que si el grupo no tiene dispersión, el baseline se come toda la señal.. PPO entrena una red de valor entera para estimarla; GRPO la saca del grupo, que ya estaba pagado.

src/rukh/train/grpo.py
"""GRPO on the decoder: a group of candidate moves, scored by a function instead of a model.
DPO needs a dataset of pairs somebody else decided. GRPO needs neither the pairs nor a reward
model: for each position the policy proposes ``group_size`` moves, a reward *function* scores
them, and each move is pushed up or down according to how it did **relative to its own group**.
The group is the baseline, which is the whole trick -- no value network, no critic, nothing to
train twice.
Four things about this file that are decisions, not details.
**One token, one exact gradient.** A completion here is a single move, so the usual
sequence-level machinery collapses: the policy gradient is ``advantage * log pi(move | prompt)``
at one position, and one forward pass gives the whole distribution. That is why ``group_size``
candidates cost one forward pass rather than ``group_size`` of them.
**The group is the baseline, so a group with no spread is worth nothing.** If all eight samples
are the same move, every advantage is zero and the step is a no-op with the engine already paid
for. Those groups are skipped and counted, because the count is a measurement: it says how much
of the budget the model's own confidence is throwing away.
**The engine is a cache, not a call.** Scoring every candidate at depth ``depth`` is the whole
cost of the run, and across steps the policy proposes the same moves in the same positions over
and over. A ``(fen, move) -> centipawns`` cache turns the second visit into a dictionary lookup;
it is keyed by the FEN so it stays correct when the same position arrives by a different route.
**KL is a term, not a clip.** The reference model is the frozen starting policy and the penalty
is the k3 estimator, which is non-negative and low-variance. Without it a verifiable reward is an
invitation to stop playing chess and start farming the reward -- and `labs/m5` has the gallery of
what that looks like when the invitation is accepted.
"""

src/rukh/train/grpo.pylíneas 1-30 · p5

Las cuatro ideas del docstring son las cuatro decisiones del fichero: un token y una sola pasada por grupo (el gradiente es ventaja · log π(jugada | prompt) en una posición, y por eso GRPO cabe en una tarde), los grupos planosGrupo planoUn grupo de GRPO cuyas candidatas puntúan todas igual. El baseline cae encima de todas, las ventajas son cero y el paso no aporta gradiente, con el motor ya pagado. En Rukh son el 38 % de los grupos a temperatura 1,0. Subir la temperatura los reduce —26 % a 1,3, 19 % a 1,6— pero cuesta más análisis de motor por grupo, y medido sale a la par: unos 0,18 grupos útiles por análisis en las tres. que se saltan y se cuentan, el motor detrás de una caché y la KL como un término más de la pérdida, con el estimador k3.

Las importaciones y la configuración son fontanería que puedes saltar al leer; de la configuración, la clave que importa se explica justo debajo del pliegue. Al teclear, despliégalas (sin ellas el fichero no importa), salvo que lo trajeras entero al empezarlo.

// FontaneríaImportaciones y GrpoConfig: el tamaño del grupo, la KL, la temperatura y restrict_to_legal
src/rukh/train/grpo.py
from __future__ import annotations
import json
import logging
import math
from typing import TYPE_CHECKING
import chess
import chess.engine
import numpy as np
import torch
import torch.nn.functional as F
from pydantic import Field
from rukh.config import BaseConfig
from rukh.tokenize.uci_vocab import UciTokenizer, elo_token
from rukh.train.rewards import RewardWeights, illegal_value, reward
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
LOGGER = logging.getLogger(__name__)
__all__ = [
"EngineCache",
"best_available",
"GrpoConfig",
"GrpoResult",
"group_advantages",
"grpo_loss",
"sample_group",
"train_grpo",
]

src/rukh/train/grpo.pylíneas 32-65 · p5

src/rukh/train/grpo.py
class GrpoConfig(BaseConfig):
"""The prompts, the policy, the size of a group and the price of scoring one."""
prompts: str = "data/pairs/dpo-prompts.parquet"
"""Only the ``prefix`` column is read. GRPO needs positions, not preferences -- which is the
point worth noticing: the same file feeds both methods and only one of them uses the labels."""
checkpoint: str = ""
out_dir: str = "checkpoints"
run_name: str = "grpo"
group_size: int = Field(default=8, ge=2)
"""Candidates per position. The baseline is their mean, so two is the minimum that has one."""
temperature: float = Field(default=1.0, gt=0)
top_k: int | None = 20
"""How many candidate tokens the group is drawn from, after the restriction below."""
restrict_to_legal: bool = True
"""Whether the group is drawn only from moves that can be played here.
``True`` spends the whole engine budget on moves the model could actually play, and the
legality gate then never fires -- which is fine, because the demo masks illegal moves before
they reach the board, so legality is a property the reward *guarantees* rather than one it
teaches.
``False`` draws from the whole vocabulary, so an illegal proposal can land in the group and
score the floor. That is the only configuration in which GRPO can teach legality at all, and
M5 needed it: DPO doubled the illegal-proposal rate of its base (D-121), and a reward whose
first rule is a legality gate ought to be able to pay that back."""
beta_kl: float = Field(default=0.02, ge=0)
"""Weight of the KL term against the frozen starting policy."""
lr: float = Field(default=1e-6, gt=0)
positions_per_step: int = Field(default=8, ge=1)
steps: int = Field(default=200, ge=1)
depth: int = Field(default=8, ge=1)
"""Engine depth per candidate. Depth, not time, so a run is reproducible (D-107)."""
block: int = Field(default=200, ge=8)
weights: RewardWeights = RewardWeights()
cache: str | None = "artifacts/grpo-engine-cache.json"
"""Where the ``(fen, move) -> centipawns`` cache lives between runs. ``None`` disables it."""
val_positions: int = Field(default=200, ge=0)
"""Held-out positions the mean reward is measured on, before and after."""
seed: int = 42
device: str | None = None

src/rukh/train/grpo.pylíneas 68-108 · p5

La clave que merece atención es restrict_to_legal. Con true, el valor por defecto, el grupo se sortea solo entre jugadas legales: todo el presupuesto de motor va a jugadas posibles, y la puerta de legalidad nunca se dispara (la tasa de ilegales sale 0,0000). La demo enmascara las ilegales de todos modos, así que aquí la legalidad se garantiza en vez de enseñarse.

Con false, el sorteo es sobre el vocabulario entero y una propuesta imposible puede caer en el grupo y sacar el suelo. Es la única forma de que GRPO enseñe legalidad, y hizo falta: DPO dobló la tasa de propuestas ilegales de su base.

// FontaneríaGrpoResult: los campos que devuelve una ejecución
src/rukh/train/grpo.py
class GrpoResult(BaseConfig):
"""What the run produced, in the shape the lesson quotes it."""
checkpoint: str
steps: int
groups: int
"""Groups that contributed a gradient."""
flat_groups: int
"""Groups where every candidate scored the same, so the baseline ate the whole signal."""
engine_calls: int
cache_hits: int
reward_before: float
reward_after: float
"""Mean reward with ``cp_best`` taken **inside the group**, which is what training optimises.
Read it as a training diagnostic and never as a score. It is maximised by a policy that
proposes the same move eight times out of eight: then the best candidate *is* every candidate,
every quality term is 1.0, and the mean is as high as it goes. A model that collapses makes
this number go up. ``reward_absolute_*`` is the one that cannot be gamed that way."""
reward_absolute_before: float = math.nan
reward_absolute_after: float = math.nan
"""Mean reward with ``cp_best`` taken from the **engine's own best move** in the position.
One extra analysis per position, not per candidate, and it is the number that says whether the
moves got better. Collapsing the policy does not move it: if the group agrees on a mediocre
move, all eight are still mediocre against what was available."""
flat_share_before: float = math.nan
flat_share_after: float = math.nan
"""Share of validation groups whose candidates were all the same move. The collapse detector."""
kl_after: float
"""Mean KL over the last tenth of the run, not the last batch.
It was the last batch until a sweep reported 0.00048 and 0.126 for the same learning rate on
two runs that differed in nothing that mattered: one group's KL is a sample, not a level."""
illegal_before: float
illegal_after: float

src/rukh/train/grpo.pylíneas 111-146 · p5

El resultado de una ejecución, con los dos campos que el hito tuvo que separar. Léelos con calma, porque son el hallazgo central del módulo:

  • reward_before/reward_after es la recompensa media con cp_best tomado dentro del grupo, que es lo que el entrenamiento optimiza. Su docstring avisa de que no se lea como puntuación: si el modelo propone la misma jugada ocho de ocho veces, la mejor candidata es cada candidata, las ocho sacan calidad 1,0 y la media llega a su techo.
  • reward_absolute_* toma cp_best del mejor movimiento disponible según el motor: un análisis más por posición, y un número que no sube por hacer la política más determinista.
  • flat_share_* detecta el colapso directamente: la proporción de grupos de validación con las ocho candidatas iguales.

kl_after promedia la última décima parte de los pasos porque la KL de un solo grupo es una muestra: dos ejecuciones con la misma tasa de aprendizaje llegaron a reportar 0,00048 y 0,126.

La caché del motor es lo que hace barata una ejecución, no lo que la hace funcionar; se puede saltar en la primera lectura. Al teclear no, porque el bucle la usa: despliégala al escribirla, salvo que trajeras el fichero entero al empezarlo.

// FontaneríaEngineCache: la caché de puntuaciones del motor, indexada por FEN
src/rukh/train/grpo.py
class EngineCache:
"""``(fen, move) -> centipawns for the mover``, kept between steps and between runs.
Keyed by the FEN rather than by the move list, so a position reached by transposition is the
same key. That is not a micro-optimisation: with eight candidates per position and two hundred
steps, the engine is the entire cost of the run, and the policy keeps proposing the moves it
already likes.
"""
def __init__(self, path: Path | None = None) -> None:
self.path = path
self.data: dict[str, int] = {}
self.hits = 0
self.calls = 0
if path is not None and path.exists():
self.data = json.loads(path.read_text("utf-8"))
LOGGER.info("cache: %d analisis reutilizables en %s", len(self.data), path)
@staticmethod
def key(fen: str, move: chess.Move) -> str:
return f"{fen}|{move.uci()}"
def score(
self, engine: chess.engine.SimpleEngine, board: chess.Board, move: chess.Move, depth: int
) -> int:
"""Centipawns for the side that plays ``move``, from the cache when it is there."""
from rukh.data.onpolicy import score_move
key = self.key(board.fen(), move)
cached = self.data.get(key)
if cached is not None:
self.hits += 1
return cached
value = score_move(engine, board, move, depth)
self.data[key] = value
self.calls += 1
return value
def save(self) -> None:
if self.path is None:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(self.data), "utf-8")

src/rukh/train/grpo.pylíneas 149-191 · p5

La caché está indexada por el FEN, así que una posición a la que se llega por transposición es la misma entrada. En un LLM el juez caro es otro —un compilador con tests, un modelo evaluador— y cachear sus veredictos rinde por la misma razón. score_move viene del fichero de la lección anterior: una sola convención de signo para pares y grupos.

src/rukh/train/grpo.py
def sample_group(
logits: torch.Tensor,
legal_ids: Sequence[int] | None,
size: int,
temperature: float,
top_k: int | None,
generator: torch.Generator | None = None,
) -> torch.Tensor:
"""``size`` move tokens drawn from the policy at one position.
Sampling is **with replacement**: the group is meant to be a sample of what the policy would
actually do, and a policy that would play the same move eight times out of eight is telling
the loss something true about itself. De-duplicating here would quietly turn the baseline into
the mean of a distribution nobody is following.
"""
scaled = logits.float() / temperature
if legal_ids is None:
mask = scaled
width = scaled.numel()
else:
mask = torch.full_like(scaled, float("-inf"))
index = torch.tensor(list(legal_ids), dtype=torch.long, device=scaled.device)
mask[index] = scaled[index]
width = index.numel()
if top_k is not None and top_k < width:
cut = torch.topk(mask, top_k).values[-1]
mask = torch.where(mask < cut, torch.full_like(mask, float("-inf")), mask)
probabilities = F.softmax(mask, dim=-1)
return torch.multinomial(probabilities, size, replacement=True, generator=generator)

src/rukh/train/grpo.pylíneas 194-222 · p5

El sorteo es con reemplazo, la decisión que más se suele invertir. Quitar duplicados parece gratis, pero el grupo tiene que ser una muestra de lo que la política haría de verdad; deduplicar convertiría el baseline en la media de una distribución que nadie sigue. El enmascarado va antes que el top_k, porque al revés el top-k se gastaría en tokens que luego se descartan.

src/rukh/train/grpo.py
def group_advantages(rewards: Sequence[float], eps: float = 1e-4) -> torch.Tensor:
"""``(r - mean) / (std + eps)``: how each candidate did against its own group.
The normalisation by the spread is what makes a group of near-identical moves and a group with
a blunder in it contribute comparably. When the spread is zero the advantages are zero, which
is the honest answer -- the group said nothing -- and the caller drops the group rather than
letting ``eps`` manufacture a gradient out of floating-point dust.
"""
values = torch.tensor(list(rewards), dtype=torch.float)
spread = float(values.std(unbiased=False))
if spread < eps:
return torch.zeros_like(values)
return (values - values.mean()) / (spread + eps)

src/rukh/train/grpo.pylíneas 225-237 · p5

El algoritmo entero. Normalizar por la dispersión hace que un grupo de jugadas casi iguales y uno con una barbaridad dentro aporten de forma comparable. Con dispersión cero, las ventajas son cero: hay un if antes de dividir, porque 0 + eps fabricaría ventajas enormes con polvo de coma flotante.

src/rukh/train/grpo.py
def grpo_loss(
policy_logprobs: torch.Tensor,
reference_logprobs: torch.Tensor,
actions: torch.Tensor,
advantages: torch.Tensor,
beta_kl: float,
) -> tuple[torch.Tensor, torch.Tensor]:
"""The objective and the KL, for a one-token completion.
The KL is the k3 estimator ``exp(d) - d - 1`` with ``d = logp_ref - logp_policy``. It is
non-negative by construction, which matters for a term that is supposed to be a *penalty*:
the plain ``logp_ref - logp_policy`` difference is negative half the time and would pay the
model for moving away from the reference on exactly the samples it moved away on.
"""
chosen_policy = policy_logprobs.gather(-1, actions.unsqueeze(-1)).squeeze(-1)
chosen_reference = reference_logprobs.gather(-1, actions.unsqueeze(-1)).squeeze(-1)
objective = -(advantages * chosen_policy).mean()
difference = chosen_reference - chosen_policy
kl = (torch.exp(difference) - difference - 1.0).mean()
return objective + beta_kl * kl, kl

src/rukh/train/grpo.pylíneas 240-259 · p5

-(advantages * chosen_policy).mean() es el gradiente de política en su forma más desnuda: sube lo que salió mejor que la media y baja lo que salió peor. El segundo término es la KL.

El estimador es k3Estimador k3La forma de calcular la penalización KL con las jugadas que la política ya sorteó, sin recorrer todo el vocabulario: exp(d) − d − 1 con d = log π_ref − log π de cada jugada sorteada, promediado. Vale cero cuando la política y la referencia dan la misma probabilidad a la jugada y es positivo en cualquier otro caso, así que funciona como castigo. La resta d a secas sale negativa la mitad de las veces y le pagaría al modelo por alejarse. GRPO en Rukh lo usa con beta_kl: 0.02.: exp(d) − d − 1 con d = logp_ref − logp_política. Es la correa de la lección 1 escrita como número, y es no negativo por construcción. La diferencia de log-probabilidades a secas es negativa la mitad de las veces, así que como castigo le pagaría al modelo en las muestras en las que se alejó. Sería un radar que la mitad de las veces te multa y la otra mitad te ingresa dinero por pasarte.

Tres ayudantes de fontanería; se pueden saltar al leer. El bucle los llama, así que al teclear despliégalos, salvo que trajeras el fichero entero al empezarlo.

// Fontanería_prompt, _board_for y _move_of: el prompt, el tablero y la jugada de un token
src/rukh/train/grpo.py
def _prompt(tok: UciTokenizer, prefix: str, white: int, black: int, block: int) -> list[int] | None:
"""The same three header tokens every other measurement uses, then the game so far."""
ids = [tok.bos_id, tok.vocab[elo_token(white, "w")], tok.vocab[elo_token(black, "b")]]
for uci in prefix.split():
token = tok.vocab.get(uci)
if token is None:
return None
ids.append(int(token))
return ids if len(ids) <= block else None
def _board_for(prefix: str) -> chess.Board | None:
board = chess.Board()
try:
for uci in prefix.split():
board.push_uci(uci)
except ValueError:
return None
return None if board.is_game_over() else board
def _move_of(tok: UciTokenizer, token: int) -> chess.Move | None:
"""The move a token spells, or ``None`` when the token is not a move at all.
The reverse table is built once per tokenizer and cached on it, because this runs eight times
per position and rebuilding a 2 030-entry dict each time would be the second cost of the run
after the engine.
"""
inverse = getattr(tok, "_rukh_inverse", None)
if inverse is None:
inverse = {index: text for text, index in tok.vocab.items()}
tok._rukh_inverse = inverse # type: ignore[attr-defined]
text = inverse.get(token)
if text is None or len(text) < 4:
return None
try:
return chess.Move.from_uci(text)
except ValueError:
return None

src/rukh/train/grpo.pylíneas 262-300 · p5

Fontanería. _prompt devuelve None en vez de recortar, como en DPO; _board_for, si la partida ya terminó. _move_of cachea la tabla inversa en el propio tokenizador, porque reconstruir 2 030 entradas ocho veces por posición sería el segundo coste de la ejecución.

src/rukh/train/grpo.py
def best_available(
cache: EngineCache, engine: chess.engine.SimpleEngine, board: chess.Board, cfg: GrpoConfig
) -> float:
"""Centipawns of the position with best play, for the side to move.
The honest reference for "how good was this move", and the one `score_candidates` deliberately
does **not** use while training. One analysis of the position itself, cached like the rest.
"""
from rukh.data.scoring import MATE_SCORE
key = f"{board.fen()}|BEST"
cached = cache.data.get(key)
if cached is not None:
cache.hits += 1
return float(cached)
info = engine.analyse(board, chess.engine.Limit(depth=cfg.depth))
value = int(info["score"].pov(board.turn).score(mate_score=MATE_SCORE))
cache.data[key] = value
cache.calls += 1
return float(value)

src/rukh/train/grpo.pylíneas 303-322 · p5

src/rukh/train/grpo.py
def score_candidates(
cache: EngineCache,
engine: chess.engine.SimpleEngine,
board: chess.Board,
moves: Sequence[chess.Move | None],
cfg: GrpoConfig,
best: float | None = None,
) -> list[float]:
"""The reward of each candidate, with ``cp_best`` taken over the candidates themselves.
The best *available* move would be the honest reference, and it costs a full multi-PV search
per position. The best move **in the group** costs nothing extra and asks the question GRPO
actually asks: of what you proposed, which was best? A model whose whole group is bad still
learns the ordering inside it, and the reward stays in the same closed interval either way.
"""
legal = {move.uci(): move for move in moves if move is not None and move in board.legal_moves}
scores = {
uci: float(cache.score(engine, board, move, cfg.depth)) for uci, move in legal.items()
}
if best is None:
best = max(scores.values()) if scores else 0.0
floor = illegal_value(cfg.weights)
return [
floor
if move is None
else reward(
board,
move,
cp_move=scores.get(move.uci()),
cp_best=best if move.uci() in scores else None,
weights=cfg.weights,
).total
for move in moves
]

src/rukh/train/grpo.pylíneas 325-358 · p5

score_candidates toma cp_best dentro del grupo. Es defendible: un grupo de ocho jugadas flojas sigue siendo enseñable, porque su ordenación interna sobrevive, y no cuesta análisis extra. best_available hace la llamada honesta —un análisis de la posición— y es la que usa la métrica.

El arranque y la función que sortea un grupo se pueden saltar en la primera lectura: la referencia se congela como en DPO y el grupo sale de una sola pasada. Al teclear son el principio de train_grpo: despliégalos, salvo que trajeras el fichero entero al empezarlo.

// FontaneríaEl arranque de train_grpo y group_at: congelar la referencia, apartar la validación y sortear un grupo
src/rukh/train/grpo.py
def train_grpo(cfg: GrpoConfig) -> GrpoResult:
"""Run GRPO and return what it measured, including the two numbers the lesson needs."""
import copy
import polars as pl
from rukh import paths
from rukh.engine import find_stockfish
from rukh.infer.sampler import legal_token_ids
from rukh.train.checkpoint import load_model, save_checkpoint
from rukh.train.common import pick_device
binary = find_stockfish()
if binary is None:
raise FileNotFoundError("Stockfish not found; set RUKH_STOCKFISH")
torch.manual_seed(cfg.seed)
device = torch.device(cfg.device or pick_device())
tok = UciTokenizer()
policy, payload = load_model(paths.resolve(cfg.checkpoint), device)
policy = policy.to(device).train()
reference = copy.deepcopy(policy).eval()
for parameter in reference.parameters():
parameter.requires_grad_(False)
frame = pl.read_parquet(paths.resolve(cfg.prompts).as_posix())
rng = np.random.default_rng(cfg.seed)
order = rng.permutation(frame.height)
val_index = order[: cfg.val_positions]
train_index = order[cfg.val_positions :]
cache = EngineCache(paths.resolve(cfg.cache) if cfg.cache else None)
optimizer = torch.optim.AdamW(policy.parameters(), lr=cfg.lr)
generator = torch.Generator(device=device).manual_seed(cfg.seed)

src/rukh/train/grpo.pylíneas 361-394 · p5

El arranque. La referencia se congela igual que en DPO, en eval y sin gradientes. Las posiciones de validación se apartan de una permutación con semilla y nunca se entrenan, y el generator de torch con su propia semilla hace que dos ejecuciones iguales sorteen los mismos grupos.

src/rukh/train/grpo.py
def group_at(row: dict[str, object]) -> tuple[chess.Board, list[int], torch.Tensor] | None:
"""Prompt, board and a sampled group for one row, or ``None`` when the row is unusable."""
board = _board_for(str(row["prefix"] or ""))
ids = _prompt(
tok, str(row["prefix"] or ""), int(row["white_elo"]), int(row["black_elo"]), cfg.block
)
if board is None or ids is None:
return None
with torch.no_grad():
logits, _ = policy(torch.tensor([ids], dtype=torch.long, device=device))
tokens = sample_group(
logits[0, -1],
legal_token_ids(board, tok) if cfg.restrict_to_legal else None,
cfg.group_size,
cfg.temperature,
cfg.top_k,
generator,
)
return board, ids, tokens

src/rukh/train/grpo.pylíneas 396-414 · p5

group_at es la única función que habla con el modelo: una pasada en no_grad y, de la última posición, los ocho tokens. legal_token_ids es del muestreador de M2, así que la legalidad se calcula igual que al jugar.

src/rukh/train/grpo.py
groups = flat = 0
last_kl = math.nan
recent_kl: list[float] = []
tail = max(cfg.steps // 10, 1)
"""How many of the final steps the reported KL averages over."""
with chess.engine.SimpleEngine.popen_uci(str(binary)) as engine:
def measure(indices: np.ndarray) -> tuple[float, float, float, float]:
"""Mean reward two ways, the illegal share, and how many groups collapsed.
Two ways because they answer different questions and only one of them is a score. With
``cp_best`` inside the group -- what training uses -- a policy that proposes one move
eight times scores the maximum, so the number goes up when the model collapses. With
``cp_best`` from the engine's own best move it cannot be gamed that way, and the gap
between the two is exactly how much of the first is the collapse.
"""
policy.eval()
relative: list[float] = []
absolute: list[float] = []
illegal = total = flat = groups_seen = 0
for row_index in indices:
drawn = group_at(frame.row(int(row_index), named=True))
if drawn is None:
continue
board, _ids, tokens = drawn
moves = [_move_of(tok, int(token)) for token in tokens]
illegal += sum(1 for move in moves if move is None or move not in board.legal_moves)
total += len(moves)
groups_seen += 1
flat += int(len({int(token) for token in tokens}) == 1)
relative.extend(score_candidates(cache, engine, board, moves, cfg))
absolute.extend(
score_candidates(
cache,
engine,
board,
moves,
cfg,
best=best_available(cache, engine, board, cfg),
)
)
policy.train()
return (
float(np.mean(relative)) if relative else math.nan,
float(np.mean(absolute)) if absolute else math.nan,
illegal / total if total else math.nan,
flat / groups_seen if groups_seen else math.nan,
)

src/rukh/train/grpo.pylíneas 416-463 · p5

La medición llama dos veces a score_candidates por posición, con la referencia del grupo y con la del motor. La segunda es casi gratis porque la caché ya tiene las candidatas; lo nuevo es un best_available por posición. Ese precio compra la única columna que no se puede maquillar colapsando.

src/rukh/train/grpo.py
before = measure(val_index)
LOGGER.info(
"antes: recompensa grupo %.4f - absoluta %.4f - ilegales %.4f - planos %.3f", *before
)
for step in range(cfg.steps):
picks = rng.choice(train_index, size=cfg.positions_per_step, replace=False)
losses: list[float] = []
optimizer.zero_grad(set_to_none=True)
used = 0
for row_index in picks:
drawn = group_at(frame.row(int(row_index), named=True))
if drawn is None:
continue
board, ids, tokens = drawn
moves = [_move_of(tok, int(token)) for token in tokens]
if all(move is None for move in moves):
continue
rewards = score_candidates(cache, engine, board, moves, cfg)
advantages = group_advantages(rewards).to(device)
if float(advantages.abs().sum()) == 0.0:
flat += 1
continue
prompt = torch.tensor([ids], dtype=torch.long, device=device)
policy_logits, _ = policy(prompt)
policy_logprobs = F.log_softmax(policy_logits[0, -1].float(), dim=-1)
with torch.no_grad():
reference_logits, _ = reference(prompt)
reference_logprobs = F.log_softmax(reference_logits[0, -1].float(), dim=-1)
loss, kl = grpo_loss(
policy_logprobs.unsqueeze(0).expand(cfg.group_size, -1),
reference_logprobs.unsqueeze(0).expand(cfg.group_size, -1),
tokens,
advantages,
cfg.beta_kl,
)
(loss / cfg.positions_per_step).backward()
losses.append(float(loss.detach()))
last_kl = float(kl.detach())
if step >= cfg.steps - tail:
recent_kl.append(last_kl)
groups += 1
used += 1
if used:
torch.nn.utils.clip_grad_norm_(policy.parameters(), 1.0)
optimizer.step()
if (step + 1) % 10 == 0:
LOGGER.info(
"paso %d - loss %.4f - kl %.5f - grupos %d - planos %d - cache %d/%d",
step + 1,
float(np.mean(losses)) if losses else math.nan,
last_kl,
groups,
flat,
cache.hits,
cache.hits + cache.calls,
)
cache.save()
if recent_kl:
last_kl = float(np.mean(recent_kl))
after = measure(val_index)
LOGGER.info(
"despues: recompensa grupo %.4f - absoluta %.4f - ilegales %.4f - planos %.3f", *after
)

src/rukh/train/grpo.pylíneas 465-529 · p5

El bucle. Los detalles que no se ven a la primera:

  • if float(advantages.abs().sum()) == 0.0 es el grupo plano: ya pagado al motor, solo queda contarlo.
  • expand(cfg.group_size, -1) repite la misma distribución ocho veces sin copiarla: las ocho candidatas salen de una pasada.
  • (loss / cfg.positions_per_step).backward() acumula el gradiente de las posiciones del paso antes de un solo optimizer.step().
  • if used: evita el paso cuando todas las posiciones salieron planas; llamar a step() con el gradiente anterior todavía puesto movería el modelo dos veces en la misma dirección.
  • cache.save() cada diez pasos hace que reanudar cueste minutos en vez de horas.

El cierre es fontanería; se puede saltar al leer. Al teclear no: es el final de train_grpo, el que guarda grpo.pt. Despliégalo, salvo que trajeras el fichero entero al empezarlo.

// FontaneríaEl cierre: guardar el checkpoint con la configuración y las dos recompensas
src/rukh/train/grpo.py
cache.save()
out_dir = paths.resolve(cfg.out_dir) / cfg.run_name
out_dir.mkdir(parents=True, exist_ok=True)
final = out_dir / "grpo.pt"
save_checkpoint(
final,
step=cfg.steps,
model=policy,
optimizer=None,
cfg={**cfg.model_dump(mode="json"), "reward_before": before[0], "reward_after": after[0]},
model_cfg=payload["model_cfg"],
vocab_hash=payload.get("vocab_hash"),
data_manifest_sha=payload.get("data_manifest_sha"),
)
return GrpoResult(
checkpoint=str(final),
steps=cfg.steps,
groups=groups,
flat_groups=flat,
engine_calls=cache.calls,
cache_hits=cache.hits,
reward_before=before[0],
reward_after=after[0],
reward_absolute_before=before[1],
reward_absolute_after=after[1],
flat_share_before=before[3],
flat_share_after=after[3],
kl_after=last_kl,
illegal_before=before[2],
illegal_after=after[2],
)

src/rukh/train/grpo.pylíneas 530-561 · p5

El cierre guarda el checkpoint con la configuración y las dos recompensas dentro. (Los mensajes de log de este fichero, antes:, paso, despues:, están en castellano y sin tildes; tenlo en cuenta si haces grep sobre la salida.)

Los tests de GRPO, enteros

El criterio del docstring vale para cualquier bucle de entrenamiento: probar las partes donde un fallo produce un número en vez de un error.

Los tests se leen al teclearlos; el que más enseña es el último, el del grupo colapsado, y el recuadro de debajo del pliegue lo resume. El «Ejecútalo» de después los pasa, así que si también los saltas al teclear, tráelos: git checkout p5 -- tests/unit/test_grpo.py.

// Teststests/unit/test_grpo.py: el baseline, la KL, el signo, el muestreo, la caché y el grupo colapsado
tests/unit/test_grpo.py
"""GRPO: the baseline, the penalty, and the two ways this loop can lie to itself.
The parts worth testing here are the ones where a bug produces a *number* rather than a crash: a
baseline that manufactures a gradient out of a group that all agreed, a KL term with the wrong
sign that pays the model for leaving its reference, and a cache that answers for the wrong
position.
"""
from __future__ import annotations
import chess
import pytest
import torch
from rukh.train.grpo import (
EngineCache,
GrpoConfig,
group_advantages,
grpo_loss,
sample_group,
score_candidates,
)
pytestmark = pytest.mark.unit
def test_a_group_that_agrees_produces_no_gradient():
"""The baseline is the group's own mean, so a flat group has nothing left to say.
Dividing by a spread of zero would hand back whatever the epsilon makes of floating-point
dust, and the loop would spend a step chasing it.
"""
assert torch.equal(group_advantages([0.42] * 8), torch.zeros(8))
assert torch.equal(group_advantages([0.0, 0.0]), torch.zeros(2))
def test_the_advantages_are_centred_and_scaled_by_the_groups_own_spread():
advantages = group_advantages([1.0, 0.0, 0.5, 0.5])
assert float(advantages.mean()) == pytest.approx(0.0, abs=1e-5)
assert float(advantages[0]) > 0 and float(advantages[1]) < 0
# A group ten times as spread out gets the same advantages: only the ordering is kept.
wide = group_advantages([10.0, 0.0, 5.0, 5.0])
assert torch.allclose(advantages, wide, atol=1e-3)

tests/unit/test_grpo.pylíneas 1-43 · p5

Los dos primeros son el baseline: un grupo de acuerdo da ventajas cero (torch.equal, no allclose), y un grupo diez veces más disperso produce las mismas ventajas, que es lo que permite usar GRPO sin calibrar la recompensa.

tests/unit/test_grpo.py
def test_the_kl_term_is_never_negative():
"""k3 is non-negative by construction, which is the point of using it.
The naive ``logp_ref - logp_policy`` is negative on exactly the samples where the policy moved
away from the reference, so as a *penalty* it would pay for the behaviour it is meant to
discourage. The test draws both directions on purpose.
"""
torch.manual_seed(0)
actions = torch.tensor([0, 1, 2, 3])
advantages = torch.zeros(4)
for _ in range(20):
policy = torch.log_softmax(torch.randn(4, 8), dim=-1)
reference = torch.log_softmax(torch.randn(4, 8), dim=-1)
_loss, kl = grpo_loss(policy, reference, actions, advantages, beta_kl=1.0)
assert float(kl) >= 0.0
def test_the_kl_is_zero_when_the_policy_is_still_the_reference():
"""The first step of any run: the penalty must cost nothing before anything has moved."""
logprobs = torch.log_softmax(torch.randn(4, 8), dim=-1)
_loss, kl = grpo_loss(
logprobs, logprobs.clone(), torch.tensor([0, 1, 2, 3]), torch.zeros(4), beta_kl=1.0
)
assert float(kl) == pytest.approx(0.0, abs=1e-6)
def test_a_positive_advantage_pushes_its_move_up():
"""The smallest end-to-end check of the sign: one step of the loss raises the chosen move."""
torch.manual_seed(0)
logits = torch.zeros(1, 4, requires_grad=True)
actions = torch.tensor([2])
advantages = torch.tensor([1.0])
for _ in range(50):
logprobs = torch.log_softmax(logits, dim=-1)
loss, _kl = grpo_loss(logprobs, logprobs.detach(), actions, advantages, beta_kl=0.0)
loss.backward()
with torch.no_grad():
logits -= 0.5 * logits.grad
logits.grad = None
assert int(logits.argmax()) == 2
# And a negative advantage pushes it down, which is the same test with the sign flipped.
logits = torch.zeros(1, 4, requires_grad=True)
for _ in range(50):
logprobs = torch.log_softmax(logits, dim=-1)
loss, _kl = grpo_loss(logprobs, logprobs.detach(), actions, -advantages, beta_kl=0.0)
loss.backward()
with torch.no_grad():
logits -= 0.5 * logits.grad
logits.grad = None
assert int(logits.argmin()) == 2

tests/unit/test_grpo.pylíneas 46-96 · p5

Los tres de la pérdida. El de la KL la prueba con veinte distribuciones aleatorias (con la diferencia a secas, la mitad darían negativo). El segundo es el primer paso de cualquier ejecución: política igual a referencia, castigo cero. El tercero es el signo de extremo a extremo: tras cincuenta pasos, el token con ventaja positiva acaba arriba y el de ventaja negativa, abajo.

tests/unit/test_grpo.py
def test_sampling_only_ever_draws_from_the_moves_it_was_given():
"""Everything outside ``legal_ids`` is masked to minus infinity, however large its logit."""
logits = torch.zeros(10)
logits[7] = 50.0 # the model is very sure about a move that is not on the list
drawn = sample_group(logits, [1, 2, 3], size=64, temperature=1.0, top_k=None)
assert set(int(token) for token in drawn) <= {1, 2, 3}
def test_sampling_keeps_duplicates_because_the_group_is_a_sample_of_the_policy():
"""A policy that would play one move eight times out of eight is saying something true."""
logits = torch.zeros(10)
logits[2] = 30.0
drawn = sample_group(logits, [1, 2, 3], size=8, temperature=1.0, top_k=None)
assert drawn.shape == (8,)
assert set(int(token) for token in drawn) == {2}
class _CountingEngine:
"""An engine that refuses to be called twice for the same thing."""
def __init__(self) -> None:
self.seen: list[str] = []
def analyse(self, board: chess.Board, limit: object) -> dict[str, object]:
self.seen.append(board.fen())
return {"score": chess.engine.PovScore(chess.engine.Cp(33), chess.WHITE)}
def test_the_cache_answers_the_second_time_without_the_engine():
cache = EngineCache(None)
engine = _CountingEngine()
board = chess.Board()
move = chess.Move.from_uci("e2e4")
first = cache.score(engine, board, move, depth=4)
second = cache.score(engine, board, move, depth=4)
assert first == second == 33
assert len(engine.seen) == 1
assert (cache.calls, cache.hits) == (1, 1)
def test_the_cache_is_keyed_by_the_position_not_by_the_move_list():
"""Two move orders reaching the same position are one cache entry, which is why it pays."""
direct = chess.Board()
direct.push_uci("g1f3")
direct.push_uci("g8f6")
transposed = chess.Board()
transposed.push_uci("g1f3")
transposed.push_uci("g8f6")
move = chess.Move.from_uci("d2d4")
assert EngineCache.key(direct.fen(), move) == EngineCache.key(transposed.fen(), move)
def test_the_config_refuses_a_group_that_cannot_have_a_baseline():
with pytest.raises(ValueError):
GrpoConfig(group_size=1)
with pytest.raises(ValueError):
GrpoConfig(beta_kl=-0.1)

tests/unit/test_grpo.pylíneas 99-155 · p5

Los del muestreo y la caché: un logit de 50 fuera de la lista legal no sale en 64 sorteos; un grupo puede ser ocho veces el mismo token; y la caché no vuelve a tocar el motor. El de la clave promete más de lo que comprueba: sus dos tableros juegan las mismas jugadas en el mismo orden, así que pasaría también con una caché indexada por la lista de jugadas. Para que pruebe la transposición, el segundo tablero tiene que llegar por otro camino (g1f3 g8f6 b1c3 b8c6 frente a b1c3 b8c6 g1f3 g8f6, que dejan el mismo FEN).

tests/unit/test_grpo.py
def test_a_collapsed_group_scores_the_maximum_against_its_own_best():
"""The flaw that makes the training reward useless as a score, pinned so it stays documented.
``score_candidates`` takes ``cp_best`` over the candidates themselves, which is what keeps a
group of bad moves teachable -- the ordering inside it survives. The price is that a policy
proposing one move eight times gets quality 1.0 on all eight, which is the highest the reward
goes. So the training number is maximised by collapsing, and only the version that takes
``cp_best`` from the engine can be read as a score.
"""
class _Fixed:
"""An engine that says every move leaves the position at -50 for the mover."""
def analyse(self, board: chess.Board, limit: object) -> dict[str, object]:
return {"score": chess.engine.PovScore(chess.engine.Cp(50), chess.WHITE)}
cache = EngineCache(None)
board = chess.Board()
move = chess.Move.from_uci("a2a3") # a legal move, and not a good one
collapsed = [move] * 8
relative = score_candidates(cache, _Fixed(), board, collapsed, GrpoConfig())
assert relative == [pytest.approx(1.0)] * 8, "the group is its own best, so every one is best"
# Against a genuinely better reference the same eight moves score the floor of the quality
# term, which is what a mediocre move deserves and what the published number has to use.
absolute = score_candidates(cache, _Fixed(), board, collapsed, GrpoConfig(), best=400.0)
assert absolute == [pytest.approx(0.0)] * 8

tests/unit/test_grpo.pylíneas 158-185 · p5

El docstring de _Fixed dice «-50 for the mover», y el código hace lo contrario: Cp(50) desde el punto de vista de las blancas, que son las que mueven a2a3, es +50 para quien mueve. El test no depende del signo, porque las ocho candidatas son la misma jugada y sacan lo mismo.

El último es el más interesante porque fija un fallo conocido. Un grupo colapsado saca 1,0 en las ocho contra su propio mejor y 0,0 contra una referencia de verdad mejor. No arregla nada: deja escrito por qué la métrica de entrenamiento no se puede leer como una puntuación.

Las dos configuraciones

configs/train/grpo.yaml es la ejecución publicada, con las mediciones que fijaron cada valor.

El YAML se puede saltar en la primera lectura; la palanca que se midió está resumida debajo. Si también lo saltas al teclear, tráelo, porque el «Ejecútalo» del final lo lee: git checkout p5 -- configs/train/grpo.yaml.

// Fontaneríaconfigs/train/grpo.yaml: la ejecución publicada, con los comentarios que fijaron cada valor
configs/train/grpo.yaml
# GRPO against a verifiable reward. No preference pairs and no reward model: only the positions
# are read out of the pairs file, and the labels in it are ignored.
prompts: data/pairs/dpo-prompts.parquet
checkpoint: checkpoints/medium-v4-20260919-174623/best.pt
out_dir: checkpoints
run_name: medium-v4-grpo
# Eight candidates per position, which is what makes the group's mean a usable baseline. Their
# spread is the entire learning signal, so this is the knob that decides how much of the engine
# budget turns into a gradient and how much is spent on groups that all agree.
group_size: 8
# Temperature 1.0, and the measurement that says this is not a free lever. Raising it buys
# diversity -- 38 % of groups are flat at 1.0, 26 % at 1.3, 19 % at 1.6 -- and charges for it in
# engine calls, so the price per *useful* group barely moves: 0.184, 0.186 and 0.174 groups per
# analysis. With the cost flat, the tie-break is on-policy-ness, and 1.0 is closest to how the
# model is read.
temperature: 1.0
top_k: 20
# Only moves that can be played here. The legality gate then never fires, which is fine: the demo
# masks illegal moves before they reach the board. Set it to false to let the gate teach --
# `grpo-legality.yaml` is the run that does.
restrict_to_legal: true
# The KL term against the frozen start. The reward is verifiable, which means it is exactly
# specifiable and therefore exactly gameable: this is what stops the policy from walking off to
# whatever corner of move-space scores best.
beta_kl: 0.02
lr: 1.0e-6
positions_per_step: 8
steps: 300
# Depth 8 per candidate, and a persistent cache so the second visit to a position is free.
# The engine is the whole cost of this run; the model is almost an afterthought.
depth: 8
cache: artifacts/grpo-engine-cache.json
val_positions: 200
# The reward's weights, written down so the run can be rebuilt from its config alone.
weights:
quality: 1.0
mate: 0.25
repetition: 0.25
cp_scale: 200.0
seed: 42

configs/train/grpo.yamllíneas 1-49 · p5

El comentario de la temperatura documenta una palanca que no mueve nada. Subirla compra dispersión —38 % de grupos planos a 1,0, 26 % a 1,3, 19 % a 1,6— y la cobra en llamadas al motor, así que el precio por grupo útil casi no cambia: 0,184, 0,186 y 0,174 grupos por análisis. Con el coste plano, se queda 1,0, la más parecida a cómo se lee el modelo.

La segunda configuración es la que le pide a GRPO que devuelva el peaje que DPO se gastó. No la tecleas: si quieres probarla, la trae la caja del principio. Se lee por lo que cambia.

// Fontaneríaconfigs/train/grpo-legality.yaml: GRPO sobre el modelo de DPO para devolver la legalidad
configs/train/grpo-legality.yaml
# GRPO asked to pay back the legality that DPO spent.
#
# D-121 measured the toll: aligning with DPO doubled the base model's illegal-proposal rate, from
# 1.3-1.5 % to 2.9-3.8 % for the off-policy arm. This run starts from that model and draws its
# candidate groups from the **whole vocabulary** instead of from the legal moves, so an illegal
# proposal can land in a group and score the floor -- which is the only configuration in which a
# legality gate can teach anything at all.
#
# The number to watch is not the reward. It is `illegal` before and after, with the KL small.
prompts: data/pairs/dpo-prompts.parquet
checkpoint: checkpoints/medium-v4-dpo-offpolicy/dpo.pt
out_dir: checkpoints
run_name: medium-v4-dpo-offpolicy-grpo-legality
group_size: 8
temperature: 1.0
top_k: 20
restrict_to_legal: false
beta_kl: 0.02
lr: 1.0e-6
positions_per_step: 8
steps: 1500
depth: 8
cache: artifacts/grpo-engine-cache.json
val_positions: 400
weights:
quality: 1.0
mate: 0.25
repetition: 0.25
cp_scale: 200.0
seed: 42

configs/train/grpo-legality.yamllíneas 1-34 · p5

Va entera porque cambian cuatro cosas relacionadas: parte del modelo ya alineado con DPO, sortea del vocabulario entero, cuadruplica los pasos y dobla las posiciones de validación. Y el número que se mira aquí es illegal antes y después, con la KL pequeña; la recompensa pasa a segundo plano.

Esta ejecución no se hizo en el hito, y por eso la lección 8 no tiene su resultado. Se quedó pendiente para cuando haya presupuesto de motor, porque su señal es minúscula: solo un 0,25 % de las candidatas sorteadas salen ilegales, así que casi ningún grupo tiene una que castigar. Si quieres lanzarla tú, necesita el brazo fuera de política de la lección 3 (checkpoints/medium-v4-dpo-offpolicy/dpo.pt, del que parte), Stockfish y GPU, y son 1 500 pasos:

Terminal window
uv run rukh train grpo --config configs/train/grpo-legality.yaml

Mira la línea illegal: de la salida, antes y después, junto a la de kl:. No hay cifra de referencia con la que comparar la tuya.

El subcomando

rukh train grpo imprime once números, en un orden que dice cuáles importan.

El código del subcomando es fontanería; lo que importa es el orden en que imprime, y está explicado debajo. Al teclear no se salta, porque el «Ejecútalo» lo usa; si lo saltas, tráelo con git checkout p5 -- src/rukh/cli.py (si ya lo trajiste en la lección 2 o 3, ya lo tienes).

// Fontaneríarukh train grpo: el subcomando y los once números que imprime
src/rukh/cli.py
@train_app.command("grpo")
def train_grpo_cmd(
config: Annotated[
Path, typer.Option("--config", exists=True, dir_okay=False, help="GRPO run YAML.")
],
steps: Annotated[
int | None, typer.Option("--steps", help="Override the number of steps.")
] = None,
run_name: Annotated[str | None, typer.Option("--run-name", help="Name the output.")] = None,
device: Annotated[str | None, typer.Option("--device", help="Where to train.")] = None,
as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON only.")] = False,
) -> None:
"""Align the decoder against a reward *function*, using the group as its own baseline.
No pairs and no reward model: the policy proposes a group of moves, a verifiable reward scores
them, and each is pushed up or down relative to its group's mean. What to watch is not the
reward going up -- it always can -- but whether it went up *and* the KL stayed small.
"""
from rukh.config import load_yaml
from rukh.train.grpo import GrpoConfig, train_grpo
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
cfg = load_yaml(config, GrpoConfig)
updates: dict[str, object] = {}
if steps is not None:
updates["steps"] = steps
if run_name:
updates["run_name"] = run_name
if device:
updates["device"] = device
if updates:
cfg = cfg.model_copy(update=updates)
result = train_grpo(cfg)
if as_json:
typer.echo(result.model_dump_json(indent=2))
return
typer.echo(f"checkpoint: {result.checkpoint}")
typer.echo(
f"reward: {result.reward_before:+.4f} -> {result.reward_after:+.4f} (group best)"
)
typer.echo(
f" {result.reward_absolute_before:+.4f} -> "
f"{result.reward_absolute_after:+.4f} (engine best -- this is the score)"
)
typer.echo(f"flat: {result.flat_share_before:.3f} -> {result.flat_share_after:.3f}")
typer.echo(f"illegal: {result.illegal_before:.4f} -> {result.illegal_after:.4f}")
typer.echo(f"kl: {result.kl_after:.5f} against the frozen start")
spent = result.engine_calls + result.cache_hits
share = result.cache_hits / spent if spent else 0.0
typer.echo(f"groups: {result.groups:,} useful, {result.flat_groups:,} flat")
typer.echo(f"engine: {result.engine_calls:,} analyses, {share * 100:.1f} % from cache")

src/rukh/cli.pylíneas 847-897 · p5

La segunda línea de recompensa lleva escrito -- this is the score, para que quien lea la salida pegada en un informe un mes después sepa cuál de los dos números significa algo.

Después van juntos grupos planos, ilegales y KL. Una recompensa escrita siempre se puede subir; lo que dice si subió jugando mejor es que suba y el colapso no crezca y la KL se quede pequeña. Al alinear un asistente se lee igual: recompensa, KL contra el modelo de partida y alguna medida de diversidad, en la misma tabla.

Salida en la máquina de referencia (RTX 5090), la ejecución publicada de 1 500 pasos:

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 01¿Por qué el paso de un grupo plano no cuesta nada y aun así hay que contarlo?

Un grupo plano se salta antes de la pasada de la política que lleva gradiente. ¿Qué se ha gastado ya cuando se descubre que es plano, y por qué el recuento es una métrica y no un detalle de log?

// SoluciónVer la solución

Cuando se descubre, ya se han pagado la pasada sin gradiente que sorteó el grupo y las ocho puntuaciones de motor de las candidatas: el grupo se sortea, se puntúa, y solo entonces se calculan las ventajas y se ve que son cero. Lo que se ahorra es lo que viene detrás —la pasada de la política con gradiente, la de la referencia y la pasada hacia atrás—, que en este modelo es lo barato.

Y es una métrica porque mide algo que no se puede saber por adelantado: qué fracción del presupuesto de motor se lleva la propia confianza del modelo. Empieza en el 38 % a temperatura 1,0 y crece con el entrenamiento, que es el mecanismo de la sobreoptimización que la lección 8 mide con una curva de tres puntos.

// Ejercicio 02El grupo plano y el par que no salió

En la lección anterior, un 27,1 % de las posiciones no produjo par on-policy porque el modelo propuso una sola jugada distinta. ¿Qué relación tiene con flat_groups?

// SoluciónVer la solución

Son el mismo fenómeno medido en dos sitios: el modelo propone varias veces, sale siempre lo mismo, el motor ya está pagado y no hay nada que aprender. En los pares es un descarte en el manifiesto, una propiedad estática del modelo. En GRPO es un contador que sube durante la ejecución: un síntoma de que la política se está cerrando.

Qué has aprendido

Una recompensa verificable solo puede fallar por diseño, y por eso lleva cuatro reglas y un test de cotas sobre entradas absurdas. GRPO cambia la red de valor de PPO por la media del propio grupo, y el precio es que un grupo de acuerdo no enseña nada y ya se ha pagado. La KL tiene que ser no negativa o deja de ser un castigo. Y te llevas una pregunta que sirve igual para el score de un juez LLM, el KPI de un agente o una métrica de producto: describe la política que maximizaría tu métrica. Si no es la que quieres, la métrica tampoco.

Lo siguiente es el instrumento con el que se decide si algo de esto sirvió: dos modelos jugando entre sí.