// M3 · lección 02
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.
Parte 2 de 3 del módulo «El encoder». Viene de «El encoder: la línea que lo cambia todo» y sigue en «El encoder: los labs».
Cómo se mide y contra qué
Al terminar esta sección sabrás leer las cifras que produce rukh eval encoder, explicar por qué la
línea base es deliberadamente mala, 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 retenido 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 y decir qué
significa cada métrica en un problema donde una clase es rara.
Un encoder no juega partidas, así que no se puede medir como al decoder. Se mide por lo que sabe de
posiciones que no ha visto: la partición de validación del reparto por partidas, que es el 10 %
de las partidas (val_fraction: 0.1, nunca el 10 % de las posiciones sueltas), y con la línea
base medida sobre exactamente las mismas filas.
Estos son los números reales del hito, para que leas el resto de la sección sabiendo adónde va:
Criterio de GOAL.md |
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» existe porque el módulo siguió midiendo después de publicarse, y lo que se
encontró —que la pérdida optimizaba una cosa distinta de la que puntúa el criterio— está más abajo,
en «La pérdida no medía lo que el criterio mide». La cifra sube de 0,520 a 0,6665 y sigue sin
cumplir, que es exactamente por lo que la tabla tiene dos columnas y no una reescrita. Todas las
cifras «al cerrar el hito» de esta parte son del mismo checkpoint, el afinado last-n del esquema
de jugadas (checkpoints/encoder-heads-moves-20260919-101904/best.pt), que es el que se publicó;
cuando aparezca otro se dirá cuál.
Un criterio cumplido y otro no, y las dos cosas se publican con el mismo tamaño de letra. Hay además
una tercera cifra, val/blunder_acc = 96,78 %, que no es ningún criterio y que si se leyera como si
lo fuera sería la mentira más cómoda del módulo. Empezamos por ella.
El 96,78 % que no significa nada
Esta es la subsección más transferible del módulo, y no tiene nada que ver con el ajedrez: vale para cualquier detector de algo raro que vayas a construir en tu vida —fraude, fallos de máquina, diagnósticos, moderación—, que es decir, para casi todos.
Los errores son el 3,72 % de las filas etiquetadas. De ahí salen tres consecuencias, en orden de gravedad.
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 %. Los cuatro decimales de diferencia son todo lo que la exactitud es capaz de 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 conjunto de datos.
Dos: el F1 en un umbral arbitrario tampoco mide lo que crees. Esta es la que engaña a gente con experiencia. El F1 de esta cabeza en el umbral fijo de 0,5 es exactamente 0,0000. Precisión cero, exhaustividad cero, cero posiciones marcadas de 3 660. La razón está en una sola 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. En 0,5 no se dispara nunca, así que ese F1 era cero por construcción antes de entrenar nada. Y no es que el modelo esté roto: es que un sigmoide entrenado con entropía cruzada sobre una clase del 3,7 % aprende, correctamente, que la respuesta esperada casi siempre es “no”, y coloca toda su masa de probabilidad abajo. Ordena bien las posiciones y las ordena todas por debajo de 0,5. Un F1 en un umbral fijo mide la calibración de un sigmoide que nadie calibró, no la calidad de la representación.
Tres: elegir el umbral es legítimo, y hay una única forma honesta de hacerlo. El umbral es un hiperparámetro como cualquier otro, y nadie te obliga a dejarlo en el 0,5 que viene de fábrica. Lo que no vale es elegirlo mirando las filas que después vas a puntuar, porque entonces el número que publicas es el máximo de una búsqueda sobre el propio conjunto de evaluación y no una estimación de nada. El procedimiento correcto es:
- Parte las filas etiquetadas del conjunto retenido en dos mitades, por
game_idy nunca por posición, por la misma razón que el reparto principal (la sección siguiente). 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.
Y publica también lo que se mueve entre las dos mitades, porque es la parte que casi nadie enseña:
el mismo umbral 0,0663 da 0,171 en la mitad tune donde se eligió y 0,180 en la mitad
score. Esta vez la mitad que no eligió salió casi un punto por encima; con el checkpoint full
de la tabla de la parte 1 fue al revés (0,184 en tune, 0,179 en score). Lo que hay que leer no es el
signo, es el tamaño: con 144 errores en 3 660 filas, un punto de F1 es lo que separa dos mitades del
mismo conjunto sin cambiar nada. Elegir el umbral en las filas que luego se puntúan convierte esa
diferencia en optimismo garantizado, y esa es la razón entera de que las dos mitades no sean las
mismas filas.
Las cuarenta líneas, porque cada una tapa una forma distinta de engañarse:
def _ranks(values: np.ndarray) -> np.ndarray: """Rangos medios, empates compartidos. La única sutileza de un Spearman a mano.
Los empates tienen que recibir la **media** de los rangos que abarcan. Darles rangos consecutivos arbitrarios inventa un orden que los datos no tienen, y aquí los empates abundan: un `tanh` acotado aplasta muchas posiciones sobre casi el mismo número. """ order = np.argsort(values, kind="mergesort") ranks = np.empty(len(values), dtype=np.float64) sorted_values = values[order] i = 0 while i < len(values): j = i while j + 1 < len(values) and sorted_values[j + 1] == sorted_values[i]: j += 1 ranks[order[i : j + 1]] = (i + j) / 2.0 + 1.0 i = j + 1 return ranks
def spearman(x, y) -> float: """Pearson sobre rangos medios: solo el orden, no la escala.""" a, b = np.asarray(x, dtype=np.float64), np.asarray(y, dtype=np.float64) return pearson(_ranks(a), _ranks(b)) if a.size >= 2 else math.nan
def roc_auc(scores, labels) -> float: """Probabilidad de que un positivo al azar quede por encima de un negativo al azar.
Calculado con la suma de rangos (la identidad de Mann-Whitney) en vez de recorriendo una curva: es más corto y es exacto con empates. """ s, y = np.asarray(scores, dtype=np.float64), np.asarray(labels, dtype=np.int64) n_pos, n_neg = int((y == 1).sum()), int((y == 0).sum()) if not n_pos or not n_neg: return math.nan ranks = _ranks(s) return float((ranks[y == 1].sum() - n_pos * (n_pos + 1) / 2.0) / (n_pos * n_neg))
def average_precision(scores, labels) -> float: """Área bajo precisión-exhaustividad. Su referencia es **la tasa base**, no 0,5.""" s, y = np.asarray(scores, dtype=np.float64), np.asarray(labels, dtype=np.int64) total = int((y == 1).sum()) if not total: return math.nan order = np.argsort(-s, kind="mergesort") hits, running = 0, 0.0 for seen, index in enumerate(order, start=1): if y[index] == 1: hits += 1 running += hits / seen return float(running / total)
def f1_at(scores, labels, threshold: float) -> OperatingPoint: """Precisión, exhaustividad y F1 en un umbral, con los conteos que los explican.""" s, y = np.asarray(scores, dtype=np.float64), np.asarray(labels, dtype=np.int64) flagged = s >= threshold tp, n_flagged, positives = int((flagged & (y == 1)).sum()), int(flagged.sum()), int((y == 1).sum()) precision = tp / n_flagged if n_flagged else 0.0 recall = tp / positives if positives else 0.0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 return OperatingPoint(threshold, len(y), positives, n_flagged, precision, recall, f1)
def tune_threshold(scores, labels) -> float: """El umbral que maximiza el F1 **sobre las filas que se le pasan**.
Dale la mitad `tune`. Dársela a las filas que vas a publicar convierte el número publicado en un récord personal en vez de en una estimación. """ s = np.asarray(scores, dtype=np.float64) best = (0.0, 0.5) for threshold in np.unique(s): point = f1_at(s, labels, float(threshold)) if point.f1 > best[0]: best = (point.f1, float(threshold)) return best[1]Y el reparto, que es la otra mitad del procedimiento honesto:
def split_by_group(groups, seed: int = 0, fraction: float = 0.5) -> np.ndarray: """Una máscara que parte las filas en dos **por grupo**, de forma reproducible.
Es una función pura del id y de la semilla —un CRC-32, no `hash()`, que Python aleatoriza por proceso—. El CRC-32 con semilla es un bombo grabado en vídeo: cada vez que pones la cinta salen las mismas bolas, en cualquier máquina y dentro de un año. """ keys = np.array( [zlib.crc32(f"{seed}:{int(group)}".encode()) % 1_000_000 for group in groups], dtype=np.int64, ) return keys < int(fraction * 1_000_000)La heurística de material y movilidad
La línea base cabe en una página: peón 1, caballo 3, alfil 3, torre 5, dama 9, rey 0. A eso se le
suma la movilidad —jugadas legales de las blancas menos las de las negras, a 0,05 peones cada una,
o sea que veinte jugadas de diferencia valen un peón— y se convierte a la misma escala acotada que
la etiqueta, tanh(puntuación / 4) en peones, que es idéntico a tanh(cp / 400) en centipeones.
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 ply; si el balance de material del que movió cae un
punto o más, la heurística dice “error”.
En código son cuatro funciones:
PIECE_VALUES = {chess.PAWN: 1.0, chess.KNIGHT: 3.0, chess.BISHOP: 3.0, chess.ROOK: 5.0, chess.QUEEN: 9.0, chess.KING: 0.0}MOBILITY_WEIGHT = 0.05BLUNDER_MATERIAL = 1.0VALUE_SCALE = 4.0 # `tanh(puntuación / 4)` en peones es `tanh(cp / 400)` en centipeones
def _moves_for(board, color) -> int: """Cuántas jugadas legales tiene `color`, **mueva quien mueva**.
Cambiar el turno sobre una copia es la única forma de hacer esa pregunta. Contar solo las del bando que mueve haría que la movilidad dependiera de la paridad del ply y no de la posición. """ 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 worst_material(board, color) -> float: """El material de `color` tras la única respuesta más rentable que tiene el rival.
Un ply, solo capturas y promociones: sin recaptura, sin amenaza, sin jaque. Esa es toda la búsqueda, y las cegueras que eso produce están escritas en la lección en vez de arregladas. """ 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
def judge(fen: str, move: str, threshold=BLUNDER_MATERIAL, mobility_weight=MOBILITY_WEIGHT): """Juzga `move` jugada en `fen` (la posición **anterior** a la jugada).""" 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))Ese VALUE_SCALE = 4.0 es la otra mitad de «medirla igual»: tanh(puntuación / 4) en peones es
exactamente tanh(cp / 400) en centipeones, así que la línea base y el modelo se leen en la misma
escala y sus correlaciones se pueden poner en la misma tabla.
Es mala a propósito, y eso es una decisión de diseño, no una concesión. Una línea base que ya entendiera los sacrificios no sería un suelo: sería un competidor, y el margen dejaría de significar “el modelo aprendió algo más que contar piezas”. Lo que sí es innegociable es medirla igual: mismas filas, misma definición de acierto, mismo umbral de 100 centipeones en la etiqueta. Un margen medido sobre dos conjuntos distintos no es un margen, es una comparación de anécdotas.
Y hay una asimetría que conviene decir antes de leer el margen, porque no pesa igual sobre los dos esquemas: la heurística recibe la posición anterior y la jugada —vuelve a jugarla en un tablero para saber qué cambió—. El encoder de casillas solo ve el FEN resultante, y detectar un error sin ver de dónde vienes es más difícil que detectarlo viéndolo, así que para él el margen está medido en contra: si aun así gana, ha aprendido algo que no es contar piezas. El esquema de jugadas, en cambio, lleva la línea dentro —su entrada es el prefijo de la partida— y esa desventaja no la tiene. Los dos ganaron: el de casillas, que no la ve, por 5,7 puntos; el de jugadas, que la lleva dentro, por 9,2.
Hay una segunda asimetría, y esta va a favor del modelo, así que también se dice: la heurística es
una regla de sí/no sin umbral que ajustar, de modo que no se le dio ninguna mitad tune ni se
barrió nada en su lado. La comparación enfrenta a un modelo en su mejor punto de operación contra
una regla en el único que tiene. Es una cortesía que recibe el modelo y no la línea base, está
escrita en el informe para que no sea injusta en silencio, y conviene tenerla en la cabeza al leer
los +9,2 puntos del esquema de jugadas, que son los de la tabla que sigue.
Estas son las tres filas del desglose por punto de operación del informe del afinado last-n del
esquema de jugadas, sobre la mitad score, y la historia está en la de abajo:
| 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 % |
Mira la fila de la heurística: precisión 4,7 %, exhaustividad 77,1 %, 2 359 posiciones marcadas de 3 660. La regla grita “error” en dos de cada tres jugadas de la partida. Encuentra casi todos los errores porque marca casi todo, y de ahí su exhaustividad envidiable y su precisión ridícula. El encoder gana el margen por el otro lado: marca 488 posiciones en vez de 2 359 —cinco veces menos— y acierta en el 11,7 % de ellas contra el 4,7 % de la regla. Dos detectores con F1 de 8,9 % y 18,0 % y con perfiles opuestos, que es exactamente por qué el titular lleva la precisión y la exhaustividad al lado y no solo el F1.
Qué no puede ver
El ejemplo que el propio código lleva escrito en src/rukh/eval/heuristic.py 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. Es una de las jugadas más famosas de la historia del ajedrez y la partida acaba siendo una
victoria de las negras.
La heurística mira esa posición, ve que las blancas pueden jugar 18.Axb6 y llevarse una dama por nada, calcula una pérdida de nueve puntos de material y llama error garrafal a 17…Ae6. No se equivoca en la aritmética: se equivoca en todo lo demás, porque un sacrificio posicional es literalmente invisible para algo que solo cuenta piezas.
Tiene otras dos cegueras que también están escritas en el módulo en vez de arregladas, y merece la pena conocerlas porque explican de dónde salen sus falsos positivos:
- No ve la recaptura. La respuesta del rival se busca a un solo ply y solo entre capturas y promociones, así que un cambio igualado cuya recaptura llega dos plies después se lee como una pérdida.
- Cobra dos veces una pieza colgada. Si una pieza ya estaba en el aire antes de mover, cualquier jugada tranquila posterior carga con esa pérdida, porque el “antes” es el material en el tablero y no lo mejor que el jugador podría haber conservado.
Arreglarlas convertiría la línea base en un pequeño motor, y entonces ya no sería una línea base.
Precisión, exhaustividad y F1 cuando una clase es rara
Con el desbalanceoDesbalanceo de clasesQue una clase sea mucho más frecuente que la otra. En detección de errores solo una fracción pequeña de las jugadas pierde 100 cp, así que la exactitud es inútil como métrica (decir siempre «no» ya la deja altísima) y hay que mirar precisión, exhaustividad y F1 sobre la clase rara. También cambia el entrenamiento: la pérdida promedia sobre las filas etiquetadas y la clase mayoritaria manda si no se compensa. ya desmontado, las tres cifras que sí se publican.
La precisión responde a “de lo que marqué, cuánto era de verdad”: verdaderos positivos entre todo lo marcado. Es la que le importa a la demo, porque una barra que grita “error” en jugadas correctas se apaga a los cinco minutos. Aquí, 11,7 %.
La exhaustividad responde a “de lo que había, cuánto marqué”: verdaderos positivos entre todos los errores reales. Es la que le importa a alguien que quiera usar esto para analizar sus partidas, porque un error que no se detecta no se aprende. Aquí, 39,6 %.
Las dos se mueven en contra. Bajar el umbral de decisión captura más errores reales y también más
falsas alarmas; subirlo hace lo contrario. Elegir el punto de esa curva es una decisión de producto
—en Rukh el valor de fábrica está en configs/eval/encoder.yaml como threshold: 0.5, y ya has
visto lo que ese valor de fábrica le hace a una clase del 3,7 %— y no tiene una respuesta
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 un respetable 0,5 y
la armónica da 0,02, que es la cifra honesta. Aquí, 18,0 %.
La media armónica es la de la velocidad media de un viaje de ida y vuelta. Si vas a 100 km/h y vuelves a 20, la media no es 60: es 33, porque en el tramo lento pasas cinco veces más tiempo. La pata lenta manda, y eso es exactamente lo que se quiere de una métrica que no debe dejarse comprar por una precisión perfecta sobre tres aciertos.
Conviene mirar esos tres números sin adornos: de cada diez posiciones que el modelo marca como
error, casi nueve no lo son, y de cada cinco errores reales se le escapan tres. Como producto,
todavía no sirve para nada. Como medida, es el doble que una heurística que sí entiende de
material, sobre las mismas filas y con la posición anterior escondida, y eso es lo que el módulo
pretendía demostrar. El listón de GOAL.md es superar el F1 de la heurística en cinco puntos y
salen +9,2: criterio cumplido, con el nivel absoluto dicho en voz alta para que nadie confunda
“bate a la línea base” con “está listo”.
// 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 (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 exactamente 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, porque la pata mala manda. Eso es lo que se quiere de la métrica: que marcar dos de cada tres jugadas como error no se pueda disfrazar de detector.
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. Es sensible a la escala: si predices sistemáticamente la mitad de lo que hay, Pearson lo nota. Spearman es Pearson calculado sobre los rangos —las posiciones en el orden, con la media de los rangos para los empates—, así que solo mide si el orden coincide y le da igual la escala. 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 pregunta además si los tiempos que has predicho se parecen a los reales. Puedes clavar el podio y fallar todos los cronos por un minuto.
Las dos discrepan exactamente en un caso, y es un caso que aquí va a ocurrir: cuando el modelo
tiene el orden bien y la escala mal. Es justo lo que le hace un tanh acotado a una
puntuación en centipeones que no lo está: las posiciones extremas se aplastan contra ±1 y la
relación deja de ser lineal aunque el ranking sea perfecto. Publicar solo Pearson haría parecer peor
un modelo que ordena bien; publicar solo Spearman escondería un modelo que ordena bien pero cuya
barra de evaluación marca 0,3 donde debería marcar 0,8, que en la demo es un fallo visible. Por eso
van las dos, y por eso GOAL.md pide ≥ 0,8, leído sobre Spearman. En el código, Spearman está
escrito a mano —Pearson sobre rangos medios— porque scipy no es una dependencia del proyecto y no
merece serlo por catorce líneas.
Un detalle de medida antes de los números: las dos correlaciones se calculan contra
tanh(cp / 400), la escala acotada con la que se entrena la cabeza, y nunca contra el cp en
bruto. Un mate forzado vale ±9 999 centipeones, y media docena de filas así decidirían el Pearson
del conjunto entero. Correlacionar contra la escala en la que se entrena no es hacerlo fácil: es
correlacionar contra la etiqueta.
El criterio de valor no se cumple, y esta es la cifra
| Esquema | Spearman | Pearson | Listón de GOAL.md |
|---|---|---|---|
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 es 0,52 y el listón es 0,80. No está cerca, no es un decimal, no hay
manera de contarlo como un aprobado raspado: el criterio de valor de GOAL.md no se cumple y se
publica sin cumplir, que es lo que el propio GOAL.md manda hacer cuando pasa esto.
Antes de seguir, vuelve a la tabla de resultados de arriba y mira la pareja que importa: contra el
encoder publicado, squares tiene mejor Pearson (0,688 contra 0,648) y peor Spearman (0,422
contra 0,520). Acabas de leer que esas dos medidas se separan exactamente cuando el orden está
bien y la escala mal, y aquí una entrada gana en escala y pierde en orden.
Eso no es ruido —son dos representaciones distintas, no dos tiradas—, es una huella dactilar. Estuvo publicada y nadie tiró del hilo. Guárdala: volvemos a ella en cuanto acabe el diagnóstico.
El diagnóstico del momento tenía tres piezas y ninguna era “hace falta un modelo más grande”:
- Cuatro mil pasos de afinado. Es el presupuesto del módulo, no un punto de convergencia, y ya viste que el preentrenamiento seguía bajando cuando se cortó.
- Cobertura de etiquetas del 9,8 % —y, atención, la curva dice que esta pieza no es la que duele. De los cinco millones de posiciones muestreadas, solo 488 159 recibieron una evaluación de Stockfish al cruzarlas con el conjunto público de evaluaciones, y la cabeza de valor aprende de las 438 093 que sobreviven al reparto. Parecen pocas; el lab 3 las mide, y con el 25 % de ellas el error del valor es el mismo. O sea que el problema no es la cantidad de etiquetas sino lo que cada una aporta: etiquetas más informativas, no más etiquetas.
tanh(cp/400)comprime justo donde hay densidad. La inmensa mayoría de las posiciones de club están entre −100 y +100 centipeones, y ahí eltanhlas aplasta contra el cero: la señal que la cabeza tiene que aprender a distinguir es minúscula precisamente donde están casi todos los datos. Una escala menos comprimida repartiría mejor el rango útil. Sería un termómetro muy fino entre 39 y 41 grados y casi ciego entre 36 y 37, que es donde está casi todo el mundo.
Ese razonamiento está bien hecho y se equivocó. Las tres piezas apuntaban a los datos y a la etiqueta; ninguna miró 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 es monótono,
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 exactamente las mismas 488 posiciones.
Todo lo que dependa de un umbral fijo mide calibración; todo lo que dependa solo del orden mide
lo que la cabeza sabe. Es la razón de que el informe publique las dos familias, y de que el
titular no sea nunca el número del umbral de fábrica.
La pérdida no medía lo que el criterio mide
Una pérdida y una métrica que se describen con las mismas palabras pueden tener mínimos distintos. Esa es la regla, y lo que sigue es cómo se descubrió que este módulo llevaba tiempo incumpliéndola.
Vuelve a la huella dactilar. Si una entrada gana en escala y pierde en orden, la pregunta que nadie hizo es qué está minimizando la pérdida. Y la respuesta estaba en una línea:
"value": F.mse_loss(outputs["value"], targets["value"])F.mse_loss minimiza la distancia entre cada predicción y su etiqueta. GOAL.md puntúa con
Spearman, que solo mira el orden y al que la distancia le da exactamente igual. Son dos
funciones distintas, con mínimos distintos, y en lenguaje corriente se dicen igual —«acertar el
valor»—, que es por lo que el fallo es invisible.
Una predicción que está lejísimos de todas las etiquetas pero las ordena perfectamente saca un Spearman de 1 y un MSE malísimo. Otra que está cerquísima de todas y confunde dos saca un MSE excelente y pierde orden. Entrenábamos la segunda y puntuábamos la primera.
Un profesor con una tarde para corregir puede quedarse cerca de la nota que cada alumno merece, o acertar quién va delante de quién. No da para las dos. Y lo único que se hará con esas notas es ordenar a los alumnos para una beca: si confunde el primero con el segundo, da igual lo finas que fueran las demás. Con presupuesto infinito las dos cosas coinciden; con una tarde, no.
El arreglo es una pérdida que mire pares: por cada dos posiciones del lote, 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 —distinguir una ganada de una perdida cuenta más que separar dos iguales, que
es justo donde un evaluador sin búsqueda no tiene nada que hacer—.
def pairwise_rank_loss(pred, target, margin: float = 0.0): """Un sustituto derivable de «¿salió bien el orden?».
Cada par del lote vota: si `target[i] > target[j]`, la predicción debería poner `i` por encima de `j`. El voto es una logística sobre la diferencia predicha, ponderada por lo lejos que están las etiquetas.
Devuelve cero para un lote cuyas etiquetas son todas iguales: ahí no hay orden que aprender, y dividir por un peso cero sería peor que no decir nada. """ 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() / totalEl peso |target_i − target_j| es la mitad del arreglo. Una pérdida de ordenación sin ponderar
gastaría casi todo su gradiente en separar posiciones que el motor separa por diez centipeones
—que es justo donde una pasada sin búsqueda no tiene nada que decir— en vez de en las parejas que
deciden la partida.
| 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 |
Dos cosas que leer aquí, y la segunda es la importante.
La primera: no hubo intercambio. Se esperaba ceder Pearson para ganar Spearman y subieron los dos, así que la pérdida anterior no estaba «optimizando otra cosa legítima», estaba peor alineada con la tarea en los dos ejes.
La segunda es una atribución: cambiar la pérdida valió +0,119 de Spearman y triplicar el modelo +0,027. Cuatro veces más barato mirar qué optimizas que comprar más red. El reflejo de subir de talla es el más caro de los disponibles y casi siempre el peor informado.
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 no es cuánto falta, es dónde falla, y eso 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, y 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. Una pasada hacia delante sin búsqueda no la tiene y no 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, casi azar 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.
Un médico sin acceso a analíticas clava los brazos rotos y con los cansancios acierta poco más que a cara o cruz, no por malo sino porque eso no se diagnostica mirando. Su media dice «regular» y no describe a nadie. Mandarlo a otro curso no arregla la segunda mitad: hace falta el laboratorio, que es otra cosa.
Fuga de datos: por qué el reparto va por partida
Al terminar esta sección sabrás reconocer la fuga concreta de este módulo y explicar por qué un reparto aleatorio de filas, que en otros problemas es correcto, aquí es una trampa.
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.
El reparto obvio de una tabla de un millón de posiciones sería aleatorio por filas: el 90 % a entrenamiento, el 10 % a validación. Aquí eso es una fuga, y la razón es esta.
Toma una partida cualquiera y sus posiciones consecutivas, ply 30 y ply 31. La segunda es la primera más una jugada: treinta y una de las treinta y dos piezas siguen donde estaban, la estructura de peones es la misma, el plan es el mismo, la evaluación de Stockfish casi la misma. Si el ply 30 cae en entrenamiento y el 31 en validación, el modelo está siendo evaluado sobre una posición que ya vio, con un cambio cosmético. Un modelo que memoriza partidas puntúa igual de bien que uno que entiende ajedrez, y la métrica de validación —cuyo único trabajo es distinguir esas dos cosas— deja de hacerlo. Peor: la fuga es invisible. No hay ninguna cifra en el informe que la delate; el modelo simplemente parece mejor de lo que es hasta que lo pones en la demo.
La solución es partir por game_id: una partida entera va a un lado o al otro, nunca a los dos. En
el código es una función pura del id y de la semilla —un CRC-32 de "semilla:partida" y un módulo—
y esa pureza no es decorativa: hash() de Python cambia entre procesos por el aleatorizador de
hashes, y un hash de dataframe puede cambiar de versión a versión. Un reparto que no es reproducible
no es un reparto, es una lotería que vuelves a jugar cada vez que ejecutas el script. El CRC-32 con
semilla es un bombo grabado en vídeo: cada vez que reproduces la cinta salen las mismas bolas en el
mismo orden, en cualquier máquina y dentro de un año; hash() es volver a girar el bombo en cada
proceso. El test que lo acompaña comprueba que ningún game_id aparece en los dos lados.
Y la misma lógica se aplica a la etiqueta de error, que necesita la posición anterior de la misma
partida: si la posición de la que se calcula el loss_cp cayera al otro lado del reparto, estarías
construyendo la etiqueta de validación con una fila de entrenamiento. Por eso el cruce se hace antes
de partir, y por eso una posición cuyo predecesor no está en la tabla no recibe la etiqueta 0: recibe
null.
Hasta aquí la medición: la línea base, el punto de operación elegido en otras filas, las dos correlaciones y el reparto por partida. La parte siguiente son los labs, con los comandos y las salidas reales de cada uno, hasta el encoder de 39 millones que se publicó. Sigue en «El encoder: los labs».