rukh · lab

// 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.

  • tiempo de trabajo165 min
  • nivel avanzado
  • actualizado el23 de septiembre de 2026

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:

src/rukh/eval/encoder.py
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 ".")

src/rukh/eval/encoder.pylíneas 317-343 · p3

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.

00,10,20,30,40,50,60,70,80,91umbral elegido en tune, 0,0663marca 488 de 3 660 · F1 0,180umbral de fábrica, 0,5marca 0 · F1 0,000franja rellena: todas las probabilidades, de 0,002 a 0,310marca corta dentro de la franja: su media, 0,035, casi la tasa base (0,037)
fig. 01El eje de las probabilidades que emite la cabeza de errores, de 0 a 1. Todas las de la mitad score caen en la franja de la izquierda, entre 0,002 y 0,310. El umbral de fábrica, 0,5, queda fuera de la franja y no marca ninguna posición; el umbral elegido en la mitad tune, 0,0663, cae dentro y marca 488.

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:

  1. Parte las filas etiquetadas del conjunto held-out en dos mitades, por game_id y nunca por posición (la sección «Fuga de datos» explica por qué). Aquí salen 3 793 filas / 2 455 partidas en la mitad tune y 3 660 filas / 2 368 partidas en la mitad score.
  2. En la mitad tune, barre el umbral y quédate con el que maximiza F1. Aquí sale 0,0663.
  3. 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

src/rukh/eval/encoder.py
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)

src/rukh/eval/encoder.pylíneas 346-360 · p3

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.

src/rukh/eval/encoder.py
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]

src/rukh/eval/encoder.pylíneas 428-466 · p3

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:

src/rukh/eval/encoder.py
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,
)

src/rukh/eval/encoder.pylíneas 287-314 · p3

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.

src/rukh/eval/encoder.py
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)

src/rukh/eval/encoder.pylíneas 363-380 · p3

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.

src/rukh/eval/encoder.py
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))

src/rukh/eval/encoder.pylíneas 383-407 · p3

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.

src/rukh/eval/encoder.py
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,
)

src/rukh/eval/encoder.pylíneas 410-425 · p3

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.

src/rukh/eval/encoder.py
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 result

src/rukh/eval/encoder.pylíneas 671-735 · p3

El 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:

src/rukh/eval/heuristic.py
"""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 worth
anything 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 as
simple 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 York
1956** (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 calls
one of the most famous moves in chess a blunder. A positional sacrifice is invisible to it, and
that 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 only
over captures and promotions, so our own recapture is never counted: an equal trade whose
recapture comes two plies later reads as a loss. And a piece that was already hanging before the
move is charged again to every quiet move that follows it, because "before" is the material on
the board and not the best the mover could have kept. Both are written down here rather than
fixed, because fixing them would make the baseline a small engine instead of a floor.
"""

src/rukh/eval/heuristic.pylíneas 1-31 · p3

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.

src/rukh/eval/heuristic.py
from __future__ import annotations
import math
from collections.abc import Iterator
import chess
from pydantic import BaseModel, ConfigDict

src/rukh/eval/heuristic.pylíneas 33-39 · p3

Sin torch: es ajedrez y aritmética, así que corre en la CI sin GPU y sus veredictos se cachean sin pensar en pesos.

src/rukh/eval/heuristic.py
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."""

src/rukh/eval/heuristic.pylíneas 41-64 · p3

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.

src/rukh/eval/heuristic.py
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."""

src/rukh/eval/heuristic.pylíneas 67-80 · p3

El veredicto trae los dos materiales además de la diferencia: sin ellos, un loss de 9,0 no se puede auditar.

src/rukh/eval/heuristic.py
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)

src/rukh/eval/heuristic.pylíneas 83-116 · p3

_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.

src/rukh/eval/heuristic.py
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)

src/rukh/eval/heuristic.pylíneas 119-126 · p3

src/rukh/eval/heuristic.py
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

src/rukh/eval/heuristic.pylíneas 129-148 · p3

_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.

src/rukh/eval/heuristic.py
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),
)

src/rukh/eval/heuristic.pylíneas 151-175 · p3

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:

tests/unit/test_heuristic.py
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)

tests/unit/test_heuristic.pylíneas 84-96 · p3

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.

tests/unit/test_heuristic.py
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)

tests/unit/test_heuristic.pylíneas 25-48 · p3

El tercero comprueba que la movilidad hace algo: sin él, un MOBILITY_WEIGHT a cero por accidente pasaría inadvertido.

tests/unit/test_heuristic.py
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 pawn

tests/unit/test_heuristic.pylíneas 51-81 · p3

Los 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.

tests/unit/test_heuristic.py
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 it

tests/unit/test_heuristic.pylíneas 99-112 · p3

El 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:

src/rukh/eval/encoder.py
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)

src/rukh/eval/encoder.pylíneas 469-482 · p3

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.

src/rukh/eval/encoder.py
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)))

src/rukh/eval/encoder.pylíneas 485-507 · p3

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.

src/rukh/eval/encoder.py
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,
)

src/rukh/eval/encoder.pylíneas 510-523 · p3

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.

src/rukh/eval/encoder.py
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 result

src/rukh/eval/encoder.pylíneas 738-775 · p3

scored = _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.

src/rukh/models/heads.py
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() / total

src/rukh/models/heads.pylíneas 36-55 · p4de 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

src/rukh/eval/encoder.py
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"
)

src/rukh/eval/encoder.pylíneas 778-813 · p3

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.

src/rukh/eval/encoder.py
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)

src/rukh/eval/encoder.pylíneas 814-832 · p3

src/rukh/eval/encoder.py
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"
)

src/rukh/eval/encoder.pylíneas 833-844 · p3

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.

src/rukh/eval/encoder.py
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 notes

src/rukh/eval/encoder.pylíneas 845-887 · p3

Una 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

src/rukh/eval/encoder.py
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)} |"
)

src/rukh/eval/encoder.pylíneas 907-927 · p3

_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.

src/rukh/eval/encoder.py
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("")

src/rukh/eval/encoder.pylíneas 930-961 · p3

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.

src/rukh/eval/encoder.py
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.",
"",
]
)

src/rukh/eval/encoder.pylíneas 962-980 · p3

src/rukh/eval/encoder.py
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 lines

src/rukh/eval/encoder.pylíneas 981-1008 · p3

De aquí sale el probability span del principio de la lección.

src/rukh/eval/encoder.py
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) |",
"",
]

src/rukh/eval/encoder.pylíneas 1011-1057 · p3

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.

src/rukh/eval/encoder.py
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)

src/rukh/eval/encoder.pylíneas 1058-1101 · p3

La tabla de la correlación lleva el baseline al lado del modelo, con la misma diana.

src/rukh/eval/encoder.py
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)

src/rukh/eval/encoder.pylíneas 1104-1118 · p3

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.

src/rukh/eval/encoder.py
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 metrics

src/rukh/eval/encoder.pylíneas 1121-1163 · p3

Una 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.