// M5 · lección 02
El reward model: una cabeza escalar sobre el encoder de M3
Los cuatro ficheros del reward model, enteros: el modelo con su pérdida de Bradley-Terry, el bucle de entrenamiento con su reparto por partida y su desglose por bandas, la configuración que se decidió midiendo y los siete tests que fijan lo que la pérdida promete.
Qué vas a construir
Un modelo que lee una posición y contesta un número: el juez que aprende a puntuar como puntúa el
motor. No juega ni propone jugadas, y ni DPODPODirect Preference Optimization: entrena la política directamente sobre los pares, sin reward model y sin bucle de refuerzo. La referencia es implícita —una copia congelada de los pesos de partida— y β mide cuánto se le deja alejarse de ella. Con una jugada por respuesta, la pérdida se reduce a comparar dos log-probabilidades en una sola posición. ni 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. lo
necesitan. Está aquí porque es la pieza que hace que PPO tenga sentido, porque se puede medir sola
contra un listón escrito de antemano (75 % de acierto en pares no vistos) y porque es el
contraejemplo de una recompensa verificable.
Es el reward model del RLHF de un asistente, en pequeño. Allí se toma un LLM preentrenado, se le quita la cabeza que predice tokens, se le pone una capa lineal que devuelve un número y se entrena con la misma pérdida sobre pares de respuestas que una persona comparó. Aquí la arquitectura es el encoder de M3 —que acaba entrenándose desde cero, por un motivo que se mide más abajo— y los pares los juzgó un motor.
Son cuatro ficheros —modelo, bucle, configuración y tests—, enteros y tal como están en el tag
p5. Cómo se juzga lo que entrenes en este módulo está contado en
«Cómo vamos a medir»: este modelo se mide solo, y las políticas de las dos lecciones
siguientes, contra su base.
El modelo: qué ve de entrada y por qué la cabeza no está acotada
Las dos decisiones de diseño están en el docstring y no en el código.
"""A reward model: the encoder of M3 with one scalar head, trained on preferences.
The scalar it produces has **no absolute meaning**. Bradley-Terry only ever sees differences --the loss is ``-log σ(r(better) - r(worse))`` -- so adding a constant to every score changesnothing, and a reward model that behaves differently when you shift it is broken in a way thatonly shows up later. There is a test for exactly that.
Two design choices worth defending.
**The input is the position *after* the move.** A reward over ``(position, move)`` pairs could bebuilt by feeding both, but the encoder of M3 already takes a FEN and the position after a move isthe move's consequence, which is what a reward is about. It also means the reward model can scorea position that arrived by any route, which is what GRPO needs when it ranks eight candidate movesagainst each other.
**The head is unbounded.** A ``tanh`` would keep the scale tidy and would also cap the marginbetween a good move and a catastrophic one at the same ceiling, which is the one distinction themodel is being trained to make. The scale is kept in check by the loss itself: pushing the marginto infinity costs more and more for less and less.
What this module is **not** for: DPO. `DPOTrainer` and `rukh.train.dpo` have an implicit referenceand need no reward model at all. This one is here because it is the piece that makes PPO makesense, because it can be measured on its own (``GOAL.md`` asks for 75 % on held-out pairs), andbecause a learned reward is the thing GRPO's verifiable rewards are being compared against."""La primera, la entrada es la posición después de la jugada, permite reutilizar el encoderEncoderTransformer con atención bidireccional: cada posición ve toda la secuencia. No genera; representa. En Rukh el encoder lee una partida entera y produce un vector por posición del que salen el valor y la detección de errores. de M3 sin tocarlo: ya toma un FENFENCadena de texto que describe una posición completa: piezas por fila, turno, derechos de enroque, casilla al paso y contadores de jugadas. Es la clave con la que se cruzan las posiciones de las partidas con las evaluaciones públicas de Stockfish., y la posición después de una jugada es su consecuencia, que es de lo que va una recompensa. De paso, puede puntuar una posición a la que se llegó por cualquier camino, que es lo que GRPO necesita cuando ordena ocho candidatas.
La segunda, la cabeza sin acotar, cuesta más defenderla. Un tanh dejaría la escala ordenada, y
también le pondría el mismo techo al margen entre una jugada buena y una catastrófica, que es la
distinción que el modelo está aprendiendo. A la escala ya la contiene la pérdida: llevar el margen
al infinito cuesta cada vez más para cada vez menos.
from __future__ import annotations
import torchimport torch.nn.functional as Ffrom torch import Tensor, nn
from rukh.models.encoder import PositionEncoder
__all__ = ["RewardModel", "preference_accuracy", "preference_loss"]El import que cuenta es PositionEncoder: el reward model es el de M3 con una capa lineal encima.
class RewardModel(nn.Module): """``PositionEncoder`` plus a scalar head: how good is this position for whoever just moved."""
def __init__( self, encoder: PositionEncoder, pooling: str = "mean", ) -> None: super().__init__() self.encoder = encoder self.pooling = pooling self.head = nn.Linear(encoder.cfg.d_model, 1)
def forward(self, idx: Tensor, attention_mask: Tensor | None = None) -> Tensor: """``(B,)`` scores. Higher is better for the side that reached this position.""" pooled = self.encoder.pool(idx, self.pooling, attention_mask) return self.head(pooled).squeeze(-1)encoder.pool es el método de M3 que reduce los 69 tokens de casilla a un vector (mean, la
opción que M3 midió mejor). El squeeze(-1) deja (B,) en vez de (B, 1): lo que sale de aquí va
a una resta, y una dimensión sobrante convertiría por broadcasting un lote de 64 en una matriz de
64×64 sin que nada falle.
def preference_loss(chosen: Tensor, rejected: Tensor) -> Tensor: """Bradley-Terry: ``-log σ(r_chosen - r_rejected)``, averaged over the batch.
Only the difference appears, which is the whole model: preferences say what is better than what, never how good anything is on its own. """ return -F.logsigmoid(chosen - rejected).mean()La pérdida entera de Bradley-TerryBradley-TerryEl modelo que convierte preferencias en puntuaciones: la probabilidad de que a gane a b es σ(r(a) − r(b)). Su consecuencia más útil y la más fácil de olvidar es que solo ve diferencias: sumar una constante a todas las puntuaciones no cambia nada, así que la escala de un reward model no significa nada por sí sola y compararla entre dos entrenamientos no tiene sentido. cabe en una línea: maximizar la
verosimilitud de σ(r(chosen) − r(rejected)) es minimizar −log σ de la diferencia. Se usa
F.logsigmoid y no log(sigmoid(x)) porque la versión ingenua satura a -inf cuando el margen es
muy negativo, que es donde el gradiente hace falta.
def preference_accuracy(chosen: Tensor, rejected: Tensor) -> float: """Share of pairs the model orders correctly. A tie counts as wrong, not as half right.
``GOAL.md`` reads its bar on this number, and a reward model that scores two moves exactly the same has expressed no preference -- rounding that up to half a point would flatter it. """ with torch.no_grad(): return float((chosen > rejected).float().mean())El acierto es chosen > rejected, no >=: un empate cuenta como fallo, porque un modelo que
puntúa dos jugadas igual no ha expresado ninguna preferencia.
El bucle: de dos cadenas a dos tableros
src/rukh/train/reward.py es el fichero largo del módulo: traduce los pares a posiciones, los
reparte sin fugas y mide el resultado de varias maneras, porque una sola engañaría.
La primera mitad del fichero son tipos y la traducción de cadenas a tableros: fontanería que puedes
saltar en la primera lectura. Lo que enseña empieza en el reparto por partida, justo debajo del
pliegue. Al teclear no se salta: sin esa mitad el fichero no importa. Este fichero tiene cuatro
pliegues; si prefieres no teclearlos, trae el fichero entero ahora, antes de empezarlo, con
git checkout p5 -- src/rukh/train/reward.py, y lee el resto de la sección contra él en vez de
teclearlo.
// FontaneríaImportaciones, RewardConfig, los tipos del resultado y la conversión de pares a posiciones
"""Training the reward model on preference pairs, and measuring it on pairs it never saw.
The data is the one the DPO pairs already use: a prefix of a real game, a move an engine scoredbetter and a move it scored worse, at least ``min_delta_cp`` apart. What this loop adds is thetranslation from "(prefix, move)" to "the position the move leads to", which is what the encoderof M3 reads, and a split that does not lie.
**The split goes by game, not by pair.** Two pairs from the same game are two moves a few pliesapart in the same position, so a model that memorised one has most of the other. `game_split` isthe same pure CRC-32 function M3 used, for the same reason: it gives the same answer in everyprocess and every future run.
**The metric is accuracy on held-out pairs**, which `GOAL.md` puts a bar on, plus the correlationbetween the reward and the centipawn difference it was trained from -- Spearman as well asPearson, because M3 measured that a head can get the order right and the scale wrong, and the twonumbers are the diagnosis."""El reparto por partida evita una fuga de datosFuga de datosCualquier camino por el que información del conjunto de validación llega al de entrenamiento. No falla: los números mejoran, y se descubre cuando el modelo sale al mundo. En Rukh se evita partiendo por mes (enero entrena; de febrero, desde M2 parte 4, validan las primeras 100 000 partidas y el resto del mes entrena), por PuzzleId con semilla en los puzles, entrenando el BPE solo con datos de enero y, en M3, repartiendo la tabla de posiciones por game_id con un CRC-32 de la semilla y el id, nunca por posición: dos posiciones consecutivas de la misma partida se diferencian en una jugada. El umbral del detector se elige además en una mitad tune y se mide en otra score, partidas también por partida. que no se vería:
dos pares de la misma partida son dos jugadas separadas por pocos 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., así
que un modelo que memorizó uno tiene casi todo el otro. Y la correlaciónCorrelación (Pearson y Spearman)Un número entre −1 y 1 que dice si dos cantidades suben juntas: 1, a la par; 0, sin relación; −1, una baja cuando la otra sube. Hay dos maneras de medir si un número predicho acompaña al real. Pearson mide si los valores se alinean en una recta y le importa la escala; Spearman es Pearson sobre los rangos, así que solo mide si el orden coincide: el podio frente a los cronos. Se publican las dos porque discrepan exactamente cuando el modelo tiene bien el orden y mal la escala, que es lo que le hace un tanh acotado a una puntuación en centipeones. Las dos se calculan contra tanh(cp/400), la escala con la que se entrena la cabeza, nunca contra el cp en bruto. GOAL.md pide ≥ 0,80 leído sobre Spearman; el checkpoint publicado en M3 se queda en 0,520 (Pearson 0,648) y la pérdida con término de orden sube a 0,6665: criterio no cumplido. se publica en Pearson y en Spearman
porque M3 ya midió una cabeza que acertaba el orden y erraba la escala: si las dos discrepan, ahí
está el diagnóstico (las dos, con un ejemplo de cinco números, en la
estadística justa de M2).
from __future__ import annotations
import jsonimport loggingimport mathfrom pathlib import Pathfrom typing import TYPE_CHECKING, Any, Literal
import chessimport numpy as npimport torchimport torch.nn.functional as Ffrom pydantic import ConfigDict, Fieldfrom torch import Tensor
from rukh.config import BaseConfigfrom rukh.models.config import EncoderConfigfrom rukh.models.encoder import PositionEncoderfrom rukh.models.reward import RewardModelfrom rukh.models.squares import fen_to_tokens
if TYPE_CHECKING: # pragma: no cover - typing only from collections.abc import Iterator, Sequence
log = logging.getLogger(__name__)
__all__ = [ "RewardBand", "RewardConfig", "RewardExample", "RewardResult", "encode_pairs", "load_squares_encoder", "train_reward",]scipy no aparece aquí: se importa dentro de _correlations, la única función que lo usa, porque
cargarlo cuesta casi un segundo en cada arranque de la CLI.
class RewardConfig(BaseConfig): """One reward-model run: where the pairs are, what to start from, and the two knobs."""
pairs: str = "data/pairs/dpo-prompts.parquet" encoder_ckpt: str | None = None """The masked-move checkpoint to start from. ``None`` trains from scratch, which is the honest baseline the pretraining has to beat -- the same argument as M3's heads.""" model: EncoderConfig | None = None pooling: str = "mean" out_dir: str = "checkpoints" run_name: str = "rm" lr: float = Field(default=1e-4, gt=0) weight_decay: float = Field(default=0.01, ge=0) batch_size: int = Field(default=64, ge=1) epochs: int = Field(default=3, ge=1) val_fraction: float = Field(default=0.1, gt=0.0, lt=0.5) seed: int = 42 device: str | None = None max_pairs: int | None = None """Cap for a smoke run; ``None`` uses every usable pair.""" point_of_view: Literal["mover", "white"] = "white" """Whose side the reward is good for.
``mover`` asks the model for "how good is this position for whoever just moved", which is what a reward *is* and which makes the sign flip every ply -- the model has to read the side to move out of the FEN and invert. ``white`` asks for the value head's question instead, "how good is this for White", and puts the flip in the **loss**, where it is a known constant rather than something to learn.
They are the same information and they are not the same task, which is why this is a knob and not a decision: M5 measured both (D-112)."""La clave que merece explicación es point_of_view. Con mover, el modelo tiene que leer del FEN
quién acaba de mover y darle la vuelta al signo cada ply; con white, contesta siempre por las
blancas y el giro vive en la pérdida, donde es una constante conocida. Se midieron las dos, la
diferencia salió pequeña, y queda como palanca documentada.
encoder_ckpt: None entrena desde cero, el baseline que un preentrenamiento tiene que batir. Aquí
no lo bate; lo verás en la configuración.
class RewardExample(BaseConfig): """One preference, already turned into the two positions the encoder reads."""
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
game_id: str chosen: list[int] rejected: list[int] white_moved: bool """Whose move the pair is about. The reward is read from that side's point of view.""" delta_cp: float """How much better the engine said the chosen move was. Not used by the loss -- Bradley-Terry only sees the order -- but it is what the correlation is measured against."""
class RewardBand(BaseConfig): """Accuracy over the pairs whose engine gap falls in one band."""
band: str pairs: int accuracy: floatLa pérdida no usa delta_cp —Bradley-Terry solo ve el orden—; está ahí porque la correlación se
mide contra él. Guardarlo en el ejemplo, en vez de recalcularlo al medir, impide que métrica y
entrenamiento se separen sin que nadie lo note.
class RewardResult(BaseConfig): """What the run produced and what it measured."""
checkpoint: str pairs: int train_pairs: int val_pairs: int accuracy: float """Share of held-out pairs ordered correctly. The bar of ``GOAL.md`` is 75 %.""" loss: float pearson: float | None = None spearman: float | None = None pearson_without_mates: float | None = None """The same correlation over the pairs an evaluation can decide.
Both are published because on this dataset they do not share a sign (D-119): the mate pairs carry the largest engine gap and the smallest learned margin, and a third of the points in that corner is enough to bend the line the other way.""" spearman_without_mates: float | None = None epochs: int = 0 from_scratch: bool = True point_of_view: str = "white" bands: list[RewardBand] = [] """Accuracy split by how far apart the engine scored the two moves.
The number that turns a missed bar into a diagnosis. Accuracy rises with the gap, as it should -- until the mate band, where it falls off a cliff: a mate is a tactical fact and a static evaluation of the resulting position cannot see it. A third of these pairs are mate-scored, so the overall figure is dragged down by the very rows a reward *model* is worst equipped for and a verifiable reward answers exactly.""" accuracy_without_mates: float | None = None """Accuracy over the pairs an evaluation could decide, which is the fair read of the bar."""El resultado lleva siete números de calidad. accuracy es el titular del criterio;
accuracy_without_mates es la lectura justa de ese listón, sin los pares que una evaluación
estática no puede decidir; bands convierte un listón fallado en un diagnóstico; y las
correlaciones van con y sin mates porque en este conjunto no comparten signo. Con un solo campo
accuracy se habría publicado la mitad fácil de lo que se midió.
def _position_after(prefix: str, move_uci: str) -> tuple[str, bool] | None: """``(fen after the move, the mover was white)``; ``None`` when the row does not replay.
A row is dropped rather than repaired. The pairs came from a different pipeline and a prefix that does not replay means the row disagrees with python-chess about the game; guessing which of the two is right is how a dataset quietly acquires positions nobody played. """ board = chess.Board() try: for uci in prefix.split(): board.push_uci(uci) move = chess.Move.from_uci(move_uci) if move not in board.legal_moves: return None mover_was_white = board.turn == chess.WHITE board.push(move) except (ValueError, AssertionError): return None return board.fen(), mover_was_whiteAquí un par deja de ser dos cadenas y pasa a ser dos tableros. Mira el return None: una fila que
no se puede reproducir se descarta, no se repara. Si la fila y python-chess no están de acuerdo
sobre qué partida fue, adivinar quién tiene razón es como un dataset se llena en silencio de
posiciones que nadie jugó. El except recoge las dos excepciones que push_uci puede levantar
según dónde falle.
def encode_pairs(frame: Any, limit: int | None = None) -> list[RewardExample]: """Turn the pair rows into the two token sequences the encoder reads.""" rows = frame.iter_rows(named=True) if hasattr(frame, "iter_rows") else frame examples: list[RewardExample] = [] for row in rows: prefix = row.get("prefix") or "" after_chosen = _position_after(prefix, row["chosen"]) after_rejected = _position_after(prefix, row["rejected"]) if after_chosen is None or after_rejected is None: continue chosen_fen, white_moved = after_chosen rejected_fen, _ = after_rejected examples.append( RewardExample( game_id=str(row["game_id"]), chosen=fen_to_tokens(chosen_fen), rejected=fen_to_tokens(rejected_fen), white_moved=white_moved, delta_cp=abs(float(row["cp_rejected"]) - float(row["cp_chosen"])), ) ) if limit is not None and len(examples) >= limit: break return examplesencode_pairs acepta un DataFrame de polars o una lista de diccionarios (el hasattr) para que
el test de más abajo pruebe el descarte con tres diccionarios escritos a mano, sin parquet.
delta_cp va en valor absoluto porque la orientación ya la lleva el par: chosen es la buena por
construcción.
def split_examples( examples: Sequence[RewardExample], val_fraction: float, seed: int) -> tuple[list[RewardExample], list[RewardExample]]: """Split by **game**, not by pair: two pairs of one game are almost the same pair.""" from rukh.data.labels import game_split
train: list[RewardExample] = [] val: list[RewardExample] = [] for example in examples: side = game_split(example.game_id, val_fraction, seed) (val if side == "val" else train).append(example) return train, valgame_split es la misma CRC-32 pura de M3: da la misma respuesta en cualquier proceso y ejecución,
así que el reparto se repite sin guardar nada en disco. Un reparto aleatorio por par dejaría pares
de la misma partida a los dos lados y el acierto de validación subiría sin avisar. En un
clasificador de texto es la fuga de dos frases del mismo documento, una en entrenamiento y otra en
validación.
def batches( examples: Sequence[RewardExample], size: int, shuffle: bool, seed: int) -> Iterator[tuple[Tensor, Tensor, Tensor, Tensor]]: """``(chosen, rejected, sign, delta_cp)``, where ``sign`` is +1 when White made the move.""" order = list(range(len(examples))) if shuffle: rng = np.random.default_rng(seed) rng.shuffle(order) for start in range(0, len(order), size): chunk = [examples[index] for index in order[start : start + size]] yield ( torch.tensor([example.chosen for example in chunk], dtype=torch.long), torch.tensor([example.rejected for example in chunk], dtype=torch.long), torch.tensor( [1.0 if example.white_moved else -1.0 for example in chunk], dtype=torch.float ), torch.tensor([example.delta_cp for example in chunk], dtype=torch.float), )El tensor que hay que entender es sign: +1 cuando movieron las blancas, −1 cuando movieron las
negras. Convierte «buena para las blancas» en «buena para quien movió», y por eso point_of_view
es una palanca que no toca el modelo. No hay relleno ni máscara: toda posición produce los mismos
69 tokens, así que el lote es rectangular por construcción.
def margins( model: RewardModel, chosen: Tensor, rejected: Tensor, sign: Tensor, point_of_view: str) -> Tensor: """``r(chosen) - r(rejected)``, oriented so that positive means "the preferred one won".
With ``point_of_view="white"`` the model answers White's question and the flip lives here, in a constant the loss knows. With ``"mover"`` the model has to work out from the board who just moved and answer for them, and the sign is always +1. """ difference = model(chosen) - model(rejected) return difference * sign if point_of_view == "white" else differenceAquí vive la palanca: un if de una línea que separa dos tareas de aprendizaje distintas.
@torch.no_grad()def evaluate( model: RewardModel, examples: Sequence[RewardExample], size: int, device: torch.device, point_of_view: str = "white",) -> dict[str, float]: """Accuracy, loss and the two correlations against the engine's margin.""" model.eval() correct = total = 0 losses: list[float] = [] scored: list[float] = [] deltas: list[float] = [] for chosen, rejected, sign, delta in batches(examples, size, shuffle=False, seed=0): chosen, rejected, sign = chosen.to(device), rejected.to(device), sign.to(device) margin = margins(model, chosen, rejected, sign, point_of_view) losses.append(float(-F.logsigmoid(margin).mean())) correct += int((margin > 0).sum()) total += int(chosen.shape[0]) scored.extend(margin.tolist()) deltas.extend(delta.tolist()) model.train() return { "accuracy": correct / max(total, 1), "loss": float(np.mean(losses)) if losses else math.nan, **_correlations(scored, deltas), "bands": band_accuracy(scored, deltas), **_correlations( [m for m, d in zip(scored, deltas, strict=True) if d < MATE_GAP], [d for d in deltas if d < MATE_GAP], suffix="_without_mates", ), }La clave está en las dos llamadas a _correlations: la segunda recibe las mismas listas
filtradas por MATE_GAP. En este conjunto la correlación completa sale negativa y la de los pares
sin mate, positiva; publicar solo la primera diría que el modelo está más seguro cuanto más
parecidas son las dos jugadas, que es lo contrario de lo que pasa.
@torch.no_grad() quita el grafo, pero el dropout lo quita model.eval(). Olvidarlo mete ruido en
la métrica y no en la pérdida, así que la curva sigue pareciendo sana.
Las bandas y las correlaciones son el informe que acompaña al acierto; se pueden saltar en la
primera lectura y volver a ellas cuando leas la card de la lección 6. Al teclear hacen falta,
porque train_reward las llama: despliégalas al escribir el fichero, salvo que lo trajeras entero
al empezarlo.
// Fontaneríaband_accuracy y _correlations: el desglose del acierto por bandas y las dos correlaciones
MATE_GAP = 2000.0"""Above this the pair involves a mate score, which is not a position an evaluation can read."""
BANDS: tuple[tuple[float, float, str], ...] = ( (100, 200, "100-200"), (200, 400, "200-400"), (400, 800, "400-800"), (800, MATE_GAP, "800-2000"), (MATE_GAP, float("inf"), "mate"),)
def band_accuracy(scored: Sequence[float], deltas: Sequence[float]) -> list[RewardBand]: """Accuracy by how far apart the engine put the two moves, plus a mate band of its own.""" margin = np.asarray(scored, dtype=float) gap = np.asarray(deltas, dtype=float) rows: list[RewardBand] = [] for low, high, label in BANDS: mask = (gap >= low) & (gap < high) if not mask.any(): continue rows.append( RewardBand(band=label, pairs=int(mask.sum()), accuracy=float((margin[mask] > 0).mean())) ) return rowsLa tabla de bandas convierte «no llegué al 75 %» en «sé dónde fallo». Cada banda es la distancia
en centipeones entre las dos jugadas, y la de mate va aparte porque una diferencia de ±10 000 es un
marcador de otra escala; meterla en «800+» halagaría a la banda alta. Una banda vacía se omite, así
que la tabla de la model cardModel cardEl README de un repositorio del Hub, y la afirmación pública de qué se publicó y qué se midió. Las de Rukh se generan desde run.json y results.json, no se escriben a mano, y llevan el SHA del fichero y el de los pesos, el muestreo y la suite de cada número, la fecha, lo que no se midió y la licencia. rukh publish cards las regenera todas desde la tabla y sube solo el README. no tiene filas sin medir.
def _correlations( margins: Sequence[float], deltas: Sequence[float], suffix: str = "") -> dict[str, float]: """Pearson and Spearman between the learned margin and the engine's centipawn gap.
Both, because M3 measured a head that got the order right and the scale wrong (D-076). When they disagree, the disagreement *is* the diagnosis.
Reported twice, over all pairs and over the pairs that are not mates, because on this dataset the two do not even share a **sign**: -0.128 against +0.058 (D-119). Mate pairs carry the largest engine gap by construction and the smallest learned margin, so a third of the points sits in the corner that bends the line. One correlation over two regimes describes neither. """ keys = (f"pearson{suffix}", f"spearman{suffix}") if len(margins) < 3: return dict.fromkeys(keys, math.nan) from scipy.stats import pearsonr, spearmanr
margin = np.asarray(margins, dtype=float) delta = np.asarray(deltas, dtype=float) if margin.std() == 0 or delta.std() == 0: return dict.fromkeys(keys, math.nan) return { keys[0]: float(pearsonr(margin, delta).statistic), keys[1]: float(spearmanr(margin, delta).statistic), }Con menos de tres puntos, o una serie constante, devuelve NaN en vez de una correlación: con dos
puntos sale 1,0 siempre, y sin varianza es una división por cero. El suffix permite llamarla dos
veces sin que las claves se pisen.
La carga del encoder es fontanería salvo por una guarda, la del error de abajo; puedes desplegarla al escribirla (o la tienes ya si trajiste el fichero entero al empezarlo).
// Fontaneríaload_squares_encoder: cargar el encoder de M3 comprobando qué esquema de entrada aprendió
def load_squares_encoder(path: Path, map_location: str = "cpu") -> PositionEncoder: """The encoder inside a checkpoint, refusing anything that does not read positions.
The guard is the whole point. A reward model is fed the 69 square tokens of ``rukh.models.squares``, and an encoder trained on the ``moves`` scheme accepts those ids without complaining -- they are all inside its 2 030-token vocabulary -- while meaning something else entirely. Nothing crashes, nothing warns, and the run produces a plausible number from a scrambled representation. It happened: the first reward model of this milestone reached 72.98 % that way.
A heads checkpoint is accepted too, and is in fact the better start: its encoder was already fine-tuned to answer "how good is this position", which is what a reward is. """ from rukh.train.checkpoint import load_checkpoint, load_state
payload = load_checkpoint(path, map_location=map_location) shape = EncoderConfig.model_validate(payload["model_cfg"]) if shape.input != "squares": raise ValueError( f"{path} was trained on the `{shape.input}` scheme; a reward model reads positions " "and needs an encoder trained on `squares`" ) encoder = PositionEncoder(shape) state = payload["model_state"] inner = { key.removeprefix("encoder."): value for key, value in state.items() if key.startswith("encoder.") } load_state(encoder, inner or state) return encoderAquí lo que importa es la guarda. Un reward model come los 69 tokens de casilla de
rukh.models.squares, y un encoder entrenado con el esquema moves acepta esos identificadores sin
quejarse —caben en su vocabulario de 2 030— aunque signifiquen otra cosa.
El resto del fichero es el bucle de entrenamiento de siempre —leer, repartir, iterar épocas, medir—
y sus ayudantes. Se puede saltar en la primera lectura, pero no al teclear: es train_reward, la
función que llama el subcomando. Despliégalo al escribirlo, salvo que trajeras el fichero entero al
empezarlo.
// Fontaneríabuild_model, el bucle train_reward y los ayudantes que escriben y leen run.json
El removeprefix("encoder.") permite cargar tanto un encoder suelto como un checkpoint de las
cabezas de M3, donde el encoder está anidado y ya afinado para contestar «cómo de buena es esta
posición».
def build_model(cfg: RewardConfig, device: torch.device) -> tuple[RewardModel, bool]: """The reward model, from a pretrained encoder when there is one.""" if cfg.encoder_ckpt: encoder = load_squares_encoder(Path(cfg.encoder_ckpt), map_location=str(device)) return RewardModel(encoder, cfg.pooling).to(device), False shape = cfg.model or EncoderConfig(input="squares") if shape.input != "squares": raise ValueError("the reward model reads positions; `model.input` must be `squares`") return RewardModel(PositionEncoder(shape), cfg.pooling).to(device), TrueLa segunda guarda es la misma idea desde cero: si la configuración pide un input que no es
squares, se para aquí en vez de entrenar cuarenta minutos sobre tokens que significan otra cosa.
def train_reward(cfg: RewardConfig) -> RewardResult: """Train the reward model and return what it measured on pairs it never saw.""" from datetime import UTC, datetime
import polars as pl
from rukh.train import pick_device
torch.manual_seed(cfg.seed) device = torch.device(cfg.device or pick_device()) frame = pl.read_parquet(cfg.pairs) examples = encode_pairs(frame, cfg.max_pairs) if not examples: raise ValueError(f"no usable pairs in {cfg.pairs}") train_set, val_set = split_examples(examples, cfg.val_fraction, cfg.seed) if not train_set or not val_set: raise ValueError( f"the split left {len(train_set)} train and {len(val_set)} val pairs; " "check `val_fraction` and that the pairs carry more than one game" ) log.info( "reward model: %d pairs (%d train, %d val) over %d games", len(examples), len(train_set), len(val_set), len({example.game_id for example in examples}), )
model, from_scratch = build_model(cfg, device) optimiser = torch.optim.AdamW(model.parameters(), lr=cfg.lr, weight_decay=cfg.weight_decay) for epoch in range(cfg.epochs): seen = 0 running = 0.0 for chosen, rejected, sign, _delta in batches( train_set, cfg.batch_size, shuffle=True, seed=cfg.seed + epoch ): chosen, rejected, sign = chosen.to(device), rejected.to(device), sign.to(device) loss = -F.logsigmoid(margins(model, chosen, rejected, sign, cfg.point_of_view)).mean() loss.backward() optimiser.step() optimiser.zero_grad(set_to_none=True) running += float(loss.detach()) * int(chosen.shape[0]) seen += int(chosen.shape[0]) measured = evaluate(model, val_set, cfg.batch_size, device, cfg.point_of_view) log.info( "epoch %d/%d train loss %.4f val loss %.4f val accuracy %.4f", epoch + 1, cfg.epochs, running / max(seen, 1), measured["loss"], measured["accuracy"], )
final = evaluate(model, val_set, cfg.batch_size, device, cfg.point_of_view) stamp = f"{datetime.now(UTC):%Y%m%d-%H%M%S}" directory = Path(cfg.out_dir) / f"{cfg.run_name}-{stamp}" directory.mkdir(parents=True, exist_ok=True) checkpoint = directory / "reward.pt" torch.save( { "model": model.state_dict(), "encoder_config": model.encoder.cfg.model_dump(mode="json"), "pooling": cfg.pooling, "config": cfg.model_dump(mode="json"), }, checkpoint, ) result = RewardResult( checkpoint=checkpoint.as_posix(), pairs=len(examples), train_pairs=len(train_set), val_pairs=len(val_set), accuracy=final["accuracy"], loss=final["loss"], pearson=_clean(final["pearson"]), spearman=_clean(final["spearman"]), pearson_without_mates=_clean(final["pearson_without_mates"]), spearman_without_mates=_clean(final["spearman_without_mates"]), epochs=cfg.epochs, from_scratch=from_scratch, point_of_view=cfg.point_of_view, bands=list(final["bands"]), accuracy_without_mates=_without_mates(final["bands"]), ) (directory / "run.json").write_text( json.dumps(result.model_dump(mode="json"), indent=2) + "\n", encoding="utf-8", newline="\n", ) return resultEl bucle no tiene sorpresas: leer, codificar, repartir, iterar épocas y medir al final de cada una. Tres detalles:
- Si el reparto deja un lado vacío (pasa con un fichero de prueba de una sola partida), el error
dice qué mirar en vez de caer más tarde con un
mean of empty slice. - El checkpoint lleva dentro la configuración del encoder. Uno que no lleva su propia forma obliga
a adivinarla al cargarlo, que es el fallo de
load_squares_encoderpor el otro lado. run.jsonguarda el resultado entero junto al checkpoint. La lección 6 lo usa para escribir la model card, y el publicador se niega a publicar un checkpoint sin él.
def _clean(value: float) -> float | None: """`None` instead of NaN, so the run file reads as "not measured" and not as a number.""" return None if math.isnan(value) else float(value)
def _without_mates(bands: Sequence[RewardBand]) -> float | None: """Accuracy over everything but the mate band: what the model could actually have known.""" usable = [row for row in bands if row.band != "mate"] pairs = sum(row.pairs for row in usable) if not pairs: return None return sum(row.accuracy * row.pairs for row in usable) / pairs
def load_reward(path: Path | str, map_location: str | None = None) -> RewardModel: """Read a reward-model checkpoint back.""" payload = torch.load(Path(path), map_location=map_location or "cpu", weights_only=False) encoder = PositionEncoder(EncoderConfig.model_validate(payload["encoder_config"])) model = RewardModel(encoder, payload.get("pooling", "mean")) model.load_state_dict(payload["model"]) return model.eval()_clean cambia NaN por None para que el run.json diga «no medido» (en JSON estándar un NaN ni
siquiera es válido). _without_mates recompone el acierto sin la banda de mate, ponderando por
pares. load_reward lee el checkpoint de vuelta para el publicador.
La configuración, donde tres decisiones son medidas
La mitad de configs/train/rm.yaml es comentario, y cada comentario explica una decisión que costó
una ejecución.
El YAML se puede saltar en la primera lectura: la decisión que importa está resumida justo debajo.
Si también lo saltas al teclear, tráelo, porque el «Ejecútalo» que entrena lo lee:
git checkout p5 -- configs/train/rm.yaml.
// Fontaneríaconfigs/train/rm.yaml: la configuración del reward model, con cada decisión comentada
# The reward model of M5: a position encoder with one scalar head, trained on preference pairs.## Three things in this file were decided by measurement and not by taste (D-112).## `encoder_ckpt: null` -- it trains from scratch. The only `squares` encoder the project has was# fine-tuned for value, blunder and result, and starting from it scores **62 %** against **69 %**# from a random initialisation at the same size. A representation optimised for one question can# be a worse starting point than no representation at all.## The size is 12 x 512 and not 8 x 384 because that is what the accuracy tracks: 73 % against# 69 %. The first run of this milestone reached 73 % by accident, loading a `moves` encoder whose# embeddings meant nothing for square tokens, and matched what scratch does at the same capacity.# `load_squares_encoder` now refuses that mistake instead of producing a plausible number from it.## `point_of_view: white` asks the model the value head's question and puts the sign flip in the# loss, where it is a constant. Asking it for the mover's point of view instead costs about two# points, which is small and is measured rather than assumed.pairs: data/pairs/dpo-prompts.parquetencoder_ckpt: nullmodel: input: squares n_layer: 12 n_head: 8 d_model: 512 dropout: 0.1pooling: meanout_dir: checkpointsrun_name: rmlr: 1.0e-4weight_decay: 0.01batch_size: 64epochs: 10val_fraction: 0.1seed: 42point_of_view: whiteLa primera decisión es la que se lleva uno: partir del encoder de M3 da unos 6,5 puntos menos
que inicializar al azar (62 % frente a 69 % con la misma talla de 8 × 384). El único encoder de
casillas del proyecto es el de cabezas de M3, afinado para contestar valor, error grave y
resultado, y aquí se le pide ordenar jugadas por lo que dejan en el tablero. El docstring de
load_squares_encoder lo llama «the better start»: es lo que se esperaba antes de medirlo, y la
medida dijo lo contrario. Que un modelo esté «preentrenado» describe la
relación entre lo que aprendió y lo que le vas a pedir; cuando esa relación no existe, el azar gana.
El subcomando
La CLI añade rukh train reward: carga el YAML, entrena e imprime juntos los números que el
resultado obliga a publicar.
Es fontanería de CLI y puedes saltarla al leer; al teclear no, porque el «Ejecútalo» de debajo la
usa. Si la saltas, tráela: git checkout p5 -- src/rukh/cli.py deja el cli.py de la etiqueta,
con este subcomando y los de las lecciones 3 a 6, que entonces ya no tendrás que teclear. Importa
cada módulo dentro de su subcomando, así que funciona aunque los ficheros de esas lecciones aún no
existan.
// Fontaneríarukh train reward: el subcomando que carga el YAML, entrena e imprime los números
@train_app.command("reward")def train_reward_cmd( config: Annotated[ Path, typer.Option("--config", exists=True, dir_okay=False, help="Reward-model run YAML."), ], 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: """Train the reward model on preference pairs and measure it on pairs it never saw.
The reward model is **not** what DPO uses -- DPO's reference is implicit and needs no reward model at all. It is here because it is the piece that makes PPO make sense, because its accuracy on held-out pairs is a bar `GOAL.md` puts a number on, and because a learned reward is what GRPO's verifiable ones are compared against. """ from rukh.config import load_yaml from rukh.train.reward import RewardConfig, train_reward
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") cfg = load_yaml(config, RewardConfig) if device: cfg.device = device try: result = train_reward(cfg) except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc if as_json: typer.echo(result.model_dump_json(indent=2)) return typer.echo(f"checkpoint: {result.checkpoint}") split = f"{result.train_pairs:,} train, {result.val_pairs:,} val" typer.echo(f"pairs: {result.pairs:,} ({split})") typer.echo(f"accuracy: {result.accuracy * 100:.2f} % on held-out pairs") if result.accuracy_without_mates is not None: clean = result.accuracy_without_mates * 100 typer.echo(f" {clean:.2f} % over the pairs an evaluation can decide") for row in result.bands: typer.echo(f" {row.band:>9} cp {row.pairs:>5} pairs {row.accuracy * 100:6.2f} %") typer.echo(f"loss: {result.loss:.4f}") if result.pearson is not None and result.spearman is not None: correlations = f"Pearson {result.pearson:+.3f}, Spearman {result.spearman:+.3f}" typer.echo(f"margin vs delta cp: {correlations}") if result.pearson_without_mates is not None and result.spearman_without_mates is not None: # Printed beside the other one because the two do not share a sign (D-119). clean_correlations = ( f"Pearson {result.pearson_without_mates:+.3f}, " f"Spearman {result.spearman_without_mates:+.3f}" ) typer.echo(f" without mates: {clean_correlations}") origin = "from scratch" if result.from_scratch else "from the encoder" typer.echo(f"started: {origin}")El try/except convierte los dos errores previsibles en un mensaje y un código de salida. El
bucle sobre result.bands pone el desglose delante de quien lanzó el comando: si el titular no
llega, la línea siguiente ya dice por qué.
Salida en la máquina de referencia (RTX 5090), la del comando de arriba con la semilla 42 de
rm.yaml (rm-20260920-223655, la que está publicada), desde la línea del acierto:
accuracy: 74.21 % on held-out pairs 75.77 % over the pairs an evaluation can decide 100-200 cp 616 pairs 76.30 % 200-400 cp 247 pairs 74.09 % 400-800 cp 101 pairs 79.21 % 800-2000 cp 14 pairs 57.14 % mate cp 484 pairs 71.07 %margin vs delta cp: Pearson -0.148, Spearman -0.070 without mates: Pearson +0.081, Spearman +0.089Con la semilla 42 caen en validación 484 pares de mate de 1 462, un tercio; con otra semilla el reparto cambia (la 1 deja 356 de 1 268), y con él el titular.
Los tests: lo que la pérdida promete
tests/unit/test_reward_model.py escribe el contrato: qué promete Bradley-Terry y las dos cosas que
el bucle de datos tiene que hacer bien. Su segunda mitad va plegada; si no quieres teclearla, trae
el fichero entero antes de empezarlo, git checkout p5 -- tests/unit/test_reward_model.py, y
lee lo de abajo contra él.
"""The reward model: what Bradley-Terry does and does not promise.
A preference loss only ever sees *differences*, which has a consequence worth a test of its own:the scale has no meaning, and a model whose answer changes when every score shifts by a constantis broken in a way nothing downstream would notice."""
from __future__ import annotations
import chessimport pytestimport torch
from rukh.models.config import EncoderConfigfrom rukh.models.encoder import PositionEncoderfrom rukh.models.reward import RewardModel, preference_accuracy, preference_lossfrom rukh.models.squares import SQUARE_TOKENS, fen_to_tokensfrom rukh.train.reward import RewardExample, encode_pairs, split_examples
pytestmark = pytest.mark.unit
TOY = EncoderConfig(input="squares", n_layer=1, n_head=2, d_model=16, dropout=0.0)
def _model() -> RewardModel: torch.manual_seed(0) return RewardModel(PositionEncoder(TOY))
def test_the_loss_only_sees_the_difference(): """Shift every score by a constant and nothing changes: that *is* Bradley-Terry.""" chosen = torch.tensor([1.0, -0.5, 3.0]) rejected = torch.tensor([0.0, -2.0, 1.0]) base = preference_loss(chosen, rejected) for shift in (-100.0, 0.25, 7.0): assert preference_loss(chosen + shift, rejected + shift) == pytest.approx(float(base))
def test_a_perfect_ordering_drives_the_loss_towards_zero(): wide = torch.tensor([20.0, 20.0]) low = torch.tensor([-20.0, -20.0]) assert float(preference_loss(wide, low)) == pytest.approx(0.0, abs=1e-6) assert preference_accuracy(wide, low) == 1.0 # And a coin flip costs log 2 per pair, which is the loss of a model that knows nothing. same = torch.zeros(4) assert float(preference_loss(same, same)) == pytest.approx(0.6931, abs=1e-3)
def test_a_tie_counts_as_wrong_rather_than_as_half_right(): """A model that scores two moves the same has expressed no preference.""" same = torch.tensor([1.0, 1.0]) assert preference_accuracy(same, same) == 0.0
def test_the_model_reads_a_position_and_answers_one_number_per_row(): model = _model() board = chess.Board() board.push_uci("e2e4") idx = torch.tensor([fen_to_tokens(board.fen())] * 3, dtype=torch.long) assert idx.shape == (3, SQUARE_TOKENS) scores = model(idx) assert scores.shape == (3,) # The same position scores the same, which sounds obvious and is the pooling being right. assert float(scores.detach().std()) == pytest.approx(0.0, abs=1e-6)tests/unit/test_reward_model.py
El primero es la invariancia: desplazar todas las puntuaciones por una constante no cambia la
pérdida ni un decimal. El segundo fija los extremos: una ordenación perfecta lleva la pérdida a
cero, y una moneda al aire cuesta log 2 = 0,6931 por par. Ese 0,6931 es la línea contra la que se
lee cualquier curva de pérdida de preferencias: por encima, el modelo va peor que no saber nada. El
tercero fija el empate como fallo, para que nadie lo «arregle» a medio punto.
Los tests que quedan vigilan los datos y el extremo a extremo; no hacen falta para seguir la lección, pero el «Ejecútalo» de después cuenta siete tests, y tres están aquí: despliégalos al teclear, salvo que trajeras el fichero entero al empezarlo.
// TestsLos tests de datos y el entrenamiento de un lote: descarte, reparto por partida y la pérdida que baja
def test_a_row_that_does_not_replay_is_dropped_rather_than_repaired(): """Guessing which of the dataset and python-chess is right is how bad positions creep in.""" rows = [ { "game_id": "1", "prefix": "e2e4", "chosen": "e7e5", "rejected": "h7h5", "cp_chosen": 10, "cp_rejected": 110, }, # An illegal "chosen" for this prefix: white cannot move twice. { "game_id": "2", "prefix": "e2e4", "chosen": "d2d4", "rejected": "e7e5", "cp_chosen": 10, "cp_rejected": 110, }, # A prefix that is not a legal game at all. { "game_id": "3", "prefix": "e2e9", "chosen": "e7e5", "rejected": "h7h5", "cp_chosen": 10, "cp_rejected": 110, }, ] kept = encode_pairs(rows) assert [example.game_id for example in kept] == ["1"] assert len(kept[0].chosen) == len(kept[0].rejected) == SQUARE_TOKENS assert kept[0].delta_cp == 100
def test_the_split_goes_by_game_so_two_pairs_of_one_game_cannot_straddle_it(): """Two pairs a few plies apart are almost the same pair; splitting by pair leaks.""" examples = [ RewardExample( game_id=str(game), chosen=[0] * 4, rejected=[0] * 4, white_moved=True, delta_cp=100.0, ) for game in range(200) for _ in range(3) ] train, val = split_examples(examples, val_fraction=0.2, seed=42) assert train and val assert len(train) + len(val) == len(examples) overlap = {e.game_id for e in train} & {e.game_id for e in val} assert overlap == set(), "no game may appear on both sides of the split" # Every game keeps all three of its pairs on the same side. for side in (train, val): counts = {} for example in side: counts[example.game_id] = counts.get(example.game_id, 0) + 1 assert set(counts.values()) == {3}tests/unit/test_reward_model.py
Los dos de datos. El del descarte comprueba que de tres filas escritas a mano solo sobrevive la que se puede reproducir. El del reparto construye 200 partidas de tres pares y comprueba que las tres filas de cada partida se quedan juntas: es lo que fallaría con un reparto por par, y convierte la fuga en un test rojo en vez de en un número optimista.
def test_training_one_batch_moves_the_scores_apart(): """The smallest end-to-end check: the loss goes down and the ordering improves.""" model = _model() board = chess.Board() board.push_uci("e2e4") good = torch.tensor([fen_to_tokens(board.fen())], dtype=torch.long) board.pop() board.push_uci("f2f3") bad = torch.tensor([fen_to_tokens(board.fen())], dtype=torch.long)
optimiser = torch.optim.AdamW(model.parameters(), lr=0.05) first = float(preference_loss(model(good), model(bad))) for _ in range(30): loss = preference_loss(model(good), model(bad)) loss.backward() optimiser.step() optimiser.zero_grad(set_to_none=True) last = float(preference_loss(model(good), model(bad))) assert last < first assert preference_accuracy(model(good), model(bad)) == 1.0tests/unit/test_reward_model.py
El último es el extremo a extremo más pequeño posible: e4 contra f3, treinta pasos de AdamW, y
la pérdida tiene que bajar y la ordenación salir bien. Tarda menos de un segundo y separa «el código
importa» de «el código entrena».
// Ejercicio 01Rompe la invariancia a propósito
Cambia preference_loss por -F.logsigmoid(chosen - rejected).mean() - 0.01 * chosen.mean()
—un término que parece inocente, «que las buenas puntúen alto»— y corre los tests. ¿Cuál falla, y
qué le pasaría al modelo entrenado con esa pérdida?
// SoluciónVer la solución
Falla test_the_loss_only_sees_the_difference: con el término nuevo, desplazar las dos
puntuaciones por +7 baja la pérdida en 0,07. Y el modelo entrenado así aprende dos cosas a la
vez, ordenar pares y subir todas las puntuaciones, que es justo lo que la cabeza sin acotar deja
hacer sin límite: la pérdida puede seguir bajando indefinidamente sin que la ordenación mejore.
El síntoma es una pérdida que baja con el acierto de validación plano, de los más difíciles de
diagnosticar. Es el reward hacking en miniatura: el optimizador encuentra la dirección que el
objetivo paga y que nadie quería pagar.
// Ejercicio 02¿Por qué `sign` y no un segundo modelo?
point_of_view: white mete el giro de signo en la pérdida. ¿Por qué no entrenar dos cabezas,
una para cada bando, y usar la que toque?
// SoluciónVer la solución
Porque serían dos modelos con la mitad de datos cada uno aprendiendo la misma función. El ajedrez es simétrico por color: la posición espejada —filas invertidas, colores y turno cambiados— vale para un bando lo mismo que la original para el otro. Una cabeza que aprende «lo buena que es para las blancas» ya sabe contestar por las negras con un signo. Dividir los datos para aprender dos veces la misma cosa es el error contrario al de repartir por par: allí se comparte lo que no se debe, aquí se separa lo que no hace falta.
Qué has aprendido
Un reward model es un encoder con una capa lineal encima (el de un asistente, un LLM con una capa lineal encima). Su pérdida cabe en una línea y solo ve diferencias, así que su escala no se interpreta nunca. El titular esconde dónde falla, y por eso se publica con bandas y correlaciones. Y dos guardas pequeñas, el reparto por partida y el rechazo del encoder equivocado, tapan los fallos más caros: los que devuelven números creíbles.
Lo siguiente es DPO: el mismo tipo de pares, sin reward model, entrenando la política directamente.