// M5 · lección 05
La arena: dos modelos jugando entre sí
El instrumento del hito, entero: el código que juega un número par de partidas con los colores espejados sobre un libro sembrado, lo convierte en Elo con su intervalo por bootstrap y escribe un informe que dice si hay que creérselo, más los dos arreglos que encontró el control.
Qué vas a construir
rukh eval match: dos modelos jugando el uno contra el otro, y un número con intervalo que dice
cuál es más fuerte. Es lo primero que se escribió del hito y de aquí sale cada fila de la lección 8.
Es también, en pequeño, cómo funcionan las arenas que ordenan LLM enfrentándolos por parejas.
Son src/rukh/eval/match.py, dos arreglos en el bucle de partida de M0, el subcomando de la CLI y la
parte de los tests que hace de control.
El fichero, y la aritmética que lo justifica
src/rukh/eval/match.py tiene tres pliegues de fontanería (importaciones, el adaptador del segundo
modelo y el informe) que el «Ejecútalo» necesita. Si prefieres no teclearlos, trae el fichero entero
ahora, antes de empezarlo, con git checkout p5 -- src/rukh/eval/match.py, y lee la lección
contra él.
El docstring hace la cuenta de la lección 1 con números.
"""Two models playing each other, which is how a *difference* in strength gets measured.
Everything published so far measures a model against the Stockfish ladder and reports an absoluteElo. That is the right instrument for "how strong is this" and the wrong one for "is this strongerthan that", and P5's acceptance criterion asks the second question: **+50 Elo over its base**.
The arithmetic says how wrong. A 50-Elo edge is a 0.5715 expected score head to head, andseparating 0.5715 from 0.5 at 95 % takes **185 games**. The same 50 Elo read off the ladder is0.0712 of score around a base of 0.45, and separating two such estimates takes **757 games perside**, 1 514 in total -- before adding the ladder's own irreproducibility, which P4 measured atabout 40 Elo of one sigma (D-107) because Stockfish plays on a clock and randomises on purpose.
Eight times cheaper and one source of noise fewer, for the simple reason that both models play*the same game*: there is no third party whose mood has to be averaged out.
Two details make the number honest.
**Colours are mirrored.** Every opening is played twice, once with each model on white, so arepertoire that happens to suit one side cannot decide the match. A match with an odd number ofgames is refused rather than silently unbalanced.
**The openings are shared and seeded.** Both games of a pair start from the same position, so thepair is a comparison and not two samples."""Una ventaja de 50 Elo es una puntuación esperada de 0,5715, y separarla de 0,5 al 95 % cuesta 185 partidas. En la escalera, esos 50 Elo son 0,0712 de puntuación alrededor de 0,45, y separar dos estimaciones así cuesta unas 375 partidas por lado, 750 en total, antes de sumar la irreproducibilidad de la escalera: unas cuatro veces más. El docstring dice 757 por lado y «Eight times cheaper» porque cuenta dos veces la varianza de la resta (la lección 9, en el lab 1, enseña la línea). Los dos modelos juegan la misma partida: no hay un tercero cuyo humor haya que promediar.
Dos detalles hacen el número honesto: los colores se espejan —cada apertura se juega dos veces, una con cada modelo de blancas— y el libro es compartido y sembrado, así que las dos partidas de un par salen de la misma posición. Es un diseño emparejado: el mismo paciente prueba los dos tratamientos, y lo que varía entre pacientes deja de contar.
Las importaciones y el tipo de una partida se pueden saltar en la primera lectura; al teclear hacen falta (sin ellas el fichero no importa), así que despliégalas, salvo que trajeras el fichero entero al empezarlo.
// FontaneríaImportaciones y MatchGame: una partida, siempre desde el punto de vista de a
from __future__ import annotations
import jsonimport mathimport randomfrom pathlib import Pathfrom typing import TYPE_CHECKING
import chessimport numpy as npfrom pydantic import BaseModel, ConfigDict
from rukh.infer.game import DecoderPlayer, play_game_with
if TYPE_CHECKING: # pragma: no cover - typing only from collections.abc import Sequence
from rukh.infer.game import Player from rukh.infer.sampler import SampleConfig from rukh.models import MoveDecoder from rukh.tokenize.uci_vocab import UciTokenizer
__all__ = [ "MatchSettings", "MatchGame", "MatchResult", "elo_difference", "games_for_edge", "opening_book", "play_match", "render_markdown", "summarise", "write_match",]class MatchGame(BaseModel): """One game of the match, from ``a``'s point of view."""
model_config = ConfigDict(extra="forbid")
index: int opening: str """The moves both games of this pair started from, in UCI.""" a_white: bool result: str score: float """1 for a win by ``a``, 0.5 for a draw, 0 for a loss.""" plies: int a_illegal: int b_illegal: intUna partida, siempre desde el punto de vista de a. Guardar la apertura en cada partida permite
comprobar después que las dos direcciones de un par jugaron el mismo libro.
class MatchSettings(BaseModel): """Everything that would change the number, written beside the number.
``masked`` is the one worth a sentence. By default both models propose from the unmasked distribution and an illegal proposal is rescued with a masked draw -- so a model that proposes illegally more often gets that second, legal-only draw more often. With ``masked`` the rescue path does not exist for either side. The two readings answer different questions and the file has to say which one it is. """
model_config = ConfigDict(extra="forbid")
games: int opening_plies: int opening_seed: int header_elo: int temperature: float top_k: int | None masked: bool bootstrap_samples: intTodo lo que cambiaría el número, escrito al lado del número. La clave que hay que entender es
masked, y su docstring explica un sesgo que nadie estaba buscando.
Por defecto, una propuesta ilegal se rescata con un segundo sorteo restringido a jugadas legales, que es un sorteo mejor. Así que un modelo que propone más ilegales recibe más segundos sorteos. Es un examen tipo test donde cada respuesta inválida te da derecho a contestar otra vez con las opciones malas tachadas.
Con masked, ningún lado tiene rescate. Sirve de control: si las dos lecturas coinciden, la
ventaja era fuerza; si no, era el rescate.
class MatchResult(BaseModel): """What the match measured: a score, an Elo difference and its interval."""
model_config = ConfigDict(extra="forbid")
a: str b: str games: int score: float """``a``'s share of the points. 0.5 means the match found no difference.""" wins: int draws: int losses: int elo: float """``400 log10(p / (1 - p))``: how much stronger ``a`` is than ``b``, in Elo.""" ci_low: float | None = None ci_high: float | None = None separated: bool = False """Whether the interval excludes zero, which is the claim the criterion is read on.""" a_illegal: int = 0 b_illegal: int = 0 settings: MatchSettings = None # type: ignore[assignment] """How the match was played. A measurement that does not carry its settings cannot be compared to another one, and D-118 is the entry that says so with numbers: every reward in the gallery changed when the engine depth did, and nothing in the log would have said why."""
@property def decisive(self) -> int: return self.wins + self.lossesEl veredicto, separated, vive junto al intervalo del que se dedujo; más abajo verás un caso en el
que la estimación se movió 22 puntos y el veredicto se dio la vuelta. Y settings va dentro porque
una medición sin sus ajustes no se puede comparar con otra.
def elo_difference(score: float, cap: float = 1200.0) -> float: """``400 log10(p / (1 - p))``, clamped when one side won everything.
A clean sweep has no finite fit -- the logit runs off -- so it is reported at the cap with the sign it earned, and ``MatchResult.separated`` is what a reader should look at instead. """ 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))))La conversión a Elo, con su tope: un instrumento que puede devolver infinito acaba metiéndolo en
una tabla. En una barrida, lo que hay que mirar es separated, no el número.
def games_for_edge(elo: float, level: float = 0.95) -> int: """How many games it takes to tell an edge of ``elo`` from nothing.
The inverse of the question every match asks, and the reason this module exists: run it **before** paying for the games, not after looking at them. """ if elo == 0: return 0 from scipy.stats import norm # noqa: PLC0415 - only needed here
p = 1.0 / (1.0 + 10.0 ** (-abs(elo) / 400.0)) z = float(norm.ppf(0.5 + level / 2.0)) return math.ceil(z * z * p * (1.0 - p) / (p - 0.5) ** 2)La función que se ejecuta antes de pagar las partidas: ¿cuántas hacen falta para distinguir una
ventaja de elo de nada? El abs(elo) deja escrito que +50 y −50 cuestan lo mismo.
def opening_book(pairs: int, plies: int = 6, seed: int = 0) -> list[list[str]]: """``pairs`` random legal openings, each to be played twice with the colours swapped.
Random rather than a curated book on purpose: a book of "sound" openings would measure the two models inside somebody else's taste, and the question here is which of the two plays better chess, not which of them knows the Ruy Lopez. """ rng = random.Random(seed) book: list[list[str]] = [] while len(book) < pairs: board = chess.Board() moves: list[str] = [] for _ in range(plies): legal = list(board.legal_moves) if not legal: break move = rng.choice(legal) moves.append(move.uci()) board.push(move) if len(moves) == plies and not board.is_game_over(): book.append(moves) return bookEl libro es aleatorio y sin curar, a propósito: uno de aperturas «sanas» mediría los dos modelos
dentro del gusto de otra persona, y la pregunta es cuál juega mejor, no cuál se sabe la Ruy López.
La guarda del final descarta aperturas que terminaron antes de tiempo, y el while asegura el
número de aperturas pedido.
El adaptador que deja jugar a dos decoders es fontanería; se puede saltar al leer. Al teclear no:
sin él, play_match no tiene con qué poner al segundo modelo en el tablero. Despliégalo, salvo que
trajeras el fichero entero al empezarlo.
// Fontanería_PlayerOpponent: envolver el segundo modelo para reutilizar el bucle de partida
def _board_from(opening: Sequence[str]) -> chess.Board: board = chess.Board() for uci in opening: board.push_uci(uci) return board
class _PlayerOpponent: """A ``Player`` seen as an ``Opponent``: what lets two decoders face each other.
``play_game_with`` drives one side through the ``Player`` protocol and asks the other for a move. Wrapping the second model here means the match reuses the very loop every published game was played on, instead of a second implementation that could disagree with it. """
def __init__(self, player: Player) -> None: self.player = player self.illegal = 0
def start(self, white_elo: int, black_elo: int) -> None: self.illegal = 0 self.player.start(white_elo, black_elo)
def choose(self, board: chess.Board) -> chess.Move: move, legal = self.player.choose(board) if not legal: self.illegal += 1 if move is None: # The loop has no way to say "I resign"; the first legal move ends the game honestly # and the illegal count already recorded that the model had nothing to offer. return next(iter(board.legal_moves)) return move
def observe(self, move: chess.Move) -> None: self.player.observe(move)_PlayerOpponent permite que dos decoders se enfrenten. El bucle de partida conduce un lado por el
protocolo Player, que devuelve (jugada, fue_legal), y el otro por Opponent, que devuelve solo
la jugada. Envolver el segundo modelo aquí hace que el enfrentamiento reutilice el mismo bucle
que jugó cada partida publicada, en vez de una segunda implementación que podría discrepar.
El comentario del choose explica el último recurso: el bucle no sabe rendirse, y un modelo que no
propone nada ya quedó contado como ilegal, así que la primera jugada legal termina la partida sin
reventar el enfrentamiento.
def play_match( a: Player, b: Player, games: int = 200, opening_plies: int = 6, seed: int = 0, header_elo: int = 1800, max_plies: int | None = None,) -> list[MatchGame]: """Play ``games`` games between two players, colours mirrored over shared openings.""" if games < 2 or games % 2: raise ValueError(f"a mirrored match needs an even number of games, got {games}") book = opening_book(games // 2, plies=opening_plies, seed=seed) played: list[MatchGame] = [] for opening in book: for a_white in (True, False): opponent = _PlayerOpponent(b) # `play_game_with` starts the player and replays the board's opening into both sides; # the opponent is started here because the loop does not know it is a model. opponent.start(header_elo, header_elo) board = _board_from(opening) result = play_game_with( a, opponent, model_color=chess.WHITE if a_white else chess.BLACK, white_elo=header_elo, black_elo=header_elo, max_plies=max_plies, board=board, ) score = _score_for_a(result.result, a_white) played.append( MatchGame( index=len(played), opening=" ".join(opening), a_white=a_white, result=result.result, score=score, plies=result.plies, a_illegal=result.illegal_proposals, b_illegal=opponent.illegal, ) ) return playedLa guarda de la primera línea es la que más se agradece: un número impar de partidas se rechaza en vez de redondearse en silencio. Un reparto de colores torcido es un sesgo que no aparece en ninguna métrica, así que tiene que doler al escribir el comando.
El opponent.start(...) explícito es parte del arreglo que encontró el control: el bucle arranca
al Player, pero no sabe que su rival también es un modelo.
def _score_for_a(result: str, a_white: bool) -> float: if result == "1/2-1/2": return 0.5 if result == "1-0": return 1.0 if a_white else 0.0 if result == "0-1": return 0.0 if a_white else 1.0 return 0.5 # a game that was cut short is a draw, the same rule the ladder usesdef summarise( played: Sequence[MatchGame], names: tuple[str, str] = ("a", "b"), samples: int = 2_000, seed: int = 0, level: float = 0.95, settings: MatchSettings | None = None,) -> MatchResult: """The match as one number with its interval, bootstrapped over games.""" if not played: raise ValueError("cannot summarise a match with no games") scores = np.array([game.score for game in played], dtype=float) score = float(scores.mean()) rng = np.random.default_rng(seed) draws = rng.choice(scores, size=(samples, len(scores)), replace=True).mean(axis=1) elos = np.array([elo_difference(float(value)) for value in draws]) low, high = (np.quantile(elos, (1 - level) / 2), np.quantile(elos, 1 - (1 - level) / 2)) return MatchResult( a=names[0], b=names[1], games=len(played), score=score, wins=int(sum(1 for game in played if game.score == 1.0)), draws=int(sum(1 for game in played if game.score == 0.5)), losses=int(sum(1 for game in played if game.score == 0.0)), elo=elo_difference(score), ci_low=float(low), ci_high=float(high), separated=bool(low > 0 or high < 0), a_illegal=int(sum(game.a_illegal for game in played)), b_illegal=int(sum(game.b_illegal for game in played)), settings=settings or MatchSettings( games=len(played), opening_plies=len(played[0].opening.split()), opening_seed=-1, header_elo=-1, temperature=float("nan"), top_k=None, masked=False, bootstrap_samples=samples, ), )El bootstrapBootstrapRemuestrear los datos con reemplazo muchas veces y recalcular el estadístico en cada remuestreo para ver cuánto se mueve. El intervalo del Elo de Rukh sale así: 500 remuestreos de las 160 partidas (bootstrap: 500 en la suite), un ajuste por remuestreo, y los percentiles 2,5 y 97,5. Cubre el ruido del muestreo de partidas y nada más: no cubre que el rival cambie entre ejecuciones ni que los peldaños estén mal colocados. se hace sobre las puntuaciones de las partidas: se
remuestrean 2 000 veces con reemplazo, cada media se convierte a Elo, y los cuantiles de esa
distribución son el intervalo. Es preguntar dos mil veces «¿y si hubieran salido otras partidas
parecidas a estas?» con las que tienes, lo mismo que la
estadística justa de M2 hace a mano con cinco
partidas. separated es low > 0 or high < 0, la única afirmación
que admite el criterio del hito. Los -1 y nan del settings por defecto son feos a propósito,
para que nadie los confunda con una medición.
def decoder_player(model: MoveDecoder, tok: UciTokenizer, cfg: SampleConfig) -> DecoderPlayer: """The same player the ladder uses, so a match and a ladder run measure the same model.""" return DecoderPlayer(model, tok, cfg)El jugador del enfrentamiento es el mismo que el de la escalera; si fueran dos, estarían midiendo dos modelos distintos con el mismo nombre.
El informe y su escritor se pueden saltar en la primera lectura: por color, si hay partidas suficientes y cómo se jugó. Al teclear hacen falta, porque el subcomando escribe el informe con ellos: despliégalos, salvo que trajeras el fichero entero al empezarlo.
// Fontaneríarender_markdown y write_match: el informe report.md y el results.json
def render_markdown(result: MatchResult, played: Sequence[MatchGame]) -> str: """The match as a report, with the arithmetic that says whether to believe it.""" sign = "+" if result.elo >= 0 else "" interval = ( f"{result.ci_low:.0f} to {result.ci_high:.0f}" if result.ci_low is not None and result.ci_high is not None else "n/a" ) needed = games_for_edge(result.elo) if result.elo else 0 white = [game for game in played if game.a_white] black = [game for game in played if not game.a_white] settings = result.settings rescue = ( "masked from the first draw" if settings.masked else "proposed unmasked, rescued with a masked draw" ) lines = [ f"# `{result.a}` against `{result.b}`", "", "Two models playing each other over the same openings with the colours mirrored. This is", "the instrument for a *difference* in strength: both models play the same game, so there", "is no third party whose mood has to be averaged out.", "", "| Metric | Value |", "|---|---|", f"| Games | {result.games} |", f"| Score for `{result.a}` | {result.score:.4f} |", f"| Won / drawn / lost | {result.wins} / {result.draws} / {result.losses} |", f"| Elo difference | **{sign}{result.elo:.0f}** |", f"| 95 % CI | {interval} |", f"| Separated from zero | {'yes' if result.separated else 'no'} |", f"| Illegal proposals | {result.a_illegal} by `{result.a}`, " f"{result.b_illegal} by `{result.b}` |", "", "## By colour", "", "A model that only wins with one colour has an opening repertoire, not an edge.", "", f"| `{result.a}` plays | Games | Score |", "|---|---:|---:|", f"| white | {len(white)} | {sum(g.score for g in white) / max(len(white), 1):.4f} |", f"| black | {len(black)} | {sum(g.score for g in black) / max(len(black), 1):.4f} |", "", "## Is this enough games?", "", f"An edge of {abs(result.elo):.0f} Elo takes **{needed}** games to tell from nothing at " f"95 %, and this match played **{result.games}**. " + ( "So the number above is a measurement." if result.games >= needed else "So the number above is a hint, not a measurement: play more games or say so." ), "", "## How it was played", "", "Two numbers from different settings are two numbers, not a comparison.", "", "| Setting | Value |", "|---|---|", f"| Opening book | {settings.opening_plies} plies, seed {settings.opening_seed} |", f"| Elo header | `<w{settings.header_elo}>` for both |", f"| Sampling | temperature {settings.temperature}, top-k {settings.top_k} |", f"| Illegal moves | {rescue} |", f"| Bootstrap | {settings.bootstrap_samples} resamples |", "", ] return "\n".join(lines) + "\n"El informe es Markdown generado, con tres secciones:
- Por color: un modelo que solo gana con un color tiene un repertorio, no una ventaja.
- ¿Son suficientes partidas?: llama a
games_for_edgecon la ventaja que acaba de medir y, si no llegan, escribe que el número es una pista. Un informe que se corrige a sí mismo vale más que un número que hay que acordarse de cuestionar. - Cómo se jugó: libro, semilla, muestreo y si las ilegales se rescataron o se enmascararon.
def write_match(result: MatchResult, played: Sequence[MatchGame], out_dir: Path | str) -> Path: """Write ``report.md`` and ``results.json`` for one match; returns the report.""" directory = Path(out_dir) / f"{result.a}-vs-{result.b}" directory.mkdir(parents=True, exist_ok=True) report = directory / "report.md" report.write_text(render_markdown(result, played), encoding="utf-8", newline="\n") payload = result.model_dump(mode="json") payload["played"] = [game.model_dump(mode="json") for game in played] (directory / "results.json").write_text( json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n" ) return reportEl escritor deja un report.md y un results.json con todas las partidas dentro; el agrupador de
la lección 9 necesita las puntuaciones partida a partida.
Los dos arreglos que el control encontró
El control de la lección 1: un modelo contra una copia exacta de sí mismo tiene que dar 0,5000.
uv run rukh eval match --a checkpoints/medium-v4-<fecha>/best.pt --b checkpoints/medium-v4-<fecha>/best.pt --games 100Salió 0,975 a favor de uno de los dos lados, con 999 jugadas ilegales. Los dos errores estaban
en src/rukh/infer/game.py, el bucle de partida de M0, y los dos se arreglan en pocas líneas.
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)El primero es esta función, nueva en p5: le enseña una jugada a un rival si el rival sabe
recibirla. Opponent no tiene observe en su protocolo —Stockfish y el jugador aleatorio no
guardan estado—, así que un rival con estado, como un segundo modelo, iba a ciegas toda la partida.
El getattr con callable lo resuelve sin tocar el protocolo.
board = board if board is not None else chess.Board() limit = max_plies if max_plies is not None else player.limit player.start(white_elo, black_elo) # A board handed in with moves already on it is a position the player has to *know about*: # its prompt is the game so far, not the game from here. Without this a match that starts # from an opening book plays every move blind to the opening, which reads as a model that # forgot how to play. The match harness of P5 found it with a model against itself. for played in board.move_stack: player.observe(played) _tell(opponent, played) moves: list[str] = [] illegal = 0El segundo, dentro de play_game_with: el prompt de un jugador es la partida hasta aquí, así que un
tablero que llega con jugadas puestas se le tiene que contar. Sin este bucle, el modelo veía un
prompt vacío y un tablero con seis jugadas del libro, y proponía jugadas de la posición inicial. En
un agente o un chat es el mismo fallo: arrancar a mitad de conversación sin pasarle el historial.
_tell(opponent, move) dentro del bucle cuenta cada jugada a los dos lados a partir de ahí.
Después de los dos arreglos: 0,5000 exacto, 11 ilegales por bando.
El subcomando
El docstring de rukh eval match es documentación de uso: explica cuándo no usar la bandera que
trae.
Es fontanería de CLI y se puede saltar al leer; al teclear no, porque el «Ejecútalo» de debajo lo
usa. Si lo saltas, tráelo con git checkout p5 -- src/rukh/cli.py (si ya lo trajiste en una
lección anterior del módulo, ya lo tienes).
// Fontaneríarukh eval match: el subcomando, con la guarda del número par y el muestreo de la suite
@eval_app.command("match")def eval_match_cmd( a: Annotated[str, typer.Option("--a", help="Checkpoint or Hub id of the first model.")], b: Annotated[str, typer.Option("--b", help="Checkpoint or Hub id of the second model.")], games: Annotated[ int, typer.Option("--games", help="Games to play; must be even (colours are mirrored).") ] = 200, opening_plies: Annotated[ int, typer.Option("--opening-plies", help="Random plies both games of a pair start from.") ] = 6, header_elo: Annotated[ int, typer.Option("--elo", help="Elo header both models are prompted with.") ] = 1800, config: Annotated[ Path | None, typer.Option("--config", exists=True, dir_okay=False, help="Suite YAML for the sampling."), ] = None, seed: Annotated[int, typer.Option("--seed", help="Seed of the opening book.")] = 0, mask: Annotated[ bool, typer.Option("--mask/--no-mask", help="Mask illegal moves for both sides from the start."), ] = False, out: Annotated[ Path | None, typer.Option("--out", help="Where to write the report (default: artifacts).") ] = None, device: Annotated[str | None, typer.Option("--device", help="Where to run.")] = None,) -> None: """Play two models against each other and report the Elo difference with its interval.
This is the instrument for "is A stronger than B", and it is not the ladder. A 50-Elo edge takes 185 games head to head and 750 games *per side* off the ladder, before adding the ladder's own irreproducibility (about 40 Elo of one sigma, D-107). To measure a difference, measure the difference -- do not measure two absolutes and subtract them.
Run `uv run python labs/m5/games_needed_match.py <elo>` first: it says how many games the edge you expect would take, which is a decision to make before paying for them rather than after.
`--mask` exists as a **control**, not as a setting. By default both models propose from the unmasked distribution and an illegal proposal is rescued with a masked draw, which is how every published number of this project is read. But the rescue is a second draw restricted to legal moves, so a model that proposes illegally more often gets that second draw more often -- and the two DPO models of M5 propose about twice as many illegal moves as their base. `--mask` removes the rescue path entirely by masking from the first draw, so the edge can be read again with that confound gone. If the two numbers agree, the edge was strength; if they do not, the edge was the rescue. """ from rukh.config import load_yaml from rukh.eval.match import MatchSettings, games_for_edge, play_match, summarise from rukh.eval.suite import EvalConfig, resolve_model from rukh.infer import SampleConfig from rukh.infer.game import DecoderPlayer from rukh.tokenize.uci_vocab import UciTokenizer from rukh.train import load_model, pick_device
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") if games % 2 or games < 2: typer.echo("error: --games must be even and at least 2 (colours are mirrored)", err=True) raise typer.Exit(code=2)
suite = load_yaml(config, EvalConfig) if config else EvalConfig() where = device or pick_device() tok = UciTokenizer() try: model_a, _ = load_model(resolve_model(a), map_location=where) model_b, _ = load_model(resolve_model(b), map_location=where) except (FileNotFoundError, ValueError) as exc: typer.echo(f"error: {exc}", err=True) raise typer.Exit(code=1) from exc
sampling = SampleConfig( temperature=suite.temperature, top_k=suite.top_k, mask_illegal=mask, seed=suite.seed ) names = (Path(a).parent.name or a, Path(b).parent.name or b) player_a = DecoderPlayer(model_a.to(where).eval(), tok, sampling) player_b = DecoderPlayer(model_b.to(where).eval(), tok, sampling)
typer.echo(f"match: {names[0]} vs {names[1]}, {games} games, header <w{header_elo:04d}>") played = play_match( player_a, player_b, games=games, opening_plies=opening_plies, seed=seed, header_elo=header_elo, ) result = summarise( played, names=names, seed=suite.seed, settings=MatchSettings( games=games, opening_plies=opening_plies, opening_seed=seed, header_elo=header_elo, temperature=suite.temperature, top_k=suite.top_k, masked=mask, bootstrap_samples=2_000, ), )
from rukh.eval.match import write_match
path = write_match(result, played, out or Path(suite.out_dir) / "matches") sign = "+" if result.elo >= 0 else "" typer.echo(f"score: {result.score:.4f} ({result.wins}W {result.draws}D {result.losses}L)") typer.echo( f"elo: {sign}{result.elo:.0f} (95 % CI {result.ci_low:.0f} to {result.ci_high:.0f})" if result.ci_low is not None and result.ci_high is not None else f"elo: {sign}{result.elo:.0f}" ) typer.echo(f"verdict: {'separated from zero' if result.separated else 'includes zero'}") typer.echo(f"illegal: {result.a_illegal} by {names[0]}, {result.b_illegal} by {names[1]}") typer.echo(f"would need {games_for_edge(result.elo)} games to call this edge, played {games}") typer.echo(f"report: {path}")La guarda del número par se repite aquí, antes de cargar nada, para que el error cueste dos segundos
en vez de los cuarenta que tarda en cargar dos modelos. El muestreo sale de la configuración de la
suite (--config), así que un enfrentamiento y una fila de la tabla única usan la misma
temperatura. Y la última línea impresa compara las partidas que harían falta con las que se
jugaron: con eso delante, «+40 con 100 partidas» y «+40 con 800» dejan de parecerse.
Dos detalles de la ayuda. Donde el docstring dice «750 games per side», con la varianza bien
contada son 375 por lado, 750 en total, como arriba. Y donde sugiere games_needed_match.py <elo>,
el lab no acepta un argumento posicional; su bandera es --edges, una lista separada por comas, y
sin ella imprime la tabla completa.
Salida en la máquina de referencia (RTX 5090), la arista entre los dos brazos de DPO con el de
dentro de política pasado como A:
score: 0.5687 (191W 73D 136L)elo: +48 (95 % CI 19 to 80)verdict: separated from zeroillegal: 441 by A, 692 by Bwould need 200 games to call this edge, played 400Esa misma comparación, con los lados intercambiados, dio +26 con el intervalo incluyendo el cero. La estimación se movió 22 puntos, ruido normal con 400 partidas, y el veredicto se dio la vuelta. Por eso las dos direcciones se corren siempre y se agrupan (lab 3).
El test que es el control
De tests/unit/test_match.py van aquí los dos tests que sostienen el instrumento. El resto fija la
conversión a Elo y su tope, las 185 partidas de games_for_edge(50), el rechazo del número impar,
los colores espejados sobre un libro reproducible, las ilegales por bando y el rival sin observe.
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_match.py: el control con jugadores de juguete y el libro que se cuenta a los dos lados
def test_the_same_model_on_both_sides_measures_zero(): """The control the whole instrument rests on: no difference must read as no difference.""" played = play_match(_Scripted(5), _Scripted(5), games=20, opening_plies=4, seed=7) result = summarise(played, names=("x", "x")) assert result.games == 20 assert result.wins + result.draws + result.losses == 20 assert result.ci_low is not None and result.ci_high is not None assert result.ci_low <= 0 <= result.ci_high, "an unbiased instrument includes zero" assert result.separated is FalseEl control con un jugador de juguete: el mismo _Scripted en los dos lados, veinte partidas, y el
intervalo tiene que contener el cero. Con un juguete corre en milisegundos en cada guardado; con
modelos de verdad cuesta un minuto de GPU y se corre una vez.
def test_a_board_handed_in_with_moves_is_replayed_into_both_sides(): """The first bug the control found: a match from an opening book played it blind.
`play_game_with` used to start the player and then begin at whatever position the board was in, without telling it how the game got there. The prompt was a game that never happened, and the model answered with illegal moves -- which read as a broken model rather than a broken harness. """ from rukh.infer.game import play_game_with
opening = ["e2e4", "e7e5", "g1f3"] board = chess.Board() for uci in opening: board.push_uci(uci)
player, opponent = _Counting(), _CountingOpponent() play_game_with(player, opponent, max_plies=len(opening) + 4, board=board) assert player.seen[: len(opening)] == opening assert opponent.seen[: len(opening)] == openingY el que fija el fallo del libro: con un tablero que llega con e4 e5 Cf3, los dos lados tienen
que haber recibido esas tres jugadas antes de empezar. Sin el bucle, player.seen empieza vacío.
Qué has aprendido
Un instrumento para medir diferencias se construye antes que el experimento y se estrena contra una respuesta conocida. Lo que lo hace honesto —número par de partidas, colores espejados, libro compartido— cabe en unas pocas guardas, y la que más vale es la que se niega a hacer algo en vez de arreglarlo en silencio. El informe lleva dentro la cuenta que dice si hay que creérselo. Todo esto vale igual para comparar dos prompts, dos modelos o dos versiones de un agente.
Lo siguiente es publicar: el reward model no es una política y no se puede publicar como si lo fuera.