rukh · lab

// M5 · lección 01

Alineamiento: el instrumento y la teoría justa

Para medir una diferencia, mide la diferencia: el enfrentamiento directo frente a restar dos escaleras, y su control. Después la teoría justa: qué dice y qué no dice un par de preferencia, Bradley-Terry, DPO y su referencia implícita, pares dentro y fuera de política, GRPO y por qué la KL no es un adorno.

  • 150 min
  • nivel medio
  • vigente
  • actualizado el21 de septiembre de 2026

Parte 1 de 3 del módulo «Alineamiento». Sigue en «Alineamiento: cómo se mide y lo que salió» y termina en «Alineamiento: los labs».

Qué vas a construir

Al terminar esta sección sabrás qué deja de hacer el modelo, qué empieza a hacer, y por qué el primer trabajo del módulo no es entrenar nada sino cambiar de instrumento.

El alineamientoAlineamientoCambiar lo que un modelo **prefiere**, no lo que sabe. El preentrenamiento y el afinado ajustan la probabilidad de la siguiente jugada para parecerse a un corpus; el alineamiento la ajusta para parecerse a un criterio: cuál de dos jugadas es mejor (DPO), o cuánto vale una jugada según una función (GRPO). La diferencia práctica es de dónde sale la señal: de imitar a alguien, o de una comparación que nadie jugó nunca. empieza donde termina la imitación, y los cuatro módulos anteriores enseñaron al modelo a imitar. El preentrenamiento le puso delante cinco millones de partidas y le pidió la siguiente jugada; el afinado de M4 le puso delante otras partidas y le pidió lo mismo. Toda la señal salía de gente que jugó. M4 terminó midiendo el techo de eso con cinco instrumentos distintos: afinar cambia el comportamiento de forma total y barata, y no cambia la competencia. Un adaptador de 1,6 MB sube 1. e4 del 59,64 % al 99,85 %; ningún afinado mueve el Elo de forma demostrable.

Este módulo cambia de dónde sale la señal. En vez de «esto es lo que jugó un humano de 2000», la señal pasa a ser «de estas dos jugadas, esta es mejor» —que es un par de preferencia— o «esta jugada vale 0,84 y esta otra 0,31» —que es una recompensa—. Ninguna de las dos frases aparece en una partida. Las dos se pueden construir, y el ajedrez es uno de los poquísimos sitios donde la segunda se puede verificar: una jugada es legal o no lo es, un mate es un mate, y un motor pone un número a cualquier posición.

La pregunta del módulo es: ¿se puede mover la competencia cambiando el objetivo? Y la primera respuesta del hito no fue un modelo, fue un instrumento — porque con el que había, la pregunta no se podía contestar.

Vas a construir:

  • rukh eval match, un enfrentamiento directoEnfrentamiento directoMedir una diferencia haciendo que los dos modelos jueguen entre sí, en vez de medir dos Elo absolutos contra una escalera y restarlos. Sale unas ocho veces más barato en partidas y se ahorra el ruido del tercero: en un enfrentamiento los dos juegan *la misma* partida. Es el instrumento con el que M5 mide, y el que separó del cero un +40 Elo que la escalera dejaba solapado. entre dos modelos, con libro de aperturas sembrado y colores espejados. Sale 8,1 veces más barato en partidas que restar dos Elo de la escalera, y se ahorra el ruido de un tercero. Es lo primero del hito y lo que más se reutiliza.
  • src/rukh/models/reward.py y src/rukh/train/reward.py: un reward modelReward modelUna red que lee una respuesta y devuelve un número, entrenada con pares de preferencia para que le dé más al preferido. Es lo que hace falta para PPO y lo que DPO **no** necesita. En Rukh lee la posición resultante con el encoder de casillas y acierta en torno al 75 % de los pares no vistos; falla justo en la banda de mate, que es donde una recompensa verificable acierta siempre. entrenado con la pérdida 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., que acierta cerca de tres de cada cuatro pares no vistos y falla justo donde tiene que fallar.
  • src/rukh/data/onpolicy.py, que construye pares con las jugadas que propone el propio modelo, para poder comparar dos 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. —el entrenamiento que aprende directamente de los pares, sin reward model; se define en la teoría de abajo— cuya única diferencia sea de dónde salen las dos jugadas.
  • src/rukh/train/rewards.py: la recompensa verificableRecompensa verificableUna recompensa que calcula un programa y se puede comprobar, en vez de estimarla un modelo entrenado con opiniones. El ajedrez es uno de los pocos sitios donde se puede escribir entera: una jugada es legal o no, un mate es un mate, y el motor pone un número a la posición. Lo que gana es que no puede equivocarse como se equivoca un modelo aprendido; lo que no gana es inmunidad al **reward hacking**, porque sigue siendo un diseño humano., con su puerta de legalidad, sus topes y un test que comprueba sobre miles de entradas aleatorias que nada se sale del intervalo.
  • src/rukh/train/grpo.py: 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* la línea base, así que no hace falta una red de valor. Su consecuencia menos obvia: un grupo cuyas respuestas puntúan igual no aporta gradiente ninguno. con grupos de ocho, ventaja relativa al propio grupo y una penalización KLPenalización KLEl término que ata la política a la copia congelada de donde salió. Sin él, una recompensa verificable es una invitación a dejar de jugar al ajedrez y ponerse a cultivar el número. Rukh usa el estimador k3, `exp(d) − d − 1`, que es **no negativo por construcción**: la diferencia de log-probabilidades a secas es negativa la mitad de las veces y pagaría al modelo precisamente por alejarse. contra los pesos de partida.
  • Y labs/m5/reward_hacking.py, la galería de reward hackingReward hackingQue el modelo maximice el número en vez de hacer la tarea. En Rukh se midió sin entrenar nada: seis recompensas, cinco de ellas rotas a propósito, sobre el mismo grupo de candidatas. Las seis **coronan la misma jugada** —el daño no está en la cima— y las rotas se ven en la forma del grupo: una sola candidata catastrófica se lleva el 46 % de la ventaja, y el valor de una recompensa sin tope se mueve 47 veces su intervalo legal al cambiar la profundidad del motor.: la misma recompensa rota de cinco maneras, medida. Su primera versión preguntaba qué jugada corona cada una, las seis coronaron la misma, y esa respuesta —que parecía un fracaso— es el hallazgo que ordena la última parte del módulo.

El instrumento: para medir una diferencia, mide la diferencia

Al terminar esta sección sabrás por qué restar dos números medidos es peor que medir la diferencia, cuánto peor exactamente, y por qué el primer día del hito no se entrenó nada.

GOAL.md pide para este módulo +50 Elo con intervalo de confianza sobre el modelo de partida. Con el instrumento que había al empezar, esa frase no se podía comprobar, y conviene ver por qué antes de aceptar ningún número del resto de la lección.

El instrumento que había es la escalera: el modelo juega contra ocho Stockfish de fuerza limitada, se mira dónde saca el 50 % y eso da un Elo. Funcionó bien durante tres módulos. Pero P4 midió algo sobre ella que aquí cambia todo: la misma configuración, con la misma semilla, medida dos veces por dos caminos distintos, dio 1498 y 1558 (D-107). Sesenta puntos de diferencia sin que nada cambiara.

No es un fallo. La semilla fija nuestro muestreo, no el del rival: Stockfish juega con un límite de tiempo y UCI_LimitStrength aleatoriza a propósito para acertar el Elo pedido. Así que «las mismas 160 partidas» son 160 partidas nuevas contra alguien que no se repite. El suelo de reproducibilidad de la escalera es de unos 40 Elo de una sigma.

Y ahora la aritmética que decide el hito. Si mides el Elo de la base y el del modelo alineado por separado y los restas, la diferencia hereda los dos ruidos:

restar dos absolutosbase1504+ DPO15291450150015501600Elo en la escalera de Stockfish (IC 95 %)diferencia: +25, pero ±80 de intervalo — de −55 a +105, atraviesa el ceromedir la diferenciaDPO − base+40-50050100diferencia de Elo en el enfrentamiento directo (IC 95 %)400 partidas, cuatro minutos: de +11 a +69, no toca el cero
Los mismos dos modelos. Arriba, dos medidas de ±56 cuya resta sale de ±80 y atraviesa el cero. Abajo, la diferencia medida de frente.

Los números son los del propio proyecto. La escalera lee medium-v4 en 1504 (1446-1558) y el mismo modelo tras DPO en 1529 (1470-1583). Resta: +25, con un semiancho de √(56² + 56,5²) = 80. O sea, de −55 a +105: atraviesa el cero de lado a lado.

Cada absoluto trae su ruido entero a la resta, y la diferencia acaba más ancha que cualquiera de las dos medidas que entraron. Buscar una diferencia de 50 con un instrumento que devuelve ±80 no es cuestión de más partidas: es el instrumento equivocado.

Los mismos dos checkpoints enfrentados entre sí dan +40 Elo con intervalo 11 a 69, separado del cero, en cuatro minutos. Ese medium-v4-dpo es un DPO anterior al resto del módulo: se entrenó sobre el fichero completo de 13 838 pares fuera de política, antes de que existieran los pares del propio modelo y el conjunto igualado con los que la parte 2 compara los dos brazos. Sirve aquí como prueba del instrumento, no como resultado del hito; los números que se publican son los de la parte 2.

La cuenta, y los dos números que salen

El precio de cada camino se calcula antes de pagarlo, y eso es el primer lab del módulo:

Terminal window
uv run python labs/m5/games_needed_match.py

Para distinguir dos proporciones que difieren en Δp hacen falta, en cada lado,

n > 3,84 / (Δp)²

partidas —es la aproximación normal al 95 % con dos colas, y el 3,84 no es una constante mágica: es 1,96², el cuadrado del número de desviaciones típicas que deja fuera el 5 % de una campana—. Para detectar una ventaja en un enfrentamiento directo, donde lo que se mide es una sola proporción contra 0,5, hacen falta

n > z² · p(1-p) / (p - 0,5)²

partidas en total. Para +50 Elo, la primera pide 8,1 veces más partidas que la segunda. Y la tabla que imprime el lab es la que se usa al revés, que es como se usa de verdad: con 200 partidas, la diferencia más pequeña que se puede ver es de 48 Elo; con 400, de 34; con 50, de 95. Antes de jugar una sola partida ya sabes si tu presupuesto puede contestar tu pregunta.

src/rukh/eval/match.py
def games_for_edge(elo: float, level_z: float = 1.96) -> int:
"""Cuántas partidas hacen falta para distinguir una ventaja de `elo` de nada.
La inversa de la pregunta que hace cualquier enfrentamiento, y la razón de ejecutarlo
**antes** de pagar las partidas y no después de mirarlas.
"""
if elo == 0:
return 0
p = 1.0 / (1.0 + 10.0 ** (-elo / 400.0))
gap = abs(p - 0.5)
return int(math.ceil(level_z**2 * p * (1 - p) / gap**2))

Y el enfrentamiento en sí, cuyas dos guardas son lo que hace que el número signifique algo:

src/rukh/eval/match.py
def run_match(player_a, player_b, openings, games: int = 200, seed: int = 0) -> MatchResult:
"""Juega `games` partidas entre dos jugadores y devuelve la ventaja de A.
`openings` son prefijos de un libro sembrado. Cada uno se juega **dos veces**, una con cada
color, para que ninguna apertura pueda favorecer a un bando y ningún jugador pueda ganar por
haber llevado blancas más veces. Un número impar de partidas se **rechaza** en vez de
redondearse en silencio: el reparto de colores no puede salir torcido.
"""
if games % 2:
raise ValueError("un enfrentamiento juega cada apertura con los dos colores: número par")
if not openings:
raise ValueError("un enfrentamiento necesita un libro de aperturas")
scores: list[float] = []
for index in range(games):
board = chess.Board()
for uci in openings[(index // 2) % len(openings)].split():
board.push(chess.Move.from_uci(uci))
a_is_white = index % 2 == 0
result = play_game_with(
player_a if a_is_white else player_b,
_AsOpponent(player_b if a_is_white else player_a),
model_color=chess.WHITE if a_is_white else chess.BLACK,
board=board,
)
scores.append(_score_for_a(result.result, a_is_white))
score = sum(scores) / len(scores)
low, high = bootstrap_score(scores, seed=seed)
return MatchResult(games=len(scores), score=score, elo=elo_difference(score),
ci_low=elo_difference(low), ci_high=elo_difference(high), ...)

_AsOpponent es un envoltorio de diez líneas y es el arreglo del segundo error del control: Opponent no tiene observe en su protocolo, así que nada le contaba al otro lado qué jugadas se habían hecho, y un jugador con estado en esa posición iba a ciegas toda la partida.

El control que costó un minuto y valió el hito

Un instrumento nuevo se estrena midiendo algo cuya respuesta ya se sabe. Un modelo contra una copia exacta de sí mismo tiene que dar 0,5000 clavado:

Terminal window
uv run rukh eval match --a <base> --b <base> --games 100

Salió 0,975 a favor de uno de los dos lados, con 999 jugadas ilegales.

Los dos errores estaban en infer/game.py y ninguno era sutil de encontrar una vez que había un número imposible señalándolos (D-111):

  1. play_game_with empezaba la partida desde un tablero con las jugadas del libro de aperturas ya puestas, pero nunca se las reproducía al jugador. El modelo veía un prompt vacío y un tablero con seis jugadas hechas, así que proponía jugadas de la posición inicial.
  2. Al rival, si guardaba estado, no se le contaba ninguna jugada. Opponent no tiene observe en su protocolo, así que nadie preguntaba si lo tenía.

El arreglo del segundo cabe en tres líneas y dice exactamente lo que hace:

def _tell(opponent: Opponent, move: chess.Move) -> None:
"""Show a move to an opponent that keeps state; a no-op for one that does not."""
observe = getattr(opponent, "observe", None)
if callable(observe):
observe(move)

Después del arreglo: 0,5000 exacto, 11 ilegales por bando. El control pasa.

Lo que mide, cuando ya mide

rukh eval match juega un número par de partidas —se niega a jugar un número impar, para que el reparto de colores no pueda salir torcido— desde posiciones de un libro de aperturas sembrado, cada una jugada dos veces con los colores cambiados. Devuelve la puntuación, su intervalo por bootstrap, y la conversión a Elo:

def elo_difference(score: float, cap: float = 1200.0) -> float:
if score <= 0.0:
return -cap
if score >= 1.0:
return cap
return max(-cap, min(cap, 400.0 * math.log10(score / (1.0 - score))))

El tope no es cosmético: una puntuación de 1,0 es un logaritmo infinito, y un instrumento que puede devolver infinito acaba metiéndolo en una tabla.

Con este instrumento, medium-v4-dpo contra medium-v4 da +40 Elo con intervalo 11 a 69, en cuatro minutos — que es el número de la figura de arriba, y el mismo par de modelos que la escalera dejaba en +25 con el intervalo atravesando el cero. Misma conclusión cualitativa, y un instrumento que la puede afirmar mientras el otro no.

Teoría justa

Al terminar esta sección sabrás qué es un par de preferencia, qué promete y qué no promete Bradley-Terry, por qué DPO no necesita reward model, y qué hace GRPO que PPO hace más caro.

Un par de preferencia no dice cuánto vale nada

Un par de preferenciaPar de preferenciaDos respuestas a la misma entrada, una marcada como mejor (`chosen`) y otra como peor (`rejected`). En Rukh son dos jugadas legales en la misma posición, con la diferencia en centipeones que el motor les puso. Un par no dice cuánto vale una jugada: dice cuál de las dos gana, y eso basta para Bradley-Terry. son dos respuestas a la misma entrada, una marcada mejor y otra peor. En Rukh son dos jugadas legales en la misma posición, con la distancia en centipeones que el motor les puso. Lo que el par no dice es cuánto vale ninguna de las dos: solo dice cuál gana. Es una cata a ciegas: «este vino es mejor que este otro» se contesta sin saber si los dos son buenos, si los dos son malos, ni cuántos puntos tiene ninguno.

Eso basta, y el modelo que lo convierte en números es 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.: la probabilidad de que a gane a b es σ(r(a) − r(b)), así que la pérdida es

L = −log σ(r(chosen) − r(rejected))

La consecuencia que más se olvida está a la vista en la fórmula: solo aparecen diferencias. Sumarle cien a todas las puntuaciones no cambia la pérdida ni un decimal. La escala de un reward model no significa nada por sí sola, y comparar el «0,8» de un entrenamiento con el «1,4» de otro es comparar dos reglas sin origen: es como leer temperaturas en Celsius y en Kelvin, donde «hoy hace 20 grados más que ayer» significa lo mismo en las dos escalas y «hace 300» no significa nada hasta que sabes dónde está el cero. Hay un test que lo fija, porque un modelo que se rompa con un desplazamiento constante no lo notaría nadie aguas abajo:

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

Y el modelo en sí son quince líneas, porque es el encoder de M3 con una cabeza escalar encima:

src/rukh/models/reward.py
class RewardModel(nn.Module):
"""`PositionEncoder` más una cabeza escalar: cómo de buena es esta posición para quien movió.
La entrada es la posición **después** de la jugada. Se podría construir una recompensa sobre
pares `(posición, jugada)` dándole las dos, pero el encoder ya toma un FEN y la posición
después de una jugada *es* la consecuencia de esa jugada, que es de lo que va una recompensa.
Además así puede puntuar una posición a la que se llegó por cualquier camino, que es lo que
GRPO necesita cuando ordena ocho candidatas entre sí.
La cabeza es **sin acotar**. Un `tanh` dejaría la escala ordenada y además pondría el mismo
techo al margen entre una jugada buena y una catastrófica, que es justo la distinción que el
modelo está aprendiendo a hacer. A la escala ya la contiene la propia pérdida: llevar el margen
al infinito cuesta cada vez más para cada vez menos.
"""
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, attention_mask=None):
return self.head(self.encoder.pool(idx, self.pooling, attention_mask)).squeeze(-1)
def preference_loss(chosen, rejected):
"""Bradley-Terry: `-log σ(r_chosen - r_rejected)`, promediado sobre el lote."""
return -F.logsigmoid(chosen - rejected).mean()
def preference_accuracy(chosen, rejected) -> float:
"""Fracción de pares ordenados bien. Un empate cuenta como **fallo**, no como medio acierto.
`GOAL.md` lee su listón en este número, y un modelo que puntúa dos jugadas exactamente igual no
ha expresado ninguna preferencia; redondear eso a medio punto sería halagarlo.
"""
with torch.no_grad():
return float((chosen > rejected).float().mean())

DPO: la referencia que no hay que entrenar

El camino clásico es entrenar un reward model, y luego usarlo para hacer refuerzo sobre la política con PPO. Son dos entrenamientos y una red de valor. PPO es el algoritmo de refuerzo que se usaba para esto antes de DPO, y la red de valor es una segunda red que entrena por el camino para estimar cuánto vale cada estado, y así saber si una recompensa fue buena para lo que cabía esperar. Piensa en vender un piso: antes de aceptar una oferta contratas a un tasador que te dice qué vale, y con eso juzgas si la oferta es buena. La red de valor es el tasador, y hay que formarlo. 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. se salta al tasador: demuestra que, para el caso de las preferencias, se puede escribir el óptimo de ese refuerzo en función de la política y de una referencia, y entrenar la política directamente sobre los pares.

La referencia es implícita: una copia congelada de los pesos de partida. No se entrena, no se publica, y β mide cuánto se le deja a la política alejarse de ella. Es la foto del piso el día que entras a vivir de alquiler: no la enseña nadie, pero es contra lo que se compara todo al salir, y β es la fianza — cuánto te cuesta cada cosa que has cambiado respecto a la foto.

En Rukh la completación es una sola jugada, así que toda la maquinaria de secuencia se colapsa a dos log-probabilidades en una posición, y el fichero es corto:

def dpo_loss(policy, reference, batch, beta):
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

Dos cosas de ahí merecen quedarse. La primera: se devuelve el margen y el acierto por separado, porque un margen puede crecer con la ordenación todavía mal, y el que hay que mirar es el segundo. La segunda: nll_weight, que es opcional y suele hacer falta. DPO puro sube el margen sin mirar si la política sigue sabiendo jugar —el margen dice «chosen gana a rejected», no dice «chosen es buena»—, y un poco de pérdida de siguiente token sobre la jugada preferida la ancla.

Antes de la pérdida hay dos funciones cortas, y una de ellas esconde el error silencioso de este módulo:

src/rukh/train/dpo.py
def batches(rows, size: int, pad_id: int, shuffle: bool, seed: int):
"""Rellena cada lote hasta su propio prompt más largo y recuerda dónde acaba cada prompt.
Los prompts tienen longitudes distintas. Leer los logits en `-1` en vez de en `last` leería el
**relleno** de todas las filas más cortas que la más larga: un fallo sin síntoma, porque la
pérdida sigue bajando, sobre la posición equivocada.
"""
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]]
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, ...)
def move_logprobs(model, batch):
"""Log-probabilidades sobre el vocabulario en la posición que predice la jugada siguiente."""
logits, _ = model(batch.tokens)
picked = logits[torch.arange(logits.shape[0], device=logits.device), batch.last]
return F.log_softmax(picked.float(), dim=-1)

Y una decisión de encode_pairs que conviene ver escrita: una fila cuyo prompt no cabe en el bloque se descarta y se cuenta, nunca se recorta. Recortar el principio de un prefijo le daría al modelo una posición que en esa partida no ocurrió.

On-policy y off-policy: de dónde salen las dos jugadas

Los 13 838 pares que el proyecto trae de P1 son fuera de política: salen de las evaluaciones multi-PV de Lichess. Dicen «en esta posición e4 es mejor que h4» tanto si el modelo iba a considerar alguna de las dos como si no.

Un par dentro de políticaOn-policy / off-policyDe dónde salen las respuestas que se comparan. **Off-policy**: de otro sitio —en Rukh, de las evaluaciones de Lichess—, y dicen qué jugada es mejor tanto si el modelo iba a considerarla como si no. **On-policy**: del propio modelo, muestreando varias jugadas en la posición y puntuándolas. La distinción importa porque un modelo solo pierde partidas con las jugadas que juega. hace una pregunta más estrecha y, en principio, más útil: de las jugadas que ibas a considerar aquí, ¿cuál era la mejor y cuál la peor? Se construye muestreando cuatro jugadas del propio modelo, puntuándolas con el motor y quedándose con los extremos si están lo bastante lejos.

El argumento a favor es de una línea: un modelo solo pierde partidas con las jugadas que juega. Es la diferencia entre estudiar con el examen corregido de otro y con el tuyo: el de otro enseña errores que tú quizá nunca ibas a cometer; el tuyo enseña exactamente los que cometes, y solo esos. El argumento en contra también: los pares on-policy son más caros, y su calidad depende de que el modelo proponga cosas distintas. Este módulo no elige entre los dos con un argumento, los entrena y los mide.

GRPO: el grupo es la línea base

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* la línea base, así que no hace falta una red de valor. Su consecuencia menos obvia: un grupo cuyas respuestas puntúan igual no aporta gradiente ninguno. no necesita ni los pares ni el reward model. Para cada posición, la política propone group_size jugadas; una función las puntúa; y cada una se empuja arriba o abajo según cómo le fue frente a la media de su propio grupo:

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

Esa media es la línea baseLínea base de un grupoEl número contra el que se compara una recompensa para saber si fue buena o mala. Sin línea base, «0,8» no significa nada. PPO entrena una red de valor para estimarla; GRPO usa la media del propio grupo, que no cuesta nada y no hay que entrenar. El precio es que si el grupo no tiene dispersión, la línea base se come toda la señal.. PPO entrena una red de valor entera para estimarla; GRPO la saca del grupo, que ya estaba pagado. Es la nota sobre la media de la clase: un 7 no dice nada hasta que sabes que la media fue un 5 —entonces es un buen examen— o un 9 —entonces no lo es—. GRPO no pregunta «¿cuánto vale esta jugada?» sino «¿cuánto mejor que sus siete compañeras de grupo?». El precio está en la misma fórmula y es el detalle que casi nunca se menciona:

un grupo con dispersión0.66recompensaocho ventajas, cuatro arriba y cuatro abajoun grupo que se repite0.74recompensaocho ceros: el motor ya está pagadola línea a trazos es la media del propio grupo; las flechas desde ella hasta cada barrason las ventajas: eso, y no la altura, es lo que se empuja
GRPO no optimiza la recompensa: optimiza la recompensa menos la media de su propio grupo. Es la nota sobre la media de la clase: un 7 no dice nada hasta que sabes que la media fue un 5, y si toda la clase saca un 7 nadie destaca. Un grupo que se repite entero tiene esa media encima de todo y no aporta un solo gradiente.

Si el grupo no tiene dispersión —un grupo planoGrupo planoUn grupo de GRPO cuyas candidatas puntúan todas igual. La línea base cae encima de todas, las ventajas son cero y el paso no aporta gradiente, con el motor ya pagado. En Rukh son el 38 % de los grupos a temperatura 1,0. Subir la temperatura los reduce —26 % a 1,3, 19 % a 1,6— pero cuesta más análisis de motor por grupo, y medido sale a la par: unos 0,18 grupos útiles por análisis en las tres.— la línea base cae encima de todos y todas las ventajas son cero. Si toda la clase saca un 7, nadie está por encima de la media y no hay a quién subir. El motor ya se ha pagado y el paso es un no-op. Por eso GrpoResult cuenta los grupos planos aparte: el número dice qué parte del presupuesto se lleva la propia confianza del modelo, y no hay manera de saberlo por adelantado.

src/rukh/train/grpo.py
def group_advantages(rewards, eps: float = 1e-4):
"""`(r - media) / (desviación + eps)`: cómo le fue a cada candidato frente a su propio grupo.
Normalizar por la dispersión es lo que hace que un grupo de jugadas casi iguales y uno con una
barbaridad dentro aporten de forma comparable. Cuando la dispersión es cero las ventajas son
cero, que es la respuesta honesta —el grupo no dijo nada— y quien llama descarta el grupo en
vez de dejar que `eps` fabrique un gradiente con polvo de coma flotante.
"""
values = torch.tensor(list(rewards), dtype=torch.float)
spread = float(values.std(unbiased=False))
if spread < eps:
return torch.zeros_like(values)
return (values - values.mean()) / (spread + eps)
def sample_group(logits, legal_ids, size, temperature, top_k, generator=None):
"""`size` tokens de jugada sacados de la política en una posición.
El sorteo es **con reemplazo**. El grupo pretende ser una muestra de lo que la política haría
de verdad, y una política que jugaría la misma jugada ocho de ocho veces le está diciendo algo
cierto sobre sí misma a la pérdida. Quitar duplicados aquí convertiría la línea base en la
media de una distribución que nadie sigue.
"""
scaled = logits.float() / temperature
if legal_ids is None:
mask, width = scaled, scaled.numel()
else:
mask = torch.full_like(scaled, float("-inf"))
index = torch.tensor(list(legal_ids), dtype=torch.long, device=scaled.device)
mask[index] = scaled[index]
width = index.numel()
if top_k is not None and top_k < width:
cut = torch.topk(mask, top_k).values[-1]
mask = torch.where(mask < cut, torch.full_like(mask, float("-inf")), mask)
return torch.multinomial(F.softmax(mask, dim=-1), size, replacement=True, generator=generator)

La KL es un término, no un adorno

Una recompensa verificable es exactamente especificable, y por tanto exactamente hackeable. Lo único que impide que la política se vaya a la esquina del espacio de jugadas que mejor puntúa es la penalización KLPenalización KLEl término que ata la política a la copia congelada de donde salió. Sin él, una recompensa verificable es una invitación a dejar de jugar al ajedrez y ponerse a cultivar el número. Rukh usa el estimador k3, `exp(d) − d − 1`, que es **no negativo por construcción**: la diferencia de log-probabilidades a secas es negativa la mitad de las veces y pagaría al modelo precisamente por alejarse. contra los pesos de partida. Rukh usa el estimador k3:

difference = reference_logprob - policy_logprob
kl = (torch.exp(difference) - difference - 1.0).mean()

exp(d) − d − 1 es no negativo por construcción, y eso importa para algo que quiere ser un castigo. La diferencia de log-probabilidades a secas es negativa la mitad de las veces: como término de penalización, le pagaría al modelo precisamente en las muestras en las que se alejó. Sería un radar de velocidad que la mitad de las veces te multa y la otra mitad te ingresa dinero por pasarte: en promedio no disuade de nada. Hay un test que lo dispara en las dos direcciones a propósito.

Y la pérdida completa, que junta el objetivo y ese castigo:

src/rukh/train/grpo.py
def grpo_loss(policy_logprobs, reference_logprobs, actions, advantages, beta_kl):
"""El objetivo y la KL, para una completación de un solo token."""
chosen_policy = policy_logprobs.gather(-1, actions.unsqueeze(-1)).squeeze(-1)
chosen_reference = reference_logprobs.gather(-1, actions.unsqueeze(-1)).squeeze(-1)
objective = -(advantages * chosen_policy).mean()
difference = chosen_reference - chosen_policy
kl = (torch.exp(difference) - difference - 1.0).mean()
return objective + beta_kl * kl, kl

La recompensa verificable, escrita

GRPO no necesita un reward model: necesita una función de recompensa, y el ajedrez es uno de los poquísimos sitios donde esa función se puede escribir y comprobar. Nada de lo que sigue se aprende, así que nada puede equivocarse como se equivoca una recompensa aprendida: solo puede estar mal diseñada, que es otro problema y el que vas a mirar en la galería de la última parte.

src/rukh/train/rewards.py
ILLEGAL_MARGIN = 1.0
"""Cuánto por debajo de cualquier jugada legal puntúa una ilegal.
Un `0.0` liso era la elección obvia y estaba mal, y lo cazó la galería de reward hacking: con los
pesos por defecto una jugada legal que entra en repetición puntúa `-0,25`, así que un cero para las
ilegales ponía **lo ilegal por encima de lo legal** justo en las jugadas que el término de
repetición se escribió para desincentivar. Una puerta que lo que controla puede superar no es una
puerta."""
def legality_gate(board, move) -> bool:
"""Si la jugada se puede jugar aquí. La puerta detrás de la que están los demás términos."""
return move in board.legal_moves
def normalised_delta_cp(cp_move, cp_best, scale: float = 200.0) -> float:
"""`1` para la mejor jugada, `0` para una `scale` centipeones peor o más.
Recortada por los dos lados: una jugada no puede ser mejor que la mejor, y una catastrófica no
es más informativa que una simplemente perdedora —dejarla irse a menos infinito permitiría que
una sola barbaridad decidiera la ventaja de todo un grupo.
"""
loss = max(0.0, float(cp_best) - float(cp_move))
return max(0.0, 1.0 - loss / scale)
def mate_bonus(board, move) -> float:
"""`1` cuando la jugada da mate, `0` si no. Binario a propósito.
No «distancia al mate»: un mate más corto no es un resultado mejor, es el mismo resultado antes,
y pagar por la diferencia le enseña al modelo a preferir mates vistosos antes que mates seguros.
"""
copy = board.copy(stack=False)
copy.push(move)
return 1.0 if copy.is_checkmate() else 0.0
def repetition_penalty(board, move) -> float:
"""`1` cuando la jugada entra en repetición. El único término negativo.
Existe por un fallo observado y no por un principio: un modelo premiado por no perder descubre
que dar vueltas nunca pierde. Salta sobre una posición ya vista en esta partida y no sobre la
reclamación de tablas por triple repetición: cuando las tablas ya se pueden reclamar, el paseo
ya ha ocurrido y la recompensa llega tarde.
"""
copy = board.copy()
copy.push(move)
return 1.0 if copy.is_repetition(2) else 0.0
def reward(board, move, cp_move=None, cp_best=None, weights=None) -> RewardBreakdown:
"""La recompensa entera de una jugada, con sus términos.
`cp_move` y `cp_best` vienen de fuera: quien llama decide el presupuesto de análisis una vez,
para todo el grupo, en vez de que cada llamada lo decida otra vez. Sin ellos el término de
calidad es cero y la recompensa cae a lo que se puede saber sin motor.
"""
weights = weights or RewardWeights()
if not legality_gate(board, move):
return RewardBreakdown(total=illegal_value(weights), legal=False)
quality = 0.0
if cp_move is not None and cp_best is not None:
quality = normalised_delta_cp(cp_move, cp_best, weights.cp_scale)
mate = mate_bonus(board, move)
repetition = repetition_penalty(board, move)
total = weights.quality * quality + weights.mate * mate - weights.repetition * repetition
return RewardBreakdown(total=total, legal=True, quality=quality, mate=mate,
repetition=repetition)
def illegal_value(weights=None) -> float:
"""Lo que puntúa una jugada ilegal: estrictamente por debajo de lo que alcanza una legal.
Se **deriva de los pesos** en vez de ser una constante, porque una constante es una promesa que
deja de ser cierta en silencio la primera vez que alguien sube `repetition`.
"""
return legal_bounds(weights)[0] - ILLEGAL_MARGIN
def bounds(weights=None) -> tuple[float, float]:
"""El intervalo cerrado en el que cae toda recompensa: lo que hace el diseño auditable."""
return (illegal_value(weights), legal_bounds(weights)[1])

Cuatro reglas dan forma a todo esto y conviene leerlas como reglas y no como detalles. La legalidad es una puerta, no un término: sumar un bonus de legalidad a una suma ponderada le permite al modelo cambiarla por otra cosa, y el sentido de una recompensa verificable es que hay cosas que no se cambian por nada. Todo está acotado: una recompensa sin techo es una invitación a que el optimizador encuentre la dirección que crece sin límite y se vaya por ahí, y bounds está para que eso sea comprobable con un test sobre miles de entradas aleatorias. El signo y la escala se fijan a mano, no con la dispersión del lote: una recompensa que se reescala a sí misma hace incomparables dos corridas y esconde el momento en que el modelo deja de mejorar. Y todas las funciones son puras: tablero dentro, número fuera, sin llamar al motor, que es lo que permite que los tests corran en milisegundos y que el presupuesto de análisis se decida en un solo sitio.

Con esto tienes el instrumento y las cuatro piezas de teoría que hacen falta para leer lo que salió: el par y su pérdida, DPO y su referencia, las dos fuentes de pares, y GRPO con su línea base y su KL. La parte 2 empieza por dónde miente cada medición, y sigue con lo que midió cada pieza, incluida la que midió lo contrario de lo que se esperaba.