rukh · lab

// M5 · lección 03

DPO: la referencia implícita, y de dónde salen las dos jugadas

El fichero de DPO entero —donde una completación de un token colapsa toda la maquinaria de secuencia— y el constructor de pares dentro de política, con las tres decisiones que hacen que los dos brazos sean comparables y la que no se puede igualar.

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

Qué vas a construir

El entrenamiento que aprende de los pares sin reward model, y el dataset que permite hacerle al método la pregunta que importa: ¿cambia algo que los pares los haya escrito otro o los haya propuesto el propio modelo?

Son dos ficheros —DPO y los pares dentro de política—, tres configuraciones cortas y los dos subcomandos que los lanzan. src/rukh/train/dpo.py ya lo tienes: lo trajiste de p4 en la lección 2 de M4, sin subcomando que lo lanzara, y en p5 no ha cambiado. Aquí se lee entero, sin teclearlo, porque es donde por fin se usa. Cómo se juzga lo que entrenes aquí está en «Cómo vamos a medir»: el modelo alineado contra su base, en un enfrentamiento directo.

DPO: con una jugada por completación, todo pasa en una posición

Recuerda la idea de la lección 1: 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. entrena la políticaPolítica (policy)El modelo visto como quien decide: dada una posición, la distribución de jugadas que propone; en inglés, policy. Es el nombre que el aprendizaje por refuerzo le da a lo que en el resto del curso es «el modelo», y aparece en M5 porque DPO y GRPO la entrenan contra una referencia congelada de la que β mide cuánto se le deja alejarse. directamente sobre los pares, sin reward model, frente a una referencia que es una copia congelada de los pesos de partida.

src/rukh/train/dpo.py
"""Direct Preference Optimization on the decoder, over engine-scored move pairs.
Every other lever tried so far teaches the model to *imitate*: more games, better games, a header
saying how strong the players were. Imitation is capped by the corpus, and D-069 measured that cap
-- conditioning on 2600 does not make the model play better, it makes it play like a 2600 losing
to tactics. DPO changes the objective. Each example says "in this position `chosen` is at least
``min_delta_cp`` better than `rejected`", which is information no human game contains, and the
loss pushes probability from one to the other instead of towards the average continuation.
Our completion is a single token -- one move -- so the usual sequence-level DPO collapses to the
log-probability of two tokens at one position, which is why this file is short.
"""

src/rukh/train/dpo.pylíneas 1-12 · p5

La última frase del docstring explica el tamaño del fichero. En un LLM de texto, una completación es una respuesta entera: hay que sumar log-probabilidades a lo largo de muchos tokens, cuidar el relleno y decidir si se normaliza por longitud (el DPOTrainer de TRL lo hace por ti). Aquí la completación es una jugada, un token, así que todo se reduce a leer dos números de la distribución en una posición. GRPO se beneficia de lo mismo en la lección siguiente, y por eso los dos algoritmos caben enteros en el curso.

Las importaciones se pueden saltar.

// FontaneríaLas importaciones de dpo.py
src/rukh/train/dpo.py
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING
import numpy as np
import torch
import torch.nn.functional as F
from pydantic import Field
from rukh.config import BaseConfig
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer, elo_token
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
LOGGER = logging.getLogger(__name__)

src/rukh/train/dpo.pylíneas 14-33 · p5

MoveDecoder y UciTokenizer son los de M2, sin cambios.

src/rukh/train/dpo.py
class DpoConfig(BaseConfig):
"""Where the pairs and the starting weights are, and the two knobs that matter."""
pairs: str = "data/pairs/dpo-prompts.parquet"
checkpoint: str = ""
out_dir: str = "checkpoints"
run_name: str = "dpo"
beta: float = Field(default=0.1, gt=0)
"""How hard the loss pushes. Above ~0.5 the policy leaves the reference and legality goes."""
lr: float = Field(default=5e-6, gt=0)
nll_weight: float = Field(default=0.0, ge=0)
"""Optional anchor on the chosen move. Pure DPO can raise the margin while the model forgets
how to play at all; a little next-token loss keeps it honest."""
batch_size: int = Field(default=32, ge=1)
epochs: int = Field(default=1, ge=1)
block: int = Field(default=200, ge=8)
val_fraction: float = Field(default=0.1, ge=0.0, lt=0.5)
seed: int = 42
device: str | None = None

src/rukh/train/dpo.pylíneas 36-54 · p5

beta es la dureza de la correa de la lección 1: con un β grande basta alejarse poco de la referencia para que la pérdida quede satisfecha, así que sujeta más (aunque al arrancar también empuja más fuerte: al arrancar la política es la referencia, el margen vale cero y σ(0) = ½, así que la derivada de la pérdida respecto del margen empieza en β · ½; con el doble de β, el primer empujón es el doble). El aviso del comentario sobre 0,5 es una advertencia de uso, sin medir: Rukh solo ha entrenado con 0,1.

nll_weight vale 0,1 en las dos ejecuciones del hito, y es la palanca que hay que entender. DPO sube el margen: cuánto más prefiere la política la jugada buena de lo que la prefería la referencia, menos lo mismo para la mala. Un margen grande solo dice que la buena le gana a la otra, y nada impide llegar ahí hundiendo las dos, la mala un poco más:

lo que se buscaprobabilidad de cada jugada0,300,45chosen (la buena)×1,50,200,08rejected (la mala)×0,4margen = ln 1,5 − ln 0,4 = +1,32lo que el margen también premiaprobabilidad de cada jugada0,300,18chosen (la buena)×0,60,200,03rejected (la mala)×0,15margen = ln 0,6 − ln 0,15 = +1,39antes: referencia congeladadespués: la política tras DPO
Ejemplo ilustrativo, no una medición. El margen de DPO compara cuánto se ha movido cada jugada respecto a la referencia. A la derecha las dos bajan, la mala más deprisa, y el margen sale incluso algo mayor que a la izquierda: la pérdida no distingue los dos casos. Por eso se mira el acierto aparte, y por eso existe nll_weight.

nll_weight añade la pérdida de siguiente token sobre chosen, que ancla la política a seguir sabiendo jugar mientras el margen crece. Por lo mismo, la pérdida devuelve el margen y el acierto por separado: separar mucho unos pares y cruzar otros por el camino también sube la media, y en la lección 8 verás a uno de los brazos hacerlo.

El lote y la codificación de los pares son fontanería de datos; puedes saltarlos en la primera lectura, salvo una idea: cada lote guarda en last dónde acaba de verdad cada prompt, y lo verás usado dos bloques más abajo.

// FontaneríaPreferenceBatch y encode_pairs: de filas de pares a prompts y tokens de jugada
src/rukh/train/dpo.py
class PreferenceBatch:
"""Padded prompts plus the index of the position that predicts the move."""
def __init__(
self, tokens: torch.Tensor, last: torch.Tensor, chosen: torch.Tensor, rejected: torch.Tensor
) -> None:
self.tokens, self.last, self.chosen, self.rejected = tokens, last, chosen, rejected
def to(self, device: torch.device) -> PreferenceBatch:
return PreferenceBatch(
self.tokens.to(device),
self.last.to(device),
self.chosen.to(device),
self.rejected.to(device),
)
def __len__(self) -> int:
return int(self.tokens.shape[0])

src/rukh/train/dpo.pylíneas 57-74 · p5

Un lote de preferencias es el prompt relleno, el índice donde acaba cada prompt y los dos tokens de jugada. Ese last evita el error silencioso del fichero; lo verás dos funciones más abajo.

src/rukh/train/dpo.py
def encode_pairs(frame: object, tok: UciTokenizer, block: int) -> list[tuple[list[int], int, int]]:
"""``(prompt ids, chosen id, rejected id)`` for every usable row.
A row is dropped when either move is outside the fixed vocabulary (a promotion spelling the
enumeration does not carry) or when the prompt would not fit in ``block``: silently
truncating a prefix would hand the model a position that never happened.
"""
rows: list[tuple[list[int], int, int]] = []
for prefix, chosen, rejected, white, black in zip(
frame["prefix"],
frame["chosen"],
frame["rejected"], # type: ignore[index]
frame["white_elo"],
frame["black_elo"],
strict=True, # type: ignore[index]
):
chosen_id = tok.vocab.get(str(chosen))
rejected_id = tok.vocab.get(str(rejected))
if chosen_id is None or rejected_id is None or chosen_id == rejected_id:
continue
ids = [
tok.bos_id,
tok.vocab[elo_token(int(white), "w")],
tok.vocab[elo_token(int(black), "b")],
]
moves = [tok.vocab.get(m) for m in str(prefix).split()]
if any(m is None for m in moves):
continue
ids += [int(m) for m in moves] # type: ignore[arg-type]
if len(ids) > block:
continue
rows.append((ids, chosen_id, rejected_id))
return rows

src/rukh/train/dpo.pylíneas 77-109 · p5

Una fila se descarta si una jugada cae fuera del vocabulario fijo, si el prompt no cabe en block o si las dos jugadas son iguales. La segunda merece su frase: cortarle el principio a una partida le daría al modelo una posición que no ocurrió, con una preferencia medida sobre otra cosa. Descartar y contar es honesto; recortar es fabricar datos.

src/rukh/train/dpo.py
def batches(
rows: list[tuple[list[int], int, int]], size: int, pad_id: int, shuffle: bool, seed: int
) -> Iterator[PreferenceBatch]:
"""Pad each batch to its own longest prompt and remember where each prompt ends."""
order = np.arange(len(rows))
if shuffle:
np.random.default_rng(seed).shuffle(order)
for start in range(0, len(order), size):
chunk = [rows[int(i)] for i in order[start : start + size]]
if not chunk:
continue
width = max(len(ids) for ids, _, _ in chunk)
tokens = torch.full((len(chunk), width), pad_id, dtype=torch.long)
last = torch.empty(len(chunk), dtype=torch.long)
for i, (ids, _, _) in enumerate(chunk):
tokens[i, : len(ids)] = torch.tensor(ids, dtype=torch.long)
last[i] = len(ids) - 1
yield PreferenceBatch(
tokens,
last,
torch.tensor([c for _, c, _ in chunk], dtype=torch.long),
torch.tensor([r for _, _, r in chunk], dtype=torch.long),
)

src/rukh/train/dpo.pylíneas 112-134 · p5

Cada lote se rellena hasta su propio prompt más largo, y last[i] guarda dónde acaba de verdad cada prompt.

La tentación sería leer los logits en la última columna, [:, -1]. En toda fila más corta que la más larga del lote esa columna es relleno, y la pérdida se calcularía sobre lo que el modelo predice después del padding. Bajaría igual —el modelo aprende a predecir algo a partir del relleno— y la curva saldría sana sin haber aprendido lo que se pedía. Cualquier LLM con lotes de longitudes distintas exige el mismo cuidado: leer la posición correcta de cada fila.

src/rukh/train/dpo.py
def move_logprobs(model: MoveDecoder, batch: PreferenceBatch) -> torch.Tensor:
"""Log-probabilities over the vocabulary at the position that predicts the next move."""
logits, _ = model(batch.tokens)
picked = logits[torch.arange(logits.shape[0], device=logits.device), batch.last]
return F.log_softmax(picked.float(), dim=-1)

src/rukh/train/dpo.pylíneas 137-141 · p5

El .float() importa: el modelo puede correr en bf16bfloat16Formato de coma flotante de 16 bits con los mismos 8 bits de exponente que fp32 y solo 7 de mantisa: pierde precisión pero conserva el rango, así que un gradiente pequeño no se va a cero. Por eso entrena sin escalado de pérdida, al contrario que fp16, cuyo exponente de 5 bits desborda por abajo., y un log_softmax en media precisión sobre 2 030 clases pierde dígitos en la cola, donde viven las jugadas que el par quiere separar.

src/rukh/train/dpo.py
def dpo_loss(
policy: torch.Tensor, reference: torch.Tensor, batch: PreferenceBatch, beta: float
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""The DPO objective for a one-token completion, plus the margin and the accuracy.
``margin`` is ``rewards/margins`` in the usual reporting: how much more the policy prefers the
better move than the reference did. ``accuracy`` is the share of pairs it orders correctly,
which is the number to watch -- a margin can grow while the ordering stays wrong.
"""
index = torch.arange(policy.shape[0], device=policy.device)
chosen = policy[index, batch.chosen] - reference[index, batch.chosen]
rejected = policy[index, batch.rejected] - reference[index, batch.rejected]
margin = chosen - rejected
loss = -F.logsigmoid(beta * margin).mean()
accuracy = (policy[index, batch.chosen] > policy[index, batch.rejected]).float().mean()
return loss, margin.mean(), accuracy

src/rukh/train/dpo.pylíneas 144-159 · p5

La pérdida entera de DPO. Para cada par, chosen es cuánto ha subido la política la jugada buena respecto a la referencia, rejected lo mismo para la mala, y el margen es la diferencia de las dos diferencias, lo que dibuja la figura de arriba. En cuanto β · margen es grande, el par deja de empujar.

accuracy, en cambio, mira la política sola. El margen dice cuánto se ha movido respecto a donde estaba; el acierto, si la ordenación que produce ahora es la correcta. Se puede mover mucho y ordenar peor.

src/rukh/train/dpo.py
def evaluate(
policy: MoveDecoder,
reference: MoveDecoder,
rows: list[tuple[list[int], int, int]],
cfg: DpoConfig,
device: torch.device,
pad_id: int,
) -> tuple[float, float, float]:
"""Mean loss, margin and pair accuracy over held-out pairs."""
policy.eval()
totals = [0.0, 0.0, 0.0]
seen = 0
with torch.no_grad():
for batch in batches(rows, cfg.batch_size, pad_id, shuffle=False, seed=cfg.seed):
batch = batch.to(device)
loss, margin, accuracy = dpo_loss(
move_logprobs(policy, batch), move_logprobs(reference, batch), batch, cfg.beta
)
n = len(batch)
totals[0] += float(loss) * n
totals[1] += float(margin) * n
totals[2] += float(accuracy) * n
seen += n
policy.train()
if not seen:
return math.nan, math.nan, math.nan
return totals[0] / seen, totals[1] / seen, totals[2] / seen

src/rukh/train/dpo.pylíneas 162-188 · p5

La validación pondera por el tamaño de cada lote (el último casi nunca está lleno) y devuelve NaN en vez de cero cuando no hay nada que medir, porque un cero en una columna de pérdidas se lee como un resultado excelente.

src/rukh/train/dpo.py
def train(cfg: DpoConfig) -> Path:
"""Align a decoder on the preference pairs and return the checkpoint it wrote."""
import copy
import polars as pl
from rukh import paths
from rukh.train.checkpoint import load_model, save_checkpoint
from rukh.train.common import pick_device
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.pairs).as_posix())
rows = encode_pairs(frame, tok, cfg.block)
if not rows:
raise ValueError(f"{cfg.pairs} produced no usable pairs")
cut = int(len(rows) * (1 - cfg.val_fraction))
rng = np.random.default_rng(cfg.seed)
order = rng.permutation(len(rows))
train_rows = [rows[int(i)] for i in order[:cut]]
val_rows = [rows[int(i)] for i in order[cut:]]
LOGGER.info("dpo: %d pares de entrenamiento, %d de validación", len(train_rows), len(val_rows))
optimizer = torch.optim.AdamW(policy.parameters(), lr=cfg.lr)
out_dir = paths.resolve(cfg.out_dir) / cfg.run_name
out_dir.mkdir(parents=True, exist_ok=True)
before = evaluate(policy, reference, val_rows, cfg, device, tok.pad_id)
LOGGER.info("antes: loss %.4f · margen %.4f · aciertos %.4f", *before)
step = 0
for epoch in range(cfg.epochs):
for batch in batches(
train_rows, cfg.batch_size, tok.pad_id, shuffle=True, seed=cfg.seed + epoch
):
batch = batch.to(device)
policy_logprobs = move_logprobs(policy, batch)
with torch.no_grad():
reference_logprobs = move_logprobs(reference, batch)
loss, margin, accuracy = dpo_loss(policy_logprobs, reference_logprobs, batch, cfg.beta)
if cfg.nll_weight:
index = torch.arange(policy_logprobs.shape[0], device=device)
loss = loss - cfg.nll_weight * policy_logprobs[index, batch.chosen].mean()
optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(policy.parameters(), 1.0)
optimizer.step()
step += 1
if step % 50 == 0:
LOGGER.info(
"paso %d · loss %.4f · margen %.4f · aciertos %.4f",
step,
float(loss),
float(margin),
float(accuracy),
)
after = evaluate(policy, reference, val_rows, cfg, device, tok.pad_id)
LOGGER.info("después: loss %.4f · margen %.4f · aciertos %.4f", *after)
final = out_dir / "dpo.pt"
save_checkpoint(
final,
step=step,
model=policy,
optimizer=None,
cfg={
**cfg.model_dump(mode="json"),
"val_before": dict(zip(("loss", "margin", "accuracy"), before, strict=True)),
"val_after": dict(zip(("loss", "margin", "accuracy"), after, strict=True)),
},
model_cfg=payload["model_cfg"],
vocab_hash=payload.get("vocab_hash"),
data_manifest_sha=payload.get("data_manifest_sha"),
)
return final

src/rukh/train/dpo.pylíneas 191-272 · p5

El bucle completo. Lo que hay que mirar:

  • La referencia se congela dos veces: requires_grad_(False) por los gradientes y .eval() por el modo. Lo segundo es lo que se olvida. En modo train sigue aplicando dropout, el mismo par da una log-probabilidad de referencia distinta en cada paso y β mide ruido sin que la curva lo enseñe.
  • nll_weight resta la log-probabilidad media de la jugada preferida, y con peso 0 ni se calcula.
  • clip_grad_norm_ a 1.0: un par que la política ordena muy mal produce un gradiente grande, y con lr de 5e-6 un solo par así puede mover el modelo más que la época entera.
  • El checkpoint guarda val_before y val_after, y la lección 8 los pone en la misma tabla sin volver a medir.

Una advertencia sobre ese acierto de validación: el cut reparte pares sueltos con rng.permutation, justo lo que el reward model evita con split_examples. Dos pares de la misma partida, a 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., pueden caer a los dos lados, así que el número sale algo inflado. Sirve para comparar dos brazos con el mismo reparto; el que decide es el enfrentamiento directo.

Los pares que salen del propio modelo

Los 13 838 pares de M1 son fuera de política. Este fichero construye los otros, los que preguntan de las jugadas que ibas a considerar aquí, ¿cuál era la mejor y cuál la peor?

Tiene dos pliegues de fontanería, la configuración y el manifiesto, que el «Ejecútalo» necesita. Si prefieres no teclearlos, trae el fichero entero ahora, antes de empezarlo, con git checkout p5 -- src/rukh/data/onpolicy.py, y lee la sección contra él.

src/rukh/data/onpolicy.py
"""Preference pairs built from the moves the **model** would actually play.
The pairs the project already has are off-policy: they come from Lichess's multi-PV evaluations,
so they say "in this position ``e4`` is better than ``h4``" regardless of whether the model was
ever going to consider either. That is information, and it is not the information that fixes a
particular model. A model only ever loses games to the moves it plays.
An on-policy pair asks a narrower and more useful question: *of the moves you were going to
consider here, which was best and which was worst?* Sample ``candidates`` moves from the model,
score them with the engine, keep the best and the worst if they are far enough apart.
Three decisions worth defending, because each one is a way the comparison could have been unfair.
**The positions are the same ones the off-policy pairs use.** Same prefixes, same phase balance,
same games. The only thing that changes between the two datasets is where the two moves came
from, which is the whole point of running both.
**The engine is limited by depth, not by time.** P4 measured that a time-limited Stockfish makes
a measurement irreproducible (D-107): the same run twice gave 1498 and 1558. A depth limit gives
the same answer on a busy machine and on an idle one, and a dataset is a thing that should be
rebuildable.
**Sampling is at temperature 1.0.** At the near-deterministic setting every published number is
read at, a model proposes the same move four times and there is no pair to build. The candidates
have to come from the distribution, not from its mode -- which is the same lesson M4 ended on.
"""

src/rukh/data/onpolicy.pylíneas 1-26 · p5

Las tres decisiones del docstring son las tres formas en que la comparación se podía volver injusta sin darse cuenta. Léelas como reglas de experimento:

  • Las posiciones son las mismas que las de los pares de Lichess. Lo único que cambia entre los dos conjuntos es de dónde salieron las dos jugadas.
  • El motor va limitado por profundidad, no por tiempo: un Stockfish con reloj da respuestas distintas en una máquina ocupada y en una libre, y un dataset tiene que poder reconstruirse.
  • La temperatura es 1,0, no la 0,3 de los números publicados. Casi en determinista, el modelo propone su moda cuatro veces de cada cuatro y no hay par. En un LLM pasa igual: los pares propios se generan con temperatura aunque luego se sirva casi en greedy.

Las importaciones y la configuración se pueden saltar en la primera lectura; al teclear, despliégalas (sin ellas el fichero no importa), salvo que lo trajeras entero al empezarlo.

// FontaneríaImportaciones y OnPolicyConfig: cuántas candidatas y a partir de qué diferencia hay par
src/rukh/data/onpolicy.py
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import chess
import chess.engine
import polars as pl
import torch
from pydantic import Field
from rukh.config import BaseConfig
from rukh.data.manifest import FileHash, Manifest
from rukh.data.scoring import MATE_SCORE
from rukh.data.uci import sha256_file
from rukh.paths import resolve
if TYPE_CHECKING: # pragma: no cover - typing only
from rukh.models import MoveDecoder
from rukh.tokenize.uci_vocab import UciTokenizer
log = logging.getLogger(__name__)
__all__ = ["OnPolicyConfig", "build_on_policy", "score_move", "sample_candidates"]

src/rukh/data/onpolicy.pylíneas 28-51 · p5

src/rukh/data/onpolicy.py
class OnPolicyConfig(BaseConfig):
"""Where the positions come from, which model proposes, and how deep the engine looks."""
source: str = "data/pairs/dpo-prompts.parquet"
"""The off-policy pairs. Only their **positions** are reused; their moves are not."""
checkpoint: str = ""
out: str = "data/pairs-onpolicy/pairs.parquet"
candidates: int = Field(default=4, ge=2)
"""Moves sampled per position. `docs/spec/02` says four; more is more engine time per pair."""
temperature: float = Field(default=1.0, gt=0)
top_k: int | None = 20
depth: int = Field(default=10, ge=1)
"""Engine depth. A depth limit is reproducible; a time limit is not (D-107)."""
min_delta_cp: int = Field(default=100, ge=1)
max_positions: int | None = None
seed: int = 42
device: str | None = None

src/rukh/data/onpolicy.pylíneas 54-70 · p5

candidates: 4 fija el precio del conjunto, porque cada candidata cuesta un análisis de motor. min_delta_cp: 100 es el umbral por debajo del cual la diferencia entre dos jugadas es ruido con signo.

src/rukh/data/onpolicy.py
def score_move(
engine: chess.engine.SimpleEngine, board: chess.Board, move: chess.Move, depth: int
) -> int:
"""Centipawns **for the side that plays ``move``**, after playing it.
The engine is asked about the position the move leads to, where the *opponent* is to move, so
the score comes back from the opponent's point of view and is negated. Mates use the project's
own ``MATE_SCORE`` convention so these numbers can sit in the same column as the off-policy
ones without a footnote.
"""
board.push(move)
try:
info = engine.analyse(board, chess.engine.Limit(depth=depth))
score = info["score"].pov(not board.turn)
return int(score.score(mate_score=MATE_SCORE))
finally:
board.pop()

src/rukh/data/onpolicy.pylíneas 73-89 · p5

Aquí cabe el peor error del fichero. Al motor se le pregunta por la posición a la que lleva la jugada, donde mueve el rival, así que la puntuación vuelve desde su punto de vista; pov(not board.turn) le da la vuelta. Equivocarse ahí construye un conjunto que prefiere siempre la peor candidata, y DPO lo aprende sin quejarse. El try/finally con el pop protege el tablero, que se reutiliza para todas las candidatas: un push que se quede puesto envenena todo lo que venga después. Hay un test para cada cosa.

src/rukh/data/onpolicy.py
def sample_candidates(
model: MoveDecoder,
tok: UciTokenizer,
board: chess.Board,
history: list[int],
count: int,
temperature: float,
top_k: int | None,
) -> list[chess.Move]:
"""Distinct legal moves the model proposes here, sampled from its own distribution.
Illegal proposals are dropped rather than rescued: an on-policy pair is about ranking the
moves the model would *play*, and the demo masks illegal moves before they reach the board.
How often it proposes one is a different measurement, and the suite already makes it.
"""
from rukh.infer.sampler import SampleConfig, model_generator, pick_move
cfg = SampleConfig(temperature=temperature, top_k=top_k, mask_illegal=False)
generator = model_generator(model, cfg)
seen: dict[str, chess.Move] = {}
for _ in range(count * 4): # a few extra draws, since duplicates are the common case
move, _ = pick_move(model, tok, board, history, cfg, generator)
if move is not None and move in board.legal_moves:
seen.setdefault(move.uci(), move)
if len(seen) >= count:
break
return list(seen.values())

src/rukh/data/onpolicy.pylíneas 92-118 · p5

El sorteo, con dos decisiones dentro. Las propuestas ilegales se descartan (mask_illegal=False y después se tiran): un par dentro de política ordena jugadas que el modelo jugaría, y cuántas ilegales propone ya lo mide la suite. Y se sortea cuatro veces de más, range(count * 4), porque los duplicados son lo común incluso a temperatura 1,0.

src/rukh/data/onpolicy.py
def _history_for(tok: UciTokenizer, prefix: str, white_elo: int, black_elo: int) -> list[int]:
"""The prompt the model sees: the same three header tokens the ladder uses, then the game."""
from rukh.infer.game import _history
ids = _history(tok, white_elo, black_elo)
ids.extend(tok.vocab.get(uci, tok.unk_id) for uci in prefix.split())
return ids

src/rukh/data/onpolicy.pylíneas 121-127 · p5

El prompt se construye con la misma función que usan la escalera y la demo, importada de infer.game. Escribir aquí los tokens de cabecera a mano sería la forma más fácil de que dos mediciones del mismo modelo dejaran de ser comparables sin que nadie lo viera. En un asistente es el peligro de la plantilla de chat: generar los pares con una y servir con otra.

src/rukh/data/onpolicy.py
def build_on_policy(cfg: OnPolicyConfig) -> Manifest:
"""Sample, score and write the on-policy pairs, with a manifest that says how."""
from rukh.engine import find_stockfish
from rukh.eval.suite import resolve_model
from rukh.tokenize.uci_vocab import UciTokenizer
from rukh.train import load_model, pick_device
binary = find_stockfish()
if binary is None:
raise FileNotFoundError("Stockfish not found; set RUKH_STOCKFISH")
torch.manual_seed(cfg.seed)
where = cfg.device or pick_device()
tok = UciTokenizer()
model, _payload = load_model(resolve_model(cfg.checkpoint), map_location=where)
model = model.to(where).eval()
frame = pl.read_parquet(resolve(cfg.source))
if cfg.max_positions is not None:
# Shuffled first, because the source is phase-balanced *in blocks*: taking the head would
# buy a dataset made entirely of openings and then compare it to one that is not.
frame = frame.sample(n=min(cfg.max_positions, frame.height), shuffle=True, seed=cfg.seed)

src/rukh/data/onpolicy.pylíneas 130-150 · p5

El recorte de max_positions lleva su comentario porque arregla un fallo que habría cambiado la comparación central del hito.

src/rukh/data/onpolicy.py
rows: list[dict[str, object]] = []
skipped_same = skipped_close = 0
with chess.engine.SimpleEngine.popen_uci(str(binary)) as engine:
for index, row in enumerate(frame.iter_rows(named=True)):
board = chess.Board()
try:
for uci in (row["prefix"] or "").split():
board.push_uci(uci)
except ValueError:
continue
if board.is_game_over():
continue
history = _history_for(
tok, row["prefix"] or "", int(row["white_elo"]), int(row["black_elo"])
)
moves = sample_candidates(
model, tok, board, history, cfg.candidates, cfg.temperature, cfg.top_k
)
if len(moves) < 2:
skipped_same += 1
continue
scored = [(score_move(engine, board, move, cfg.depth), move) for move in moves]
scored.sort(key=lambda pair: pair[0], reverse=True)
(best_cp, best), (worst_cp, worst) = scored[0], scored[-1]
if best_cp - worst_cp < cfg.min_delta_cp:
skipped_close += 1
continue
rows.append(
{
"game_id": str(row["game_id"]),
"ply": int(row["ply"]),
"prefix": row["prefix"],
"chosen": best.uci(),
"rejected": worst.uci(),
"cp_chosen": int(best_cp),
"cp_rejected": int(worst_cp),
"phase": row["phase"],
"white_elo": int(row["white_elo"]),
"black_elo": int(row["black_elo"]),
"candidates": len(moves),
}
)
if index and index % 250 == 0:
log.info(
"%d positions, %d pairs (%d all-same, %d too close)",
index,
len(rows),
skipped_same,
skipped_close,
)

src/rukh/data/onpolicy.pylíneas 152-201 · p5

Los dos continue con contador son dos motivos distintos para no producir par: menos de dos jugadas distintas es falta de diversidad; una diferencia por debajo del margen es falta de desacuerdo. Lo segundo es el modelo diciendo que todo lo que consideraba era parecido de bueno, y fabricar una preferencia con eso sería enseñarle ruido.

Sobre las 13 838 posiciones salió así: 46,1 % dio par, 27,1 % se quedó sin diversidad y 26,8 % sin desacuerdo. Ese 27,1 % es lo que GRPO llama grupos planos: la confianza del propio modelo se lleva un cuarto del presupuesto de motor, y no hay forma de saber qué cuarto antes de pagarlo.

El cierre escribe el manifiesto que publica la lección 6; es fontanería y se puede saltar al leer. Al teclear no: es el final de la función que construye los pares y lo que imprime el «Ejecútalo». Despliégalo al escribirlo, salvo que trajeras el fichero entero al empezarlo.

// FontaneríaEl manifiesto del dataset: configuración, descartes y hash del fichero
src/rukh/data/onpolicy.py
if not rows:
raise ValueError("no on-policy pair survived; loosen min_delta_cp or raise the temperature")
out = resolve(cfg.out)
out.parent.mkdir(parents=True, exist_ok=True)
table = pl.DataFrame(rows)
table.write_parquet(out)
log.info("wrote %d on-policy pairs to %s", table.height, out)
manifest = Manifest(
dataset="rukh-pairs-onpolicy",
months=[],
filters={
"source": cfg.source,
"checkpoint": cfg.checkpoint,
"candidates": cfg.candidates,
"temperature": cfg.temperature,
"top_k": cfg.top_k,
"engine_depth": cfg.depth,
"min_delta_cp": cfg.min_delta_cp,
"seed": cfg.seed,
"skipped_all_same": skipped_same,
"skipped_too_close": skipped_close,
"note": (
"the moves come from the model, the positions from the off-policy pairs; "
"the engine is limited by depth so the dataset is rebuildable (D-107)"
),
},
counts={"pairs": table.height, "positions": frame.height},
files=[FileHash(path=out.name, sha256=sha256_file(out), bytes=out.stat().st_size)],
)
text = manifest.model_dump_json(indent=2) + "\n"
(out.parent / "manifest.json").write_text(text, "utf-8")
return manifest

src/rukh/data/onpolicy.pylíneas 203-235 · p5

El manifiesto convierte una carpeta con un parquet en un dataset: la configuración entera, los contadores de descartes, el hash del fichero y una nota sobre la profundidad del motor. La lección 6 lo publica tal cual.

Las tres configuraciones, y la que solo cambia los pares

configs/data/onpolicy.yaml es la que construye el conjunto. Si saltas su pliegue, tráela, porque los «Ejecútalo» de abajo la leen: git checkout p5 -- configs/data/onpolicy.yaml.

// Fontaneríaconfigs/data/onpolicy.yaml: la configuración que construye el conjunto dentro de política
configs/data/onpolicy.yaml
# Preference pairs sampled from the model itself, for the second half of M5's comparison.
#
# The positions are exactly the ones the off-policy pairs use, so the only thing that differs
# between the two datasets is where the two moves came from. That is what makes the DPO runs
# on them comparable at all.
source: data/pairs/dpo-prompts.parquet
checkpoint: checkpoints/medium-v4-20260919-174623/best.pt
out: data/pairs-onpolicy/pairs.parquet
# Four candidates is what `docs/spec/02` asks for. Each one costs an engine analysis, so this
# is the knob that sets the price of the dataset.
candidates: 4
# Temperature 1.0, not the 0.3 every published number is read at: at the near-deterministic
# setting the model proposes the same move four times and there is no pair to build.
temperature: 1.0
top_k: 20
# Depth, not time. P4 measured that a time-limited Stockfish makes a run irreproducible
# (D-107): the same ladder twice gave 1498 and 1558 Elo.
depth: 10
# Below a pawn apart the two moves are not a preference, they are noise with a sign.
min_delta_cp: 100
seed: 42

configs/data/onpolicy.yamllíneas 1-25 · p5

Y las dos de DPO son el experimento. Esta es la del brazo bajo prueba, entera:

Puedes saltarte el YAML en la primera lectura; lo que importa es que los dos brazos solo difieren en los pares (el otro, dpo-offpolicy.yaml, lo trajiste con la caja del principio). Si también lo saltas al teclear, tráelo: git checkout p5 -- configs/train/dpo-onpolicy.yaml.

// Fontaneríaconfigs/train/dpo-onpolicy.yaml: el brazo bajo prueba, entero
configs/train/dpo-onpolicy.yaml
# DPO on the moves the model itself proposed: on-policy, and the arm under test.
# Everything below is identical to `dpo-offpolicy.yaml` on purpose -- if the two runs differ, it
# has to be the pairs and nothing else. The pair counts are matched at run time by the lab script.
pairs: data/pairs-onpolicy/pairs.parquet
checkpoint: checkpoints/medium-v4-20260919-174623/best.pt
out_dir: checkpoints
run_name: medium-v4-dpo-onpolicy
# beta is the distance the policy is allowed to travel from its frozen reference. 0.1 is the
# published default and the one P5 measured at +40 Elo head-to-head against the base model.
beta: 0.1
lr: 5.0e-6
# A little next-token loss on the chosen move. Pure DPO can raise the margin while the model
# forgets how to play at all: the margin says "chosen beats rejected", not "chosen is good".
nll_weight: 0.1
batch_size: 32
epochs: 1
val_fraction: 0.1
seed: 42

configs/train/dpo-onpolicy.yamllíneas 1-21 · p5

De la otra basta con lo que cambia; aparte del comentario y del run_name, el resto es idéntico.

configs/train/dpo-offpolicy.yaml
# DPO on the pairs Lichess's evaluations produced: off-policy, and the control arm of M5's
# comparison. The only line that differs from `dpo-onpolicy.yaml` is `pairs`.
pairs: data/pairs-offpolicy-matched/pairs.parquet

configs/train/dpo-offpolicy.yamllíneas 1-3 · p5

data/pairs-offpolicy-matched/ es el fichero de 13 838 pares submuestreado a 6 386 con semilla fija, para que los dos brazos vean la misma cantidad de datos.

// Ejercicio 01¿Qué medirías si no los igualaras?

El conjunto fuera de política tiene 13 838 pares y el de dentro 6 386. Si entrenas los dos tal cual y el de fuera sale mejor, ¿qué has medido?

// SoluciónVer la solución

La cantidad de datos, no la fuente. Con 2,2 veces más pares, el brazo de fuera tiene 2,2 veces más pasos de optimizador sobre el mismo modelo de partida, y eso solo basta para explicar una diferencia. Por eso dpo-offpolicy.yaml apunta al conjunto submuestreado.

Hay una segunda diferencia que no se puede igualar y que por eso hay que declarar: los pares de fuera tienen un 29 % de mates y los de dentro un 2,9 %. Es lo que significa «dentro de política» —una evaluación multi-PV encuentra mates que el modelo nunca iba a proponer—, y lo honesto es escribirlo al lado del resultado.

Los dos subcomandos

rukh data onpolicy construye el conjunto. Sus dos banderas son lo que se quiere tocar sin editar un YAML: qué modelo propone y cuántas posiciones recorrer.

Son fontanería de CLI y se pueden saltar al leer; al teclear no, porque los «Ejecútalo» de debajo los usan. Si los saltas, tráelos con git checkout p5 -- src/rukh/cli.py (si ya lo trajiste en la lección 2, ya los tienes): deja el cli.py de la etiqueta, con los subcomandos de todo el módulo.

// Fontaneríarukh data onpolicy y rukh train dpo: los dos subcomandos
src/rukh/cli.py
@data_app.command("onpolicy")
def data_onpolicy(
config: Annotated[
Path,
typer.Option("--config", exists=True, dir_okay=False, help="On-policy pair run YAML."),
],
checkpoint: Annotated[
str | None, typer.Option("--checkpoint", help="Override the proposing model.")
] = None,
max_positions: Annotated[
int | None, typer.Option("--max-positions", help="Stop after this many positions.")
] = None,
as_json: Annotated[
bool, typer.Option("--json", help="Print the manifest as JSON only.")
] = False,
) -> None:
"""Build preference pairs out of the moves the model itself proposes.
The pairs the project already has are off-policy: Lichess evaluated two moves and one was
better, whether or not this model was ever going to play either. On-policy pairs ask the
narrower question that actually changes a model -- *of the moves you were going to consider
here, which is best and which is worst?* -- and are the second half of M5's comparison.
"""
from rukh.config import load_yaml
from rukh.data.onpolicy import OnPolicyConfig, build_on_policy
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
cfg = load_yaml(config, OnPolicyConfig)
if checkpoint:
cfg = cfg.model_copy(update={"checkpoint": checkpoint})
if max_positions is not None:
cfg = cfg.model_copy(update={"max_positions": max_positions})
manifest = build_on_policy(cfg)
_echo_manifest(manifest, as_json, Path(cfg.out).parent)

src/rukh/cli.pylíneas 280-313 · p5

model_copy(update=...) mantiene la validación de pydantic al aplicar una bandera: un --max-positions 0 se rechaza aquí y no doscientas líneas más tarde.

src/rukh/cli.py
@train_app.command("dpo")
def train_dpo_cmd(
config: Annotated[
Path, typer.Option("--config", exists=True, dir_okay=False, help="DPO run YAML.")
],
pairs: Annotated[
str | None, typer.Option("--pairs", help="Override the preference pairs to train on.")
] = 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,
) -> None:
"""Align the decoder on preference pairs, with the frozen start as its own reference.
The reference is implicit: a copy of the starting weights, never trained. That is what lets
DPO skip the reward model entirely, and it is also what the ``beta`` knob is measured against
-- push too hard and the policy leaves a reference that cannot follow it.
"""
from rukh.config import load_yaml
from rukh.train.dpo import DpoConfig, train
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
cfg = load_yaml(config, DpoConfig)
updates = {k: v for k, v in (("pairs", pairs), ("run_name", run_name), ("device", device)) if v}
if updates:
cfg = cfg.model_copy(update=updates)
typer.echo(f"wrote {train(cfg)}")

src/rukh/cli.pylíneas 819-844 · p5

La de DPO sigue el mismo patrón. El filtro if v del diccionario hace que una bandera que no se pasó no pise con None lo que decía el fichero.

Los tests que sostienen la comparación

De tests/unit/test_onpolicy.py van aquí los tres tests de la convención de puntuación, donde vive el fallo que no avisa. El resto del fichero comprueba el descarte de ilegales y duplicados, que candidates: 1 y temperature: 0 se rechazan, y que el prompt es el de la escalera.

Los trajiste con la caja del principio, así que aquí se leen, no se teclean; no hacen falta para seguir la lección.

// Teststests/unit/test_onpolicy.py: la convención de signo, el tablero intacto y la puntuación del mate
tests/unit/test_onpolicy.py
def test_the_score_comes_back_for_the_side_that_played_the_move():
"""+120 for White is +120 when White moved and -120 when Black did."""
engine = _FakeEngine(120)
board = chess.Board()
assert score_move(engine, board, chess.Move.from_uci("e2e4"), depth=4) == 120
board.push_uci("e2e4")
assert score_move(engine, board, chess.Move.from_uci("e7e5"), depth=4) == -120
def test_scoring_a_move_leaves_the_board_exactly_as_it_found_it():
"""The loop reuses one board for every candidate; a stray push poisons the rest."""
engine = _FakeEngine(0)
board = chess.Board()
board.push_uci("d2d4")
before = board.fen()
score_move(engine, board, chess.Move.from_uci("d7d5"), depth=4)
assert board.fen() == before
@pytest.mark.parametrize(("moves", "expected"), [(1, MATE_SCORE - 1), (5, MATE_SCORE - 5)])
def test_a_mate_is_scored_with_the_project_convention(moves: int, expected: int):
"""``MATE_SCORE`` minus the distance, the same convention the off-policy column uses.
The subtraction is not decoration: it is what makes a mate in one preferable to a mate in
five, which is a preference a pair should be able to express.
"""
class _Mate(_FakeEngine):
def analyse(self, board: chess.Board, limit: object) -> dict[str, object]:
return {"score": chess.engine.PovScore(chess.engine.Mate(moves), chess.WHITE)}
assert score_move(_Mate(0), chess.Board(), chess.Move.from_uci("e2e4"), depth=4) == expected

tests/unit/test_onpolicy.pylíneas 39-71 · p5

El primero es el giro de signo: +120 para las blancas es +120 cuando movieron ellas y −120 cuando movió el negro. El segundo comprueba que el tablero vuelve como estaba. El tercero fija que un mate se puntúa como MATE_SCORE menos la distancia, para que un mate en uno sea preferible a uno en cinco.

Los tres tardan milisegundos porque el motor es un objeto falso. Un test que necesita a Stockfish para comprobar un signo no se ejecuta en cada guardado, y uno que no se ejecuta no protege nada.

Qué has aprendido

DPO se salta el reward model porque su referencia es implícita: una copia congelada de los pesos de partida, que hay que congelar dos veces —gradientes y modo— o β mide ruido. Aquí la pérdida se reduce a dos log-probabilidades porque una jugada es un token (en un LLM serían dos sumas sobre las respuestas), y devuelve margen y acierto por separado porque el primero puede subir mientras el segundo baja. Y construir el segundo brazo de una comparación consiste sobre todo en igualar lo demás —posiciones, motor, número de pares— y declarar lo único que no se pudo igualar.

Lo siguiente es el otro camino: sin pares, sin reward model, y con una recompensa que se puede escribir y comprobar.