// M3 · lección 08
El encoder: medir sin engañarse
Por qué un 96,78 % de exactitud sobre una clase del 3,7 % no significa nada, la heurística de material contra la que se mide, precisión, exhaustividad y F1 en un punto de operación elegido aparte, Pearson y Spearman a la vez, y por qué el reparto de los datos va por partida y no por posición.
Esta lección es la segunda mitad de src/rukh/eval/encoder.py —las métricas y el reparto del
umbral— más el baseline entero. Es la parte del módulo que más vas a reutilizar: sirve igual para
un detector de fraude, un filtro de spam o un reranker de un RAG, en cuanto la clase que te
interesa es rara.
Cómo se mide y contra qué
Al terminar sabrás leer las cifras de rukh eval encoder, explicar por qué el baseline es malo a
propósito y elegir un punto de operaciónPunto de operaciónEl umbral a partir del cual una probabilidad se convierte en una decisión («p ≥ 0,0663 es error»). Precisión, exhaustividad y F1 dependen de él; ROC AUC y precisión media no. Es un hiperparámetro legítimo, pero solo hay una forma honesta de elegirlo: en filas que no son las que se puntúan. En Rukh las filas etiquetadas del conjunto held-out se parten en dos por game_id, la mitad tune elige el umbral que maximiza F1 y la mitad score es la que se publica. Cada checkpoint tiene el suyo (0,0663 el last-n de jugadas, 0,04459 el de casillas), y el 0,5 de fábrica de configs/eval/encoder.yaml se publica al lado, nunca como titular. sin hacer trampa.
Un encoder no juega partidas, así que se mide por lo que sabe de posiciones que no ha visto: la
partición de validación, que es el 10 % de las partidas (val_fraction: 0.1, no el 10 % de las
posiciones sueltas), con el baseline medido sobre las mismas filas. Estos son los números del hito:
| Criterio del hito | Listón | Al cerrar el hito | Hoy | ¿Se cumple? |
|---|---|---|---|---|
| F1 de error sobre la heurística | +5 puntos | +9,2 (0,180 vs 0,089) | +9,7 | sí |
| Correlación del valor con Stockfish | ≥ 0,80 (Spearman) | 0,520 | 0,6665 | no |
La columna «hoy» es la del modelo después de cambiar la pérdida de la cabeza de valor (lo cuenta
«La pérdida no medía lo que el criterio mide», más abajo): sube de 0,520 a 0,6665 y sigue sin
cumplir. Salvo donde se diga otra cosa, las cifras de esta lección son del checkpoint publicado al
cerrar el hito, el afinado last-n del esquema de jugadas.
Hay una tercera cifra, val/blunder_acc = 96,78 %, que no es ningún criterio y que, leída como si
lo fuera, sería la mentira más cómoda del módulo. Antes de desmontarla hacen falta tres
definiciones.
Precisión, exhaustividad y F1
Las tres hablan de un detector que marca algunas filas como «error».
La precisión responde a «de lo que marqué, cuánto era de verdad». Es la que le importa a la demo: una barra que grita «error» en jugadas correctas se apaga a los cinco minutos. La exhaustividad responde a «de lo que había, cuánto marqué», y es la que le importa a quien analiza sus partidas. Las dos tiran en sentidos opuestos: bajar el umbral captura más errores reales y también más falsas alarmas. Dónde plantarse es una decisión de producto, no matemática.
F1 es la media armónica de las dos, 2·P·R/(P+R), y se usa porque castiga el desequilibrio: con
precisión 1,0 y exhaustividad 0,01, la media aritmética daría 0,5 y la armónica da 0,02. Es la
velocidad media de un viaje de ida y vuelta: si vas a 100 km/h y vuelves a 20, la media sale 33 y
no 60, porque en el tramo lento pasas cinco veces más tiempo. La pata lenta manda.
El checkpoint publicado saca 11,7 % de precisión, 39,6 % de exhaustividad y 18,0 % de F1. De dónde salen esas cifras es el resto de la lección.
// Ejercicio 01Media aritmética contra media armónica, con los números del informe
La cabeza publicada tiene precisión 0,117 y exhaustividad 0,396. Calcula la media aritmética y la armónica de las dos. Después haz lo mismo con la heurística de material de más abajo (0,047 y 0,771). ¿Cuál de las dos medias le daría a la heurística un número más parecido al del encoder, y por qué es eso lo que no se quiere?
// SoluciónVer la solución
Encoder: aritmética (0,117 + 0,396) / 2 = 0,257; armónica
2 · 0,117 · 0,396 / (0,117 + 0,396) = 0,0927 / 0,513 = 0,181, que es el 18,0 % del informe
salvo redondeo. Heurística: aritmética (0,047 + 0,771) / 2 = 0,409; armónica
2 · 0,047 · 0,771 / 0,818 = 0,089, el 8,9 % del informe.
Con la aritmética la heurística ganaría (0,409 frente a 0,257): la exhaustividad del 77 % compensa de sobra una precisión de una entre veinte. Con la armónica pierde por la mitad. Eso es lo que se quiere: que marcar dos de cada tres jugadas como error no se pueda disfrazar de detector.
El 96,78 % que no significa nada
Los errores son el 3,72 % de las filas etiquetadas, y de ahí salen tres consecuencias que valen para cualquier detector de algo raro: fraude, fallos de máquina, diagnósticos, moderación.
Uno: la exactitud no mide nada. Un detector que conteste siempre «no hay error» acierta el 96,3 % de las veces sin mirar el tablero. Nuestra cabeza saca 96,78 %. Esas décimas son todo lo que la exactitud sabe decir de un modelo que, como vas a ver, sí ha aprendido algo. Cuando una clase es rara, la exactitud mide la tasa baseTasa baseFrecuencia de la clase positiva en los datos: en M3, los errores son el 3,7 % de las filas etiquetadas. Es el número que hay que preguntar antes de leer cualquier exactitud, porque un detector que siempre dice «no» acierta uno menos la tasa base (96,3 %) sin saber nada de ajedrez, y es la referencia de la precisión media: un orden aleatorio saca exactamente la tasa base (0,037), no 0,5. y disfraza de resultado una propiedad del dataset. La advertencia está en el código, para que viaje con cada número:
def base_rate_caveat(base_rate: float | None) -> str: """The one sentence that has to travel with every number in this section.
It is in the report and in the model card because it is the transferable lesson of the module: on a rare class, the two numbers everybody reaches for first say nothing. """ share = "the blunder base rate" if base_rate is None else f"{base_rate * 100:.1f} %" always = "" if base_rate is None else f" scores {(1 - base_rate) * 100:.1f} %" return ( f"a blunder is rare ({share} of the labelled rows), so **accuracy is meaningless** here: " f'a model that always answers "no blunder"{always} without knowing anything about ' "chess. F1 at an arbitrary threshold is nearly as bad, because an uncalibrated sigmoid " "can rank the positions well and still put every probability below 0.5: that number " "measures the operating point, not the representation. This is why the threshold is " f"chosen on a `{TUNE_HALF}` half and the F1 is reported on a `{SCORE_HALF}` half, and " "why ROC AUC and average precision, which no threshold can flatter, are reported next " "to it" )
def sentence(text: str) -> str: """The same clause as a sentence: the notes start in lower case, the prose does not.
``str.capitalize`` is not it: it would lower-case ``ROC AUC`` and ``F1`` on the way past. """ text = text.strip() return text[:1].upper() + text[1:] + ("" if text.endswith(".") else ".")La frase aparece en tres sitios: las notas, el informe y la model cardModel cardEl README de un repositorio del Hub, y la afirmación pública de qué se publicó y qué se midió. Las de Rukh se generan desde run.json y results.json, no se escriben a mano, y llevan el SHA del fichero y el de los pesos, el muestreo y la suite de cada número, la fecha, lo que no se midió y la licencia. rukh publish cards las regenera todas desde la tabla y sube solo el README.; escrita una sola vez, ninguna de las tres puede quedarse
con una versión más suave. Y calcula el contrafáctico, (1 - base_rate) * 100: «la exactitud no
significa mucho aquí» es una opinión; «un modelo que siempre contesta que no saca 96,3 %» es un
dato, y el lector hace la resta solo.
Dos: el F1 en un umbral arbitrario tampoco mide lo que crees. Esta engaña a gente con experiencia. El F1 de esta cabeza en el umbral fijo de 0,5 es 0,0000: cero posiciones marcadas de 3 660. La razón está en una línea del informe:
encoder probability span: 0.0020 to 0.3102 (mean 0.0353)La probabilidad más alta que esta cabeza le da a una posición en todo el conjunto es 0,3102, así que en 0,5 no se dispara nunca. Y el modelo sí sabe algo: ordena bien las posiciones (lo dirá el ROC AUC), solo que las deja todas por debajo de 0,5. Sin esa línea del informe, el cero parecería un modelo que no funciona.
Hay dos razones para ese cero. La primera es la calibraciónCalibraciónUn modelo está calibrado cuando sus probabilidades significan lo que dicen: de todas las veces que dice 0,3, acierta más o menos tres de cada diez, como un meteorólogo fiable. En M3 el detector de errores no lo está —sus probabilidades viven entre 0,002 y 0,310—, y por eso el umbral de fábrica de 0,5 no marca ninguna jugada. Aun calibrado, con una clase del 3,7 % el mejor umbral para F1 estaría muy por debajo de 0,5.: un modelo está calibrado cuando sus probabilidades significan lo que dicen, como el meteorólogo que, de todos los días en que anuncia un 30 % de lluvia, acierta más o menos en tres de cada diez. Nadie calibró esta cabeza, así que su 0,5 no tiene por qué querer decir «más probable que sí que que no». La segunda es más de fondo: aunque estuviera calibrada, 0,5 sería un mal umbral para F1 con una clase del 3,7 %, porque casi ninguna jugada es, vista desde fuera, más probablemente error que no. Para un clasificador calibrado, el umbral que maximiza F1 está en la mitad del mejor F1 alcanzable: con un F1 en torno a 0,18, alrededor de 0,09, del orden del 0,066 que encontrará el barrido de abajo. Sea cual sea la razón, un F1 en un umbral fijo mide el umbral, no la representación.
Tres: elegir el umbral es legítimo, si se hace en otras filas. El umbral es un hiperparámetro como cualquier otro. Lo que no vale es elegirlo mirando las filas que después vas a puntuar: el número publicado sería el máximo de una búsqueda sobre el propio conjunto de evaluación, no una estimación de nada. El procedimiento:
- Parte las filas etiquetadas del conjunto held-out en dos mitades, por
game_idy nunca por posición (la sección «Fuga de datos» explica por qué). Aquí salen 3 793 filas / 2 455 partidas en la mitadtuney 3 660 filas / 2 368 partidas en la mitadscore. - En la mitad
tune, barre el umbral y quédate con el que maximiza F1. Aquí sale 0,0663. - Publica el F1 medido en la mitad
score, que no ha visto ningún umbral. Aquí sale 0,180.
Publica también lo que se mueve entre las dos mitades: el mismo umbral da 0,171 en tune y 0,180
en score (con el checkpoint full de la lección 1 fue al revés, 0,184 y 0,179). Importa el
tamaño, no el signo: con 144 errores en 3 660 filas, un punto de F1 separa dos mitades del mismo
conjunto sin cambiar nada. Elegir el umbral en las filas que luego se puntúan convierte esa
oscilación en optimismo garantizado. Es la misma disciplina que separar validación y test al
ajustar hiperparámetros.
El reparto del umbral, en código
def threshold_half(game_id: int, seed: int) -> str: """Which half of the labelled rows a game falls on: ``tune`` or ``score``.
By game and never by position, exactly like ``rukh.data.labels.game_split`` and for exactly the same reason: two positions of the same game are one move apart, so choosing a threshold on one and scoring it on the other would tune on the rows being scored through the back door. CRC-32 of a salted string, so the halves are the same in every process and every run, and the salt keeps this split independent of the train/val one. """ return TUNE_HALF if zlib.crc32(f"{seed}:threshold:{game_id}".encode()) % 2 == 0 else SCORE_HALF
def threshold_halves(games: Sequence[int], seed: int) -> np.ndarray: """Boolean mask of the rows that belong to the ``tune`` half, one entry per row.""" return np.array([threshold_half(int(game), seed) == TUNE_HALF for game in games], dtype=bool)Es el reparto de la lección 6 con % 2, y lo que lo hace correcto es la palabra threshold en
medio de la cadena: una sal. Sin ella, este sorteo y el de entrenamiento/validación serían el
mismo CRC-32 de la misma semilla y el mismo id, y dos repartos que deben ser independientes
saldrían correlacionados.
def f1_at(truth: Sequence[int], scores: Sequence[float], threshold: float) -> float: """F1 of ``scores >= threshold`` against ``truth``.""" calls = (np.asarray(scores, dtype=np.float64) >= threshold).astype(np.int64) return classification(truth, calls).f1
def best_threshold( truth: Sequence[int], scores: Sequence[float], include: float = 0.5) -> tuple[float, float]: """The threshold that maximises F1 on these rows, and the F1 it reaches.
The candidates are the distinct scores (every threshold that can change a single call) plus ``include``, the configured one, so the answer is never *worse* than the fixed threshold on the rows it was chosen on — which is the only guarantee a sweep can honestly give, and the reason the reported number is measured somewhere else. Ties go to the lowest threshold: on a rare class, the lower one flags more and is the less lucky of the two. """ labels = np.asarray(truth, dtype=np.int64) values = np.asarray(scores, dtype=np.float64) if len(labels) != len(values): raise ValueError(f"{len(labels)} labels against {len(values)} scores") positives = int(np.sum(labels == 1)) if not len(labels) or not positives: return float(include), f1_at(labels, values, include) order = np.argsort(-values, kind="stable") ordered = values[order] hits = np.cumsum(labels[order] == 1) seen = np.arange(1, len(labels) + 1, dtype=np.float64) last = np.append(ordered[1:] != ordered[:-1], True) recall = hits[last] / positives precision = hits[last] / seen[last] total = precision + recall f1 = np.where(total > 0, 2 * precision * recall / np.where(total > 0, total, 1.0), 0.0) candidates = [ (float(value), float(score)) for value, score in zip(ordered[last], f1, strict=True) ] candidates.append((float(include), f1_at(labels, values, include))) candidates.sort(key=lambda pair: (-pair[1], pair[0])) return candidates[0]El barrido tiene dos decisiones que no se ven a simple vista. Los candidatos son las puntuaciones
distintas y no una rejilla de 0.01 a 0.99, que se saltaría el óptimo cuando todas las
probabilidades viven por debajo de 0,31: entre dos valores que el modelo produjo, la decisión de
todas las filas es la misma, así que probar solo esos valores hace el barrido exacto. Y el cálculo
es una acumulada: ordenando por puntuación descendente, hits son los aciertos acumulados y seen
las marcadas, así que precisión y exhaustividad salen para todos los umbrales con un solo
argsort. La máscara last se queda con el final de cada grupo de puntuaciones iguales, porque un
umbral no puede separar dos filas con la misma probabilidad.
Van enteras, porque cada comprobación tapa una forma distinta de engañarse. Primero la que cuenta aciertos, donde solo hay que mirar los ceros de los casos degenerados:
def classification( truth: Sequence[int], predicted: Sequence[int], name: str = "blunder") -> ClassificationResult: """Precision, recall and F1 of ``predicted`` against ``truth`` (both 0/1 per row).""" if len(truth) != len(predicted): raise ValueError(f"{len(truth)} labels against {len(predicted)} predictions") labels = np.asarray(truth, dtype=np.int64) calls = np.asarray(predicted, dtype=np.int64) hits = int(np.sum((labels == 1) & (calls == 1))) false_positives = int(np.sum((labels == 0) & (calls == 1))) false_negatives = int(np.sum((labels == 1) & (calls == 0))) precision = hits / (hits + false_positives) if hits + false_positives else 0.0 recall = hits / (hits + false_negatives) if hits + false_negatives else 0.0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 correct = int(np.sum(labels == calls)) return ClassificationResult( name=name, items=len(labels), positives=int(np.sum(labels == 1)), predicted=int(np.sum(calls == 1)), true_positives=hits, false_positives=false_positives, false_negatives=false_negatives, precision=precision, recall=recall, f1=f1, accuracy=correct / len(labels) if len(labels) else 0.0, )La precisión de cero marcas es cero partido por cero, y hay dos convenciones: cero o nan. Aquí es
cero porque el caso ocurre de verdad (el umbral fijo de 0,5 no marca ninguna fila) y un 0,0 % junto
a las 0 marcadas que lo explican se lee mejor que un nan. Ese es el 0,0000 del umbral fijo.
def roc_auc(truth: Sequence[int], scores: Sequence[float]) -> float | None: """Area under the ROC curve, as the rank sum of the positives (Mann-Whitney U).
Written out rather than imported: ``scipy`` and ``scikit-learn`` are not dependencies. Ties are handled by the average ranks of ``ranks``, which is what gives a constant predictor exactly 0.5 instead of 0 or 1 depending on how the sort happened to break the tie. """ labels = np.asarray(truth, dtype=np.int64) values = np.asarray(scores, dtype=np.float64) if len(labels) != len(values): raise ValueError(f"{len(labels)} labels against {len(values)} scores") positives = int(np.sum(labels == 1)) negatives = int(np.sum(labels == 0)) if not positives or not negatives: return None rank = np.asarray(ranks(values.tolist()), dtype=np.float64) rank_sum = float(rank[labels == 1].sum()) return (rank_sum - positives * (positives + 1) / 2.0) / (positives * negatives)El ROC AUC sale de la identidad de Mann-Whitney en vez de recorrer una curva: la suma de los rangos
de los positivos, menos la mínima posible (n(n+1)/2), dividida por el número de parejas
positivo-negativo, es la proporción de parejas bien ordenadas. Los rangos medios hacen que un
predictor constante saque 0,5 y no 0 o 1 según cómo la ordenación rompiera los empates. Sin una de
las dos clases el ROC AUC no está definido, y el informe escribe n/a.
def average_precision(truth: Sequence[int], scores: Sequence[float]) -> float | None: """Area under the precision-recall curve, summed over the steps of the recall.
``sum (R_n - R_{n-1}) * P_n`` over the distinct scores, taken from the highest down. Rows that share a score are one step, because a threshold cannot separate them; without that, a predictor that outputs the same number everywhere would score 1.0 by sorting luck. A random ranking scores the base rate, which is why this is the summary that means something when the positive class is rare. """ labels = np.asarray(truth, dtype=np.int64) values = np.asarray(scores, dtype=np.float64) if len(labels) != len(values): raise ValueError(f"{len(labels)} labels against {len(values)} scores") positives = int(np.sum(labels == 1)) if not positives: return None order = np.argsort(-values, kind="stable") ordered = values[order] hits = np.cumsum(labels[order] == 1) seen = np.arange(1, len(labels) + 1, dtype=np.float64) last = np.append(ordered[1:] != ordered[:-1], True) # the end of each group of equal scores recall = hits[last] / positives precision = hits[last] / seen[last] steps = np.diff(np.concatenate(([0.0], recall))) return float(np.sum(steps * precision))La misma acumulada que best_threshold, y la misma máscara de empates. Sin ella, un predictor que
devuelve el mismo número en todas las filas sacaría 1,0 si el orden estable pusiera los positivos
primero: un número perfecto del que nadie sospecharía. Tratar cada grupo de puntuaciones iguales
como un solo paso lo deja en la tasa base, que es la respuesta correcta.
def ranking(truth: Sequence[int], scores: Sequence[float], name: str = "encoder") -> RankingResult: """Everything about a detector's ranking that no threshold can change.""" labels = np.asarray(truth, dtype=np.int64) values = np.asarray(scores, dtype=np.float64) positives = int(np.sum(labels == 1)) return RankingResult( name=name, items=len(labels), positives=positives, base_rate=positives / len(labels) if len(labels) else 0.0, roc_auc=roc_auc(labels, values), average_precision=average_precision(labels, values), min_score=float(values.min()) if len(values) else None, max_score=float(values.max()) if len(values) else None, mean_score=float(values.mean()) if len(values) else None, )Las dos métricas sin umbral más el rango de las puntuaciones, en un registro que responde «¿sabe algo esta cabeza?» sin que nadie haya elegido un punto de operación.
def _finite(*arrays: np.ndarray) -> np.ndarray: """Mask of the rows where every array is a real number.""" mask = np.ones(len(arrays[0]), dtype=bool) for array in arrays: mask &= np.isfinite(array) return mask
def measure_blunder( truth: np.ndarray, probability: np.ndarray, baseline_calls: np.ndarray, games: Sequence[int], cfg: EncoderEvalConfig, result: EncoderResult,) -> EncoderResult: """Score the blunder detector at an operating point that was not chosen on these rows.
The labelled rows are cut in two **by game**: ``tune`` chooses the threshold that maximises F1, ``score`` is where the F1, the precision and the recall that go into the report and into the ``GOAL.md`` comparison are measured. The baseline is measured on the same ``score`` rows; it has no threshold to tune, so the comparison hands the model a sweep the rule cannot have, and the report says so rather than leaving the reader to notice.
When the split cannot give both halves a blunder — a handful of games, or every blunder in one of them — there is no honest way to hold rows back: the threshold is then chosen and measured on the same rows, ``threshold_split_degenerate`` is set and the report prints the warning next to the number. """ result.blunder_items = len(truth) result.blunder_positives = int(np.sum(truth == 1)) result.blunder_base_rate = float(np.mean(truth == 1)) if len(truth) else None result.threshold_fixed = float(cfg.threshold) tune = threshold_halves(games, cfg.seed) score = ~tune identifiers = np.asarray(games) usable = bool( truth[tune].sum() and truth[score].sum() and (truth[tune] == 0).any() and (truth[score] == 0).any() ) if not usable: result.threshold_split_degenerate = True tune = np.ones(len(truth), dtype=bool) score = np.ones(len(truth), dtype=bool) result.tune_items = int(tune.sum()) result.score_items = int(score.sum()) result.tune_games = int(len(np.unique(identifiers[tune]))) result.score_games = int(len(np.unique(identifiers[score]))) threshold, tune_f1 = best_threshold(truth[tune], probability[tune], cfg.threshold) result.threshold_tuned = threshold result.tune_f1 = tune_f1 result.encoder_blunder = classification( truth[score], (probability[score] >= threshold).astype(np.int64), "encoder" ) result.encoder_blunder_fixed = classification( truth[score], (probability[score] >= cfg.threshold).astype(np.int64), "encoder" ) result.encoder_blunder_ranking = ranking(truth[score], probability[score], "encoder") result.heuristic_blunder = classification(truth[score], baseline_calls[score], "heuristic") margin = 100.0 * (result.encoder_blunder.f1 - result.heuristic_blunder.f1) result.f1_margin = margin result.meets_goal = margin >= GOAL_MARGIN return resultEl procedimiento de tres pasos, en código: todas las líneas que producen números publicados usan
[score], y la que elige el umbral usa [tune]. La comprobación usable exige que cada mitad
tenga al menos un error y al menos una jugada tranquila; si no, el código usa todas las filas para
las dos cosas y lo marca con threshold_split_degenerate, y el informe avisa de que el F1 es una
cota superior. margin va en puntos porque el criterio del hito está escrito en puntos, y así nadie
compara 0,092 con 5.
La heurística de material y movilidad
El baseline cabe en una página: el material de toda la vida (peón 1, caballo y alfil 3, torre 5,
dama 9) más la movilidad, pasado a la misma escala acotada que la etiqueta. Para juzgar una jugada,
se juega en un tablero de python-chess y se deja que el rival conteste con su captura o promoción
más rentable, a un solo plyPly (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.; si el material del que movió cae un punto o más,
la heurística dice «error». Es el equivalente, en un clasificador de texto, a una regla de palabras
clave: tonta, rápida y sorprendentemente difícil de batir por mucho. El fichero abre declarando lo
que no sabe hacer:
"""The dumb baseline the encoder has to beat: material, mobility and a one-ply look at captures.
``GOAL.md`` asks for a blunder F1 five points above a heuristic, and the heuristic is only worthanything as a comparison if it is measured exactly like the model: same positions, same labels,same definition of a hit. So it lives here, next to the suite, and is deliberately kept assimple as a chess program can be:
* **material**: pawn 1, knight 3, bishop 3, rook 5, queen 9, king 0 — the values every beginner is taught, unchanged by the phase of the game;* **mobility**: how many legal moves each side has, worth ``MOBILITY_WEIGHT`` of a pawn each, which is what turns a pile of material into something that moves with the position;* **blunder**: the move that led here lost at least ``BLUNDER_MATERIAL`` point of net material, measured from the mover's point of view, by playing the move on a ``python-chess`` board and letting the opponent answer with its single most profitable capture or promotion.
What it cannot see------------------
Everything that is not material. The named example the tests use is **Byrne–Fischer, New York1956** (the "Game of the Century"), after 17.Kf1: Fischer played 17...Be6, offering the queen,and the game is a win for Black. The heuristic sees 18.Bxb6 taking a queen for nothing and callsone of the most famous moves in chess a blunder. A positional sacrifice is invisible to it, andthat is the point: a baseline that already understood sacrifices would not be a baseline.
It is also blind in the other direction. The opponent's reply is searched one ply deep and onlyover captures and promotions, so our own recapture is never counted: an equal trade whoserecapture comes two plies later reads as a loss. And a piece that was already hanging before themove is charged again to every quiet move that follows it, because "before" is the material onthe board and not the best the mover could have kept. Both are written down here rather thanfixed, because fixing them would make the baseline a small engine instead of a floor."""La sección «What it cannot see» es la decisión de diseño del fichero: el baseline es malo a propósito, y sus cegueras se escriben en vez de arreglarse. Uno que ya entendiera los sacrificios sería un competidor, y el margen dejaría de significar «el modelo aprendió algo más que contar piezas». Lo innegociable es medirlo igual: mismas filas, misma definición de acierto, mismo umbral de 100 centipeones en la etiqueta.
from __future__ import annotations
import mathfrom collections.abc import Iterator
import chessfrom pydantic import BaseModel, ConfigDictSin torch: es ajedrez y aritmética, así que corre en la CI sin GPU y sus veredictos se cachean
sin pensar en pesos.
PIECE_VALUES: dict[chess.PieceType, float] = { chess.PAWN: 1.0, chess.KNIGHT: 3.0, chess.BISHOP: 3.0, chess.ROOK: 5.0, chess.QUEEN: 9.0, chess.KING: 0.0,}"""The beginner's table, in pawns. The king is worth nothing: it is never captured."""
MOBILITY_WEIGHT = 0.05"""Pawns per legal move of difference; twenty extra moves are worth one pawn."""
BLUNDER_MATERIAL = 1.0"""Net material a move has to lose, in pawns, before the baseline calls it a blunder."""
VALUE_SCALE = 4.0"""``tanh(score / 4)`` in pawns is exactly ``tanh(cp / 400)``, the label's own scale."""
GAME_OF_THE_CENTURY = "r3r1k1/pp3pbp/1qp3p1/2B5/2BP2b1/Q1n2N2/P4PPP/3R1K1R b - - 3 17""""Byrne–Fischer, New York 1956, after 17.Kf1: the position of the positional sacrifice."""
POSITIONAL_SACRIFICE = "g4e6""""17...Be6, the queen offer the heuristic reads as a nine-point blunder."""VALUE_SCALE = 4.0 es la otra mitad de «medirlo igual»: tanh(puntuación / 4) en peones es
tanh(cp / 400) en centipeones, la escala de la etiqueta. Y GAME_OF_THE_CENTURY da a la ceguera
del baseline una posición escrita en el código, con su test: «no ve los sacrificios» pasa de frase
a propiedad comprobable.
class Verdict(BaseModel): """What the baseline says about one move: a value, a material loss and a verdict."""
model_config = ConfigDict(extra="forbid")
move: str blunder: bool loss: float """Net material the mover lost, in pawns; negative when the move won material.""" material_before: float material_after: float """Material after the move and the opponent's most profitable capture.""" value: float """Value of the resulting position, ``tanh(score / 4)`` from White's point of view."""El veredicto trae los dos materiales además de la diferencia: sin ellos, un loss de 9,0 no se
puede auditar.
def material(board: chess.Board) -> float: """Material on the board in pawns, from White's point of view.""" total = 0.0 for piece_type, value in PIECE_VALUES.items(): if not value: continue total += value * len(board.pieces(piece_type, chess.WHITE)) total -= value * len(board.pieces(piece_type, chess.BLACK)) return total
def material_for(board: chess.Board, color: chess.Color) -> float: """Material from ``color``'s point of view: its own minus the opponent's.""" return material(board) if color == chess.WHITE else -material(board)
def _moves_for(board: chess.Board, color: chess.Color) -> int: """How many legal moves ``color`` has, whoever is to move.""" if board.turn == color: return board.legal_moves.count() swapped = board.copy(stack=False) swapped.turn = color swapped.ep_square = None return swapped.legal_moves.count()
def mobility(board: chess.Board) -> int: """White's legal moves minus Black's, counted on the same position.""" return _moves_for(board, chess.WHITE) - _moves_for(board, chess.BLACK)
def score(board: chess.Board, mobility_weight: float = MOBILITY_WEIGHT) -> float: """Material plus weighted mobility, in pawns, from White's point of view.""" return material(board) + mobility_weight * mobility(board)_moves_for tiene la única decisión del bloque. board.legal_moves solo enumera las jugadas del
bando que mueve, así que para contar las del otro se cambia el turno sobre una copia. Contar solo
las del que mueve haría depender la movilidad de la paridad del ply, y la serie de valores de una
partida sería un zigzag. ep_square = None borra la casilla de captura al paso, que al cambiar el
turno a mano sería ilegal.
def value(board: chess.Board, mobility_weight: float = MOBILITY_WEIGHT) -> float: """The baseline's value of a position, on the label's own ``tanh`` scale.""" return math.tanh(score(board, mobility_weight) / VALUE_SCALE)
def value_of(fen: str, mobility_weight: float = MOBILITY_WEIGHT) -> float: """``value`` of a FEN (four fields or six).""" return value(chess.Board(fen), mobility_weight)def _material_moves(board: chess.Board) -> Iterator[chess.Move]: """The only replies that can change the material count: captures and promotions.""" yield from board.generate_legal_captures() for move in board.legal_moves: if move.promotion is not None and not board.is_capture(move): yield move
def worst_material(board: chess.Board, color: chess.Color) -> float: """``color``'s material after the single most profitable reply the opponent has.
One ply, captures and promotions only: no recapture, no threat, no check. That is the whole search, and the docstring of the module says what it costs. """ worst = material_for(board, color) for reply in _material_moves(board): child = board.copy(stack=False) child.push(reply) worst = min(worst, material_for(child, color)) return worst_material_moves enumera solo lo que puede cambiar el material en un ply, capturas y promociones:
dos o tres de las treinta jugadas legales típicas, sin perder nada. worst_material empieza en el
material actual y toma el mínimo sobre las respuestas. Aquí están las dos cegueras del docstring: no
ve la recaptura propia, porque la búsqueda acaba en un ply, y cobra otra vez una pieza que ya
colgaba antes de mover, porque el «antes» es el material del tablero y no lo mejor que el jugador
podía conservar.
def judge( fen: str, move: str, threshold: float = BLUNDER_MATERIAL, mobility_weight: float = MOBILITY_WEIGHT,) -> Verdict: """Judge ``move`` played in ``fen``: how much material it lost and whether that is a blunder.
``fen`` is the position **before** the move, which is the only way material can be compared; the labels of ``rukh.data.labels`` judge the same move from the same side. """ board = chess.Board(fen) mover = board.turn before = material_for(board, mover) board.push(chess.Move.from_uci(move)) after = worst_material(board, mover) loss = before - after return Verdict( move=move, blunder=loss >= threshold, loss=loss, material_before=before, material_after=after, value=value(board, mobility_weight), )mover = board.turn antes del push decide el signo de todo: después de empujar la jugada, el
turno es del otro. loss = before - after es una resta y la etiqueta usaba una suma, sin
contradicción: aquí los dos números ya están desde el punto de vista del que movió; en la etiqueta,
el segundo venía del lado del rival.
La comparación tiene dos asimetrías, y el informe dice las dos. En contra del modelo: la heurística
recibe la posición anterior y la jugada, y el encoder de casillas solo ve el FEN resultante (el
esquema de jugadas lleva la línea dentro). Aun así ganaron los dos: el de casillas por 5,7 puntos,
el de jugadas por 9,2. A favor del modelo: la heurística es una regla de sí o no sin umbral que
ajustar, así que se enfrenta un modelo en su mejor punto de operación contra una regla en el único
que tiene. El desglose del afinado last-n de jugadas, sobre la mitad score:
| Detector | Punto de operación | Filas | Errores | Marcadas | Precisión | Exhaustividad | F1 |
|---|---|---|---|---|---|---|---|
| encoder | ajustado, p >= 0.0663 (en tune) |
3 660 | 144 | 488 | 11,7 % | 39,6 % | 18,0 % |
| encoder | fijo, p >= 0.5 |
3 660 | 144 | 0 | 0,0 % | 0,0 % | 0,0 % |
| heurística | regla, sin umbral que ajustar | 3 660 | 144 | 2 359 | 4,7 % | 77,1 % | 8,9 % |
La heurística marca 2 359 posiciones de 3 660: grita «error» en dos de cada tres jugadas y encuentra casi todos los errores porque marca casi todo. El encoder gana por el otro lado: marca cinco veces menos y acierta en el 11,7 % contra el 4,7 %. Son perfiles opuestos, y por eso el titular lleva la precisión y la exhaustividad al lado del F1.
Sin adornos: de cada diez posiciones que el modelo marca, casi nueve no son errores, y de cada cinco errores reales se le escapan tres. Como producto todavía no sirve. Como medida, dobla a una heurística que sí entiende de material, que es lo que el módulo quería demostrar: +9,2 puntos contra un listón de cinco. Batir al baseline no es estar listo, y el informe lo dice.
Qué no puede ver, con sus tests
El ejemplo que lleva escrito el código es la partida del siglo: Donald Byrne contra Robert Fischer,
Nueva York, 1956, con Fischer de trece años. Después de 17.Rf1 (17.Kf1 en notación inglesa), Fischer
jugó 17…Ae6 (g4e6, 17…Be6), ofreciendo la dama, y acabó ganando. La heurística ve que las
blancas pueden llevarse la dama con 18.Axb6, calcula una pérdida de nueve puntos y llama error
garrafal a una de las jugadas más famosas de la historia. La aritmética es correcta; un sacrificio
posicional es invisible para algo que solo cuenta piezas. Y eso está en un test:
def test_the_baseline_cannot_see_a_positional_sacrifice() -> None: """Byrne-Fischer 1956, 17...Be6: the Game of the Century, scored as a nine-point blunder.
This is the documented blind spot of the baseline and the reason the encoder is expected to beat it: material plus one ply of captures cannot tell a queen sacrifice from a queen loss. """ verdict = judge(GAME_OF_THE_CENTURY, POSITIONAL_SACRIFICE) assert verdict.blunder is True assert verdict.loss == pytest.approx(9.0) # Black is a pawn up before the move and eight points down after 18.Bxb6, so the baseline # cannot help calling it: the compensation is not on the board yet. Fischer won the game. assert verdict.material_before == pytest.approx(1.0) assert verdict.material_after == pytest.approx(-8.0)Es un test que fija un comportamiento equivocado a propósito. El baseline no debe mejorar sin que alguien lo decida, porque el margen del hito se mide contra él: si alguien añadiera una búsqueda de dos plies, este test fallaría y obligaría a volver a medir el margen.
HANGING_QUEEN = "r1bqkbnr/pppp1ppp/2n5/4p2Q/4P3/8/PPPP1PPP/RNB1KBNR w KQkq - 2 3""""After 1.e4 e5 2.Qh5 Nc6: 3.Qxf7+ wins a pawn and loses the queen to 3...Kxf7."""
def test_the_starting_position_is_perfectly_balanced() -> None: board = chess.Board() assert material(board) == 0.0 assert mobility(board) == 0 # twenty moves each assert value(board) == 0.0
def test_material_is_counted_from_the_asking_side() -> None: board = chess.Board("4k3/8/8/8/8/8/8/3QK3 w - - 0 1") assert material(board) == PIECE_VALUES[chess.QUEEN] assert material_for(board, chess.WHITE) == 9.0 assert material_for(board, chess.BLACK) == -9.0
def test_mobility_moves_the_score_without_moving_the_material() -> None: # A rook on an open file has more moves than one boxed in, with the same pieces on the board. open_file = chess.Board("4k3/8/8/8/8/8/8/R3K3 w - - 0 1") boxed_in = chess.Board("4k3/8/8/8/8/8/R7/4K3 w - - 0 1") assert material(open_file) == material(boxed_in) assert score(open_file) != score(boxed_in)El tercero comprueba que la movilidad hace algo: sin él, un MOBILITY_WEIGHT a cero por accidente
pasaría inadvertido.
def test_the_baseline_flags_a_hanging_queen() -> None: verdict = judge(HANGING_QUEEN, "h5f7") assert verdict.blunder is True assert verdict.loss == pytest.approx(8.0) # a queen for a pawn assert verdict.material_before == pytest.approx(0.0)
def test_a_quiet_developing_move_is_not_a_blunder() -> None: assert judge(HANGING_QUEEN, "g1f3").blunder is False assert judge(HANGING_QUEEN, "g1f3").loss == pytest.approx(0.0)
def test_an_even_trade_is_not_a_blunder() -> None: # Ruy Lopez, 4.Bxc6: a bishop for a knight, recaptured at once. The one ply of replies is # exactly what keeps an even trade off the blunder list. board = chess.Board() for san in ("e4", "e5", "Nf3", "Nc6", "Bb5", "Nf6"): board.push_san(san) verdict = judge(board.fen(), "b5c6") assert verdict.blunder is False assert verdict.loss == pytest.approx(0.0)
def test_losing_a_piece_for_a_pawn_is_a_blunder() -> None: # 1.e4 e5 2.Nf3 Nc6 3.Nxe5?? Nxe5: the knight wins a pawn and is lost for nothing. board = chess.Board() for san in ("e4", "e5", "Nf3", "Nc6"): board.push_san(san) verdict = judge(board.fen(), "f3e5") assert verdict.blunder is True assert verdict.loss == pytest.approx(2.0) # a knight for a pawnLos cuatro casos que definen la regla. El del cambio igualado es el interesante: tras 4.Axc6 en la Apertura Española, la respuesta de un ply es la recaptura del rival, así que la pérdida sale cero. Cuando la recaptura es inmediata, el ply la ve; cuando llega dos plies después, no.
def test_the_value_is_on_the_label_s_own_scale() -> None: # The labels are tanh(cp / 400) and the baseline is tanh(pawns / 4), so four pawns of # advantage (400 centipawns) read as tanh(1) on both sides of the comparison. four_pawns_up = chess.Board("4k3/8/8/8/8/8/P7/1N2K3 w - - 0 1") assert value(four_pawns_up, mobility_weight=0.0) == pytest.approx(math.tanh(1.0), abs=1e-12) assert value_of(four_pawns_up.fen(), 0.0) == value(four_pawns_up, 0.0)
def test_a_promotion_is_material_even_when_nothing_is_captured() -> None: # Black to move cannot stop the pawn: the mover's quiet king move hands White a queen. board = chess.Board("8/P7/8/8/8/8/8/k5K1 b - - 0 1") verdict = judge(board.fen(), "a1b1") assert verdict.blunder is True assert verdict.loss == pytest.approx(8.0) # a queen appears, minus the pawn that became itEl primero ata las dos escalas (cuatro peones de ventaja son tanh(1) en las dos), y es lo que hace
comparables las correlaciones del modelo y del baseline. El segundo cubre la rama de las
promociones, la que se olvida quien piensa que solo las capturas mueven el material.
Pearson y Spearman, y por qué las dos
Para el valor no hay umbral que elegir: es una regresión, y lo que se mide es si el número predicho acompaña al real.
Pearson mide si los valores se alinean en una recta, y es sensible a la escala: si predices sistemáticamente la mitad de lo que hay, lo nota. Spearman es Pearson calculado sobre los rangos, así que solo mide si el orden coincide. Es la diferencia entre el podio y los cronos de una carrera: Spearman pregunta si has acertado quién sube a cada escalón; Pearson, además, si los tiempos se parecen a los reales. Puedes clavar el podio y fallar todos los cronos por un minuto.
Discrepan cuando el orden está bien y la escala mal, algo que un tanh acotado favorece: las
posiciones extremas se aplastan contra ±1 y la relación deja de ser lineal aunque el orden sea
perfecto. Solo Pearson haría parecer peor un modelo que ordena bien; solo Spearman escondería una
barra de evaluación que marca 0,3 donde debería marcar 0,8. Por eso van las dos, y el criterio del
hito se lee sobre Spearman, como en un buscador, donde lo que importa es el orden de los
resultados. Las tres funciones están escritas a mano:
def pearson(x: Sequence[float], y: Sequence[float]) -> float | None: """Pearson's correlation, or ``None`` when either side is constant (it is undefined).""" if len(x) != len(y): raise ValueError(f"{len(x)} values against {len(y)}") if len(x) < 2: return None first = np.asarray(x, dtype=np.float64) second = np.asarray(y, dtype=np.float64) first = first - first.mean() second = second - second.mean() denominator = math.sqrt(float(first @ first) * float(second @ second)) if denominator == 0.0: return None return float(first @ second / denominator)Pearson es el coseno del ángulo entre los dos vectores centrados; restar la media primero es más
estable que la fórmula de sumas de productos, que resta dos números grandes casi iguales. La
correlación de algo que no varía no está definida, y el None lo respeta: un cero se leería como
«el modelo no sabe nada», y un null dice «el modelo devolvió siempre lo mismo», que es otro
diagnóstico.
def ranks(values: Sequence[float]) -> list[float]: """Average ranks, one per value: tied values share the mean of the ranks they occupy.""" order = sorted(range(len(values)), key=lambda index: values[index]) out = [0.0] * len(values) start = 0 while start < len(order): stop = start while stop + 1 < len(order) and values[order[stop + 1]] == values[order[start]]: stop += 1 shared = (start + stop) / 2.0 + 1.0 for position in range(start, stop + 1): out[order[position]] = shared start = stop + 1 return out
def spearman(x: Sequence[float], y: Sequence[float]) -> float | None: """Spearman's rank correlation: Pearson over average ranks, written out by hand.""" if len(x) != len(y): raise ValueError(f"{len(x)} values against {len(y)}") if len(x) < 2: return None return pearson(ranks(list(x)), ranks(list(y)))La única sutileza de un Spearman a mano: los empates reciben la media de los rangos que abarcan. Darles rangos consecutivos inventaría un orden que los datos no tienen, y aquí abundan, porque la heurística da el mismo valor a todas las posiciones con igual material y movilidad.
def correlation( predicted: Sequence[float], target: Sequence[float], name: str, target_name: str = "tanh(cp / value_scale)",) -> CorrelationResult: """Both correlations of one predictor against the bounded score, in one record.""" return CorrelationResult( name=name, items=len(predicted), pearson=pearson(predicted, target), spearman=spearman(predicted, target), target=target_name, )Las dos siempre juntas, en un registro que dice contra qué se midieron: tanh(cp / 400), la escala
con la que se entrena la cabeza, y nunca el cp en bruto, donde un mate forzado vale ±9 999
centipeones y media docena de filas así decidirían el Pearson del conjunto entero.
def measure( items: pl.DataFrame, predictions: dict[str, np.ndarray], baseline: dict[str, np.ndarray], cfg: EncoderEvalConfig, result: EncoderResult,) -> EncoderResult: """Fill ``result`` with every metric the two predictors produced on the same rows.""" # The value head is trained on ``tanh(cp / value_scale)`` and is measured against it. Raw # ``cp`` would put a mate at +-9 99x against an output that cannot leave (-1, 1), and those # few rows would set the Pearson of the whole set on their own. bounded = np.tanh(items["cp"].to_numpy().astype(np.float64) / float(cfg.labels.value_scale)) labels = items["blunder"].cast(pl.Float64).fill_null(np.nan).to_numpy().astype(np.float64) result.items = items.height scored = _finite(labels, baseline["blunder"]) result.blunder_items = int(scored.sum()) if result.blunder_items: measure_blunder( labels[scored].astype(np.int64), predictions["blunder"][scored].astype(np.float64), baseline["blunder"][scored].astype(np.int64), items["game_id"].to_numpy()[scored], cfg, result, ) if items.height: target = f"tanh(cp / {cfg.labels.value_scale:g})" result.encoder_value = correlation(predictions["value"], bounded, "encoder", target) result.heuristic_value = correlation(baseline["value"], bounded, "heuristic", target) headline = result.encoder_value.spearman result.value_correlation_meets_goal = ( None if headline is None else headline >= GOAL_VALUE_CORRELATION ) truth_result = items["result_class"].to_numpy().astype(np.int64) result.result_accuracy = float(np.mean(predictions["result"] == truth_result)) if result.meets_goal is not None and result.value_correlation_meets_goal is not None: result.meets_all_goals = result.meets_goal and result.value_correlation_meets_goal return resultscored = _finite(...) garantiza que modelo y baseline se saltan las mismas filas; hoy coinciden,
pero calcularlo en vez de asumirlo lo mantiene cierto si mañana una de las dos gana otro motivo para
faltar. La detección de errores se mide sobre las filas etiquetadas y la correlación sobre todas,
porque la etiqueta de error solo existe para las que tienen predecesor. De ahí los denominadores del
informe: 10 000 posiciones, 7 453 con etiqueta de error y 3 660 de ellas en la mitad score. Y
meets_all_goals solo se calcula si los dos criterios se pudieron medir.
El criterio de valor no se cumple, y esta es la cifra
| Esquema | Spearman | Pearson | Listón del hito |
|---|---|---|---|
moves · full |
0,407 | 0,442 | ≥ 0,80 → no |
moves · last-n |
0,520 | 0,648 | ≥ 0,80 → no |
squares |
0,422 | 0,688 | ≥ 0,80 → no |
El mejor Spearman publicado al cerrar el hito es 0,52 contra un listón de 0,80: el criterio de valor no se cumple, y se publica sin cumplir, como manda el plan del hito.
Mira la pareja que importa: squares tiene mejor Pearson y peor Spearman que moves · last-n
(0,688 contra 0,648, y 0,422 contra 0,520). Una entrada gana en escala y pierde en orden, y como son
dos representaciones distintas y no dos tiradas, eso es una pista y no ruido.
Las sospechas naturales apuntaban a los datos: cuatro mil pasos de afinado, que son un presupuesto
y no una convergencia; pocas etiquetas (488 159 posiciones con Stockfish, aunque la curva de la
lección 11 descarta esta: con el 25 % el error del valor es el mismo); y la etiqueta
tanh(cp/400), que aplasta contra el cero la franja de ±100 centipeones donde están casi todas las
posiciones de club. Ninguna miraba la pérdida.
// Ejercicio 02Multiplica todas las probabilidades por tres
Las probabilidades de la cabeza de error van de 0,0020 a 0,3102. Imagina que, sin tocar un peso, multiplicas todas por tres antes de evaluar. ¿Qué pasa con el F1 en el umbral fijo de 0,5? ¿Y con el ROC AUC, la precisión media y el F1 en el umbral ajustado? ¿Qué te dice eso sobre qué mide cada número?
// SoluciónVer la solución
El F1 en 0,5 deja de ser cero: ahora el máximo es 0,93, así que hay posiciones por encima de 0,5 y se marca alguna. El número cambia sin que el modelo haya cambiado, lo que demuestra que ese F1 medía la escala del sigmoide y no la representación.
ROC AUC y precisión media no se mueven ni una milésima: multiplicar por tres no cambia ningún
orden, y las dos solo miran el orden. El F1 ajustado tampoco: el barrido en tune encontraría
0,199 en vez de 0,0663 y marcaría las mismas 488 posiciones, porque los candidatos son las
propias puntuaciones y se han movido todas igual. Lo que depende de un umbral fijo mide
calibración; lo que depende solo del orden mide lo que la cabeza sabe.
La pérdida no medía lo que el criterio mide
Si una entrada gana en escala y pierde en orden, la pregunta es qué está minimizando la pérdida, y
la respuesta está en una línea de MultiHead.loss de la lección 4:
"value": F.mse_loss(outputs["value"], targets["value"].to(outputs["value"].dtype)),F.mse_loss minimiza la distancia entre cada predicción y su etiqueta; el criterio puntúa con
Spearman, que solo mira el orden. En lenguaje corriente las dos cosas se dicen igual, «acertar el
valor», y por eso el fallo es invisible: una pérdida y una métrica que se describen con las mismas
palabras pueden tener mínimos distintos. Una predicción lejísimos de todas las etiquetas que las
ordena perfectamente saca Spearman 1 y un MSE malísimo; otra cerquísima que confunde dos saca un MSE
excelente y pierde orden. Con capacidad de sobra coincidirían; un modelo pequeño tiene que elegir
dónde equivocarse.
El arreglo es una pérdida que mire pares: si la etiqueta dice que i vale más que j, la
predicción debería ponerlas en ese orden, y el voto pesa según lo lejos que estén las etiquetas. El
código es del tag p4: llegó después de cerrar el hito.
def pairwise_rank_loss(pred: Tensor, target: Tensor, margin: float = 0.0) -> Tensor: """A differentiable stand-in for "did the ordering come out right?".
Every pair in the batch votes: when ``target[i] > target[j]`` the prediction should put ``i`` above ``j`` too. The vote is a logistic on the predicted difference, weighted by how far apart the targets are, so telling a winning position from a losing one matters more than splitting two equal ones -- which is also where a static evaluator has no chance without search, and where an unweighted ranking loss would spend most of its gradient.
Returns zero for a batch whose targets are all equal, which has no ordering to learn. """ diff_target = target.unsqueeze(1) - target.unsqueeze(0) diff_pred = pred.unsqueeze(1) - pred.unsqueeze(0) weight = diff_target.abs() sign = torch.sign(diff_target) total = weight.sum() if not bool(total > 0): return torch.zeros((), device=pred.device, dtype=pred.dtype) penalty = F.softplus(-(sign * diff_pred - margin)) return (weight * penalty).sum() / totalsrc/rukh/models/heads.pyde p4, no de p3
El peso |target_i − target_j| es la mitad del arreglo: sin él, casi todo el gradiente se iría en
separar posiciones que el motor separa por diez centipeones, justo donde una pasada sin búsqueda no
tiene nada que decir. F.softplus(-(sign · diff_pred)) es el sustituto suave de «¿salió bien el
orden?»: tiende a cero cuando el orden está bien y crece linealmente cuando está mal, algo que un
sign() a secas no permitiría derivar. Es la misma pérdida por pares, con otros nombres, que la de
un reward model en M5.
| Qué cambia | Pearson | Spearman | F1 de error |
|---|---|---|---|
nada (F.mse_loss sola) |
0,6476 | 0,5205 | 0,1804 |
| + término de ordenación | 0,6621 | 0,6395 | 0,1774 |
| + término de ordenación y el triple de red | 0,7261 | 0,6665 | 0,1855 |
Se esperaba ceder Pearson para ganar Spearman y subieron los dos: la pérdida anterior estaba peor alineada con la tarea en los dos ejes. Y la atribución: cambiar la pérdida valió +0,119 de Spearman y triplicar el modelo +0,027. Mirar qué optimizas rindió cuatro veces más que comprar más red, y fue mucho más barato.
0,6665 no significa «regular en todas partes»
Con la pérdida arreglada el criterio sigue sin cumplirse: 0,6665 frente a 0,80. La pregunta útil es dónde falla, y se responde cortando la métrica en vez de mirándola entera.
| Cómo de decidida está la posición | Posiciones | Spearman |
|---|---|---|
| menos de 50 cp (casi igualadas) | 6 099 (61 %) | 0,4625 |
| 50 a 150 cp | 2 775 (28 %) | 0,5844 |
| 150 a 400 cp | 496 (5 %) | 0,5196 |
| más de 400 cp (decididas) | 626 (6 %) | 0,8797 |
Donde la ventaja está clara, el encoder ya pasa el listón. Lo que hunde la media es el 61 % de posiciones casi igualadas: ordenar dos posiciones que el motor separa por menos de medio peón exige ver táctica, una secuencia concreta de capturas que cambia el signo, y una pasada hacia delante sin búsqueda no la tiene ni la va a tener por entrenar más.
Las dos lecturas del mismo 0,6665 llevan a acciones opuestas. «Regular en todo» dice entrena más. «Excelente en lo evidente, flojo en lo que decide» dice este enfoque tiene techo: cambia el enfoque o cambia el listón. La segunda es la verdadera, y solo aparece al cortar.
Fuga de datos: por qué el reparto va por partida
Una fuga de datosFuga de datosCualquier camino por el que información del conjunto de validación llega al de entrenamiento. No falla: los números mejoran, y se descubre cuando el modelo sale al mundo. En Rukh se evita partiendo por mes (enero entrena; de febrero, desde M2 parte 4, validan las primeras 100 000 partidas y el resto del mes entrena), por PuzzleId con semilla en los puzles, entrenando el BPE solo con datos de enero y, en M3, repartiendo la tabla de posiciones por game_id con un CRC-32 de la semilla y el id, nunca por posición: dos posiciones consecutivas de la misma partida se diferencian en una jugada. El umbral del detector se elige además en una mitad tune y se mide en otra score, partidas también por partida. es cualquier camino por el que información de
validación llega al entrenamiento. Nunca se manifiesta como un error: los números mejoran, las
curvas salen bonitas y el problema aparece cuando el modelo sale al mundo.
La lección 6 contó la de este módulo: dos posiciones consecutivas de la misma partida se diferencian
en una jugada, así que un reparto por filas evaluaría al modelo sobre posiciones que ya vio con un
cambio cosmético. Por eso el reparto va por game_id. Esta lección añade que el reparto del umbral
sigue la misma política: partido por posición, el umbral se elegiría sobre el ply 30 y se mediría
sobre el 31 de la misma partida, una fuga dentro de otra y mucho más fácil de no ver.
El orquestador y sus notas
def evaluate_encoder( ckpt: Path | str, cfg: EncoderEvalConfig, use_cache: bool = True, device: str | None = None, source: pl.DataFrame | None = None,) -> EncoderResult: """Measure one fine-tuned encoder; a missing label table becomes a note, never a crash.""" from rukh.train import load_heads, pick_device
path = Path(ckpt) where = device or cfg.device or pick_device() model, payload = load_heads(path, map_location=where) model = model.to(where).eval() log.info("evaluating %s on %s", path, where) notes: list[str] = [ "the blunder F1 of the encoder and of the material baseline are measured on the same " f"rows of the held-out '{cfg.split}' split, which is drawn by game_id, never by " f"position, and those rows are cut in two by game_id again: the '{TUNE_HALF}' half " f"chooses the threshold and the '{SCORE_HALF}' half is what gets reported" ] result = EncoderResult( stage=cfg.stage or path.parent.name, checkpoint=path.as_posix(), model_sha=file_sha(path), params=sum(parameter.numel() for parameter in model.parameters()), date=datetime.now(UTC).date().isoformat(), device=str(where), label_curve=label_curve_points(payload), config=cfg.model_dump(mode="json"), ) if not result.label_curve: notes.append( "the checkpoint carries no label-count curve: run `rukh train heads --curve` and " "evaluate one of its checkpoints to fill that table" )La primera nota se escribe antes de medir nada: describe el procedimiento, así que no puede depender de cómo salgan los resultados. La de la curva ausente no dice solo «falta la curva», dice qué orden ejecutar para que deje de faltar.
try: items = build_items(cfg, source) except FileNotFoundError as exc: notes.append(f"no labelled positions ({exc}): every metric was skipped") result.notes = notes return result if not items.height: notes.append(f"the '{cfg.split}' split is empty: every metric was skipped") result.notes = notes return result
cache = _heuristic_cache(cfg, use_cache) try: tokenized = encoder_items(items, model, cfg.labels) predictions = predict(model, tokenized, cfg.batch_size, str(where)) baseline = heuristic_predictions(items, cfg, cache) finally: cache.close() measure(items, predictions, baseline, cfg, result) if result.f1_margin is not None: notes.append( f"the encoder is {result.f1_margin:+.1f} F1 points from the baseline; GOAL.md asks " f"for at least {GOAL_MARGIN:+.0f}" ) if result.encoder_value is not None: notes.append( f"the value head is correlated against `{result.encoder_value.target}`, the bounded " "score it is trained on, and not against raw `cp`, where a forced mate is worth " "±9 99x and a few rows would decide Pearson for the whole set; the GOAL.md bar of " f"{GOAL_VALUE_CORRELATION:.2f} is read on Spearman" )Las notas que dependen de lo medido llevan el número dentro («el encoder está a +9,2 puntos de F1 del baseline; el plan pide al menos +5»), así que no hay que cruzarlas con otro documento. Y sin la tabla de etiquetas la suite devuelve campos vacíos con una nota que dice por qué, en vez de una traza: corre dentro de la publicación de un modelo, y un fallo la dejaría a medias.
notes.append( "the baseline counts material (1/3/3/5/9) and mobility and looks one ply ahead at " "captures: it cannot see a positional sacrifice, and calls Fischer's 17...Be6 " "(Byrne-Fischer, 1956) a nine-point blunder" ) notes.append( "the two blunder detectors do not see the same thing: the baseline is given the " "predecessor position and the move that was played, while the encoder is given only the " "resulting position and has to infer that something was thrown away. That is the " "comparison GOAL.md asks for, but it is not a level playing field" ) notes.extend(blunder_notes(result)) result.notes = notes return result
def blunder_notes(result: EncoderResult) -> list[str]: """How the blunder numbers have to be read; the report and the model card share this text.""" fixed = result.encoder_blunder_fixed if result.encoder_blunder is None or fixed is None: return [] if result.threshold_tuned is None or result.threshold_fixed is None: return [] notes = [ f"the blunder threshold {result.threshold_tuned:.4g} was chosen on the " f"'{TUNE_HALF}' half ({result.tune_items:,} rows, {result.tune_games:,} games) by " f"maximising F1 there, and the reported numbers are measured on the '{SCORE_HALF}' half " f"({result.score_items:,} rows, {result.score_games:,} games)" + ("" if result.threshold_split_degenerate else ", which no threshold ever saw") + f"; the same rows at the fixed threshold {result.threshold_fixed:.4g} give an F1 of " f"{fixed.f1:.4f} against {result.encoder_blunder.f1:.4f} tuned", "the material baseline is a hard yes/no rule: it has no threshold, so nothing was tuned " "on its side and it got no half to tune on. The margin therefore compares a model at its " "best operating point against a rule at its only one", base_rate_caveat(result.blunder_base_rate), ] if result.threshold_split_degenerate: notes.append( f"the by-game split could not give both halves blunders ({result.blunder_positives} " f"of {result.blunder_items} labelled rows are blunders), so the threshold was chosen " "and measured on the same rows: this F1 is an upper bound, not a held-out number" ) return notesUna nota admite que la comparación de los criterios de aceptaciónCriterio de aceptaciónEl listón que un hito se fija por escrito y antes de medir nada: una frase con un número y una comparación, del tipo «legalidad ≥ 99 % por argmax» o «Elo por condición monótono, con intervalos». Escribirlo antes es lo que impide la trampa más común al evaluar un modelo, que es mirar el resultado y decidir después qué contaba como éxito. En Rukh cada módulo cierra diciendo cuáles de sus criterios cumple y cuáles no; los que no se cumplen se publican igual, con la medida de por qué. del módulo «is not a level playing field». La cláusula «which no
threshold ever saw» solo se escribe cuando es verdad: si el reparto degeneró, desaparece y entra una
nota que lo explica, en vez de una plantilla fija que a veces miente. blunder_notes es una función
aparte porque el informe y la model card comparten este texto (lo verás otra vez en la lección 9).
El informe
def _percent(value: float | None) -> str: return "n/a" if value is None else f"{value * 100:.1f} %"
def _number(value: float | None) -> str: return "n/a" if value is None else f"{value:.3f}"
def _verdict_cell(met: bool | None) -> str: """How an acceptance criterion reads in the report: measured, missed, or not measured.""" return "not measured" if met is None else ("yes" if met else "no")
def _detector_row(measured: ClassificationResult | None, operating_point: str) -> str | None: if measured is None: return None return ( f"| {measured.name} | {operating_point} | {measured.items} | {measured.positives} | " f"{measured.predicted} | {_percent(measured.precision)} | " f"{_percent(measured.recall)} | {_percent(measured.f1)} |" )_verdict_cell tiene tres estados: un criterio que no se pudo medir sale como not measured, no
como no, para no contar un fallo de instrumentación como un fallo del modelo.
def _blunder_section(result: EncoderResult) -> list[str]: """The whole blunder block: how the operating point was chosen, then every number it gave.""" tuned = "n/a" if result.threshold_tuned is None else f"{result.threshold_tuned:.4g}" fixed = "n/a" if result.threshold_fixed is None else f"{result.threshold_fixed:.4g}" lines = [ "## Blunder detection", "", sentence(base_rate_caveat(result.blunder_base_rate)), "", f"The {result.blunder_items:,} labelled rows are cut in two by `game_id`, never by " "position, the policy the held-out split itself uses, because two positions of the same " f"game are one move apart. The `{TUNE_HALF}` half ({result.tune_items:,} rows, " f"{result.tune_games:,} games) chose the threshold `p >= {tuned}` by maximising F1 " f"there; the `{SCORE_HALF}` half ({result.score_items:,} rows, {result.score_games:,} " "games) is where every number below is measured" + ("." if result.threshold_split_degenerate else ", and no `game_id` is in both halves."), "", "The material baseline is a hard yes/no rule: it has no threshold, so it was given no " "half to tune on and nothing was swept on its side. The margin below compares the model " "at its best operating point against the rule at its only one, and that is a courtesy " "the model receives, not one the baseline does.", "", "| Detector | Operating point | Items | Blunders | Flagged | Precision | Recall | F1 |", "|---|---|---:|---:|---:|---:|---:|---:|", ] rows = [ _detector_row(result.encoder_blunder, f"tuned, `p >= {tuned}` (chosen on `{TUNE_HALF}`)"), _detector_row(result.encoder_blunder_fixed, f"fixed, `p >= {fixed}`"), _detector_row(result.heuristic_blunder, "rule, no threshold to tune"), ] lines.extend(row for row in rows if row is not None) lines.append("")La sección que produjo la tabla de tres filas de más arriba. El aviso de la tasa base va antes de cualquier cifra, y la explicación del reparto antes de la tabla: los asteriscos no van al pie.
if result.tune_f1 is not None: lines.extend( [ f"The same threshold reaches an F1 of {_percent(result.tune_f1)} on the " f"`{TUNE_HALF}` half it was chosen on. The distance between that and the " f"`{SCORE_HALF}` half above is what picking an operating point costs, and it is " "the reason the two halves are not the same rows.", "", ] ) if result.threshold_split_degenerate: lines.extend( [ "**Warning:** the by-game split could not give both halves blunders, so the " "threshold was chosen and measured on the same rows. The F1 above is an upper " "bound, not a held-out number.", "", ] ) measured = result.encoder_blunder_ranking if measured is not None: span = "n/a" if measured.min_score is not None and measured.max_score is not None: span = f"{measured.min_score:.4f} to {measured.max_score:.4f}" if measured.mean_score is not None: span += f" (mean {measured.mean_score:.4f})" lines.extend( [ "### Without an operating point", "", "ROC AUC is the probability that a random blunder is ranked above a random quiet " "move (0.5 is a coin flip); average precision is the area under the " "precision-recall curve, and a random ranking scores the base rate, which is " "printed next to it. Neither depends on a threshold, so neither can be flattered " "by choosing one. The span of the probabilities is here because it is what makes " "a fixed threshold reasonable or absurd.", "", "| Detector | Items | Blunders | Base rate | ROC AUC | Average precision | " "Probability span |", "|---|---:|---:|---:|---:|---:|---|", f"| {measured.name} | {measured.items} | {measured.positives} | " f"{_percent(measured.base_rate)} | {_number(measured.roc_auc)} | " f"{_number(measured.average_precision)} | {span} |", "", ] ) return linesDe aquí sale el probability span del principio de la lección.
def render_markdown(result: EncoderResult) -> str: """The human-readable report: the margin first, then every breakdown.""" encoder = result.encoder_blunder fixed = result.encoder_blunder_fixed baseline = result.heuristic_blunder measured = result.encoder_blunder_ranking margin = "n/a" if result.f1_margin is None else f"{result.f1_margin:+.1f} points" target = result.encoder_value.target if result.encoder_value else "tanh(cp / value_scale)" tuned = "n/a" if result.threshold_tuned is None else f"{result.threshold_tuned:.4g}" at_fixed = "n/a" if result.threshold_fixed is None else f"{result.threshold_fixed:.4g}" lines: list[str] = [ f"# Evaluation of `{result.stage}`", "", f"- Suite: `{result.suite}`", f"- Checkpoint: `{result.checkpoint}`", f"- Weights SHA-256: `{result.model_sha}`", f"- Parameters: {result.params:,}", f"- Device: `{result.device}`", f"- Date: {result.date}", f"- MLflow run: {result.run_id or 'not tracked'}", "", "## Headline", "", "| Metric | Value |", "|---|---|", f"| Blunder F1, encoder (tuned, `p >= {tuned}`) | " f"{_percent(encoder.f1 if encoder else None)} |", f"| Blunder F1, encoder (fixed, `p >= {at_fixed}`) | " f"{_percent(fixed.f1 if fixed else None)} |", f"| Blunder F1, material baseline | {_percent(baseline.f1 if baseline else None)} |", f"| Margin over the baseline | {margin} |", f"| Margin >= {GOAL_MARGIN:.0f} points | {_verdict_cell(result.meets_goal)} |", f"| Blunder ROC AUC | {_number(measured.roc_auc if measured else None)} |", f"| Blunder average precision | " f"{_number(measured.average_precision if measured else None)} |", f"| Blunder base rate | {_percent(result.blunder_base_rate)} |", f"| Value vs `{target}`, Spearman (headline) | " f"{_number(result.encoder_value.spearman if result.encoder_value else None)} |", f"| Value vs `{target}`, Pearson | " f"{_number(result.encoder_value.pearson if result.encoder_value else None)} |", f"| Value correlation >= {GOAL_VALUE_CORRELATION:.2f} | " f"{_verdict_cell(result.value_correlation_meets_goal)} |", f"| Result accuracy | {_percent(result.result_accuracy)} |", f"| Positions | {result.items:,} ({result.blunder_items:,} with a blunder label, " f"{result.score_items:,} of them scored) |", "", ]En la tabla del titular, el orden de las filas es una decisión editorial: el F1 fijo va justo debajo del que se publica, porque enterrarlo al final habría sido igual de cierto y mucho peor. La última fila responde «¿sobre cuántas filas?» con los tres denominadores a la vez.
if encoder is not None or baseline is not None: lines.extend(_blunder_section(result)) if result.encoder_value is not None: lines.extend( [ "## Value against Stockfish", "", f"Both predictors are correlated against `{target}`, the bounded score the value " "head is trained on, never against raw `cp`: a forced mate is worth ±9 99x " "there and would decide Pearson for the whole set on its own. Spearman asks " "only whether the ranking is right and is the number the goal is checked " "against; Pearson also asks whether the scale is.", "", "| Predictor | Items | Target | Spearman | Pearson |", "|---|---:|---|---:|---:|", ] ) for measured in (result.encoder_value, result.heuristic_value): if measured is not None: lines.append( f"| {measured.name} | {measured.items} | `{measured.target}` | " f"{_number(measured.spearman)} | {_number(measured.pearson)} |" ) lines.append("") if result.label_curve: keys = sorted({key for point in result.label_curve for key in point.metrics}) lines.extend( [ "## Labels needed", "", "| Labels | Rows | " + " | ".join(f"`{key}`" for key in keys) + " |", "|---:|---:|" + "---:|" * len(keys), ] ) for point in result.label_curve: cells = " | ".join(_number(point.metrics.get(key)) for key in keys) rows = "n/a" if point.train_labels is None else f"{point.train_labels:,}" lines.append(f"| {point.fraction:.0%} | {rows} | {cells} |") lines.append("") if result.notes: lines.extend(["## Notes", ""]) lines.extend(f"- {note}" for note in result.notes) lines.append("") return "\n".join(lines)La tabla de la correlación lleva el baseline al lado del modelo, con la misma diana.
def track_result(result: EncoderResult, cfg: EncoderEvalConfig) -> str | None: """Log the suite to the local MLflow store and return the run id.""" import mlflow
from rukh.tracking import start_run
with start_run( f"eval-{result.stage}", {"suite": result.suite, "checkpoint": result.checkpoint, **cfg.model_dump(mode="json")}, tags={"kind": "eval", "stage": result.stage, "model_sha": result.model_sha}, ) as run: metrics = _metrics(result) if metrics: mlflow.log_metrics(metrics) return str(run.info.run_id)El model_sha va como tag de la ejecución: en MLflow los tags se filtran, así que «todas las
evaluaciones de este fichero de pesos» es una consulta.
def _metrics(result: EncoderResult) -> dict[str, float]: """Flat metrics for MLflow (only what was actually measured).""" metrics: dict[str, float] = {} for measured, name in ( (result.encoder_blunder, "encoder"), (result.encoder_blunder_fixed, "encoder_fixed"), (result.heuristic_blunder, "heuristic"), ): if measured is not None: metrics[f"blunder_f1/{name}"] = measured.f1 metrics[f"blunder_precision/{name}"] = measured.precision metrics[f"blunder_recall/{name}"] = measured.recall if result.encoder_blunder_ranking is not None: auc = result.encoder_blunder_ranking.roc_auc precision = result.encoder_blunder_ranking.average_precision if auc is not None: metrics["blunder_roc_auc"] = auc if precision is not None: metrics["blunder_average_precision"] = precision for name, value in ( ("blunder_threshold", result.threshold_tuned), ("blunder_base_rate", result.blunder_base_rate), ("blunder_f1_tune", result.tune_f1), ): if value is not None: metrics[name] = float(value) for measured in (result.encoder_value, result.heuristic_value): if measured is not None and measured.pearson is not None: metrics[f"value_pearson/{measured.name}"] = measured.pearson if measured is not None and measured.spearman is not None: metrics[f"value_spearman/{measured.name}"] = measured.spearman if result.f1_margin is not None: metrics["blunder_f1_margin"] = result.f1_margin for name, met in ( ("meets_goal_blunder", result.meets_goal), ("meets_goal_value", result.value_correlation_meets_goal), ("meets_goal_all", result.meets_all_goals), ): if met is not None: metrics[name] = float(met) if result.result_accuracy is not None: metrics["result_accuracy"] = result.result_accuracy return metricsUna métrica que no se pudo calcular no aparece en MLflow, en vez de aparecer como cero. Los
booleanos de criterio van como float(met) porque MLflow solo acepta números. Y blunder_f1_tune,
el número optimista, se registra al lado del honesto.
Qué has aprendido
Con una tasa base del 3,7 %, la exactitud del 96,78 % y el F1 de 0,0000 en 0,5 no dicen nada; el mismo modelo tiene 0,740 de ROC AUC. Lo que sirve con cualquier detector de algo raro, sea de ajedrez, de fraude o de spam, es publicar las métricas que ningún umbral puede halagar, elegir el punto de operación en unas filas, medirlo en otras y decir cuánto cuesta esa elección.
Un baseline solo vale como comparación si es malo a propósito y se mide igual: mismas filas, misma definición de acierto, y sus cegueras escritas y con test.
Y una pérdida y una métrica que se describen con las mismas palabras pueden tener mínimos distintos: escribe el criterio primero y comprueba que tu pérdida apunta a él. Aquí, eso rindió cuatro veces más que triplicar el modelo.
Para comprobarlo: uv run pytest tests/unit/test_heuristic.py -q pasa los diez tests, y
uv run rukh eval encoder imprime el margen, las dos métricas sin umbral con lo que sacaría una
moneda al lado, las dos correlaciones y las siete notas.
Lo siguiente es la exportación: el encoder en ONNX con dos salidas, la paridad de las decisiones de error en tres precisiones, los embeddings de posición y la model card que reutiliza estas mismas notas.