Perder contra una red lineal
En la segunda quincena de septiembre perdí todas las partidas que importaban. Yo no: mis redes. Cada red neuronal con capas ocultas que entrenaba para Falcon, mi motor de ajedrez, perdía contra la que ya estaba en producción, una red lineal mucho más sencilla. Y no por poco: algunas perdían por 200 puntos de Elo, otras por más de 500.
Lo raro es que tendría que haber sido al revés. Este artículo cuenta cómo fui descartando sospechosos uno a uno y qué estaba pasando en realidad. Spoiler: el problema principal no estaba en las redes.
Lineal frente a profunda
Falcon evalúa las posiciones con una NNUE, una red neuronal pensada para correr rápido en CPU dentro de la búsqueda. Tiene dos partes. La primera, el transformador de características (FT), convierte la posición en un vector grande. Es la parte cara, pero se actualiza de forma incremental jugada a jugada. La segunda es lo que viene después.
En una red lineal no viene nada más: una suma ponderada del vector y ya está la evaluación. En una red profunda, el vector pasa antes por un par de capas pequeñas que pueden aprender relaciones que una suma no capta. Los motores punteros, Stockfish incluido, usan capas ocultas. Mi red de producción era lineal, con 1024 neuronas en el FT, y la idea era sencilla: añadirle una cabeza profunda y ganar Elo.
Tres términos para seguir el resto:
- Elo: la diferencia de fuerza entre dos versiones. +20 significa que la nueva gana algo más de lo que pierde; −500 significa que no tiene nada que hacer.
- SPRT: un test estadístico que va jugando partidas entre dos versiones hasta que puede decidir con confianza si la nueva es mejor. Es el árbitro final.
- Nodos frente a tiempo: puedes enfrentar dos motores dándoles el mismo tiempo por jugada o el mismo número de nodos (posiciones examinadas). A tiempo cuenta la velocidad; a nodos fijos, solo la calidad de la evaluación.
Los resultados
Hasta el 26 de septiembre entrené en la GPU de un portátil, una GTX 950M, así que cada red era una apuesta de días. Los números, contra la red lineal:
| Red profunda | Resultado |
|---|---|
| Cabeza 16 → 32 → 1 con target TD | −131 a tiempo; −177 con la mejor receta |
| La misma, con mezcla de resultados WDL 0.3 | −287 |
| Entradas HalfKA, cuatro recetas distintas | entre −207 y −221 |
| Cabeza ancha 2048 → 32 → 1 | entre −482 y −520 |
Cuando todo pierde, la tentación es pensar que la idea es mala. Pero las redes profundas funcionan en otros motores, así que algo mío tenía que estar mal.
La lista de sospechosos
1. ¿Evalúa mal el motor?
Si el motor calculara la red de forma distinta al entrenador, cualquier red buena parecería mala. Lo comprobé haciendo la pasada de la red en coma flotante directamente desde los pesos que guarda el entrenador y comparándola con la salida del motor.
Veredicto: el motor estaba bien. Lo que estaba mal eran dos herramientas de verificación en Python que había usado antes: leían los pesos sin la transposición que aplica el formato de guardado. Durante un tiempo había estado comparando el motor con una referencia equivocada.
2. ¿Rango comprimido?
En posiciones muy desequilibradas, la red profunda daba evaluaciones unas cinco veces más pequeñas que la lineal. Parecía «tímida». Probé a expandir su rango con una transformación monótona en inferencia, sin reentrenar.
Veredicto: perdió 49,5 Elo en SPRT. La compresión era real, pero no era la causa.
3. ¿La sigmoide del entrenamiento?
La red aprendía poco en la franja de 1000 a 2000 centipeones, y la sospecha era la sigmoide con la que el entrenamiento convierte evaluaciones en probabilidades. Doblé su escala, de 400 a 800.
Veredicto: la métrica se arregló por completo (la correlación en esa franja pasó de 0,13 a 0,87) y el Elo no se movió: −263. Fue la primera pista de algo que acabó siendo una lección: las métricas de calidad estática no predicen el Elo.
4. ¿El recorte de pesos?
Revisando el entrenador (bullet) encontré que su configuración por defecto recorta todos los pesos a ±1,98, también los de las capas que se guardan en coma flotante y que no lo necesitan. El percentil 99 de una de esas capas era 1,979: los pesos estaban literalmente pegados al techo.
Veredicto: defecto real. Lo quité y el Elo siguió igual.
5. ¿Neuronas saturadas?
Las activaciones de la primera capa oculta se saturaban mucho. Añadí una neurona lineal extra para dar a la red una vía sin saturación.
Veredicto: la saturación bajó y la compresión siguió idéntica. Era un síntoma, no la causa.
6. ¿Márgenes de búsqueda heredados?
La búsqueda de un motor tiene márgenes de poda ajustados a la escala de su evaluación, y yo estaba usando con las redes nuevas los márgenes ajustados para la vieja.
Veredicto: importaba, y mucho: movía unos 110 Elo. Pero seguía sin explicar el déficit.
7. El culpable: go nodes
Para comparar dos redes por su calidad, sin que cuente la velocidad, las enfrentaba a nodos fijos: cada motor examina exactamente N posiciones por jugada. Una red profunda es más lenta, pero a nodos fijos eso no debería importar.
El 27 de septiembre descubrí que el intérprete del protocolo UCI de Falcon ignoraba la orden go nodes. El motor recibía «busca 25.000 nodos», no le hacía caso y jugaba por tiempo. Todos mis matches «a nodos fijos» se habían jugado a tiempo, y por tanto mezclaban calidad y velocidad. Llevaba semanas intentando arreglar la calidad de unas redes cuyo problema, en buena parte, era otro.
Lo que pasaba de verdad
Con go nodes arreglado repetí la medida con una réplica de la receta pública de redes profundas de bullet:
| Ritmo | Réplica frente a la red lineal |
|---|---|
| 5.000 nodos por jugada | −21,3 |
| 25.000 nodos | +2,0 |
| 100.000 nodos | +25,7 |
| A tiempo (40 s + 0,4 s) | −17,7 |
Pasa el cursor o pulsa un punto para leer su valor.
Esta tabla lo explica todo. La red profunda es mejor y escala mejor cuanto más busca el motor: con más nodos, su ventaja crece. Lo que la frenaba a ritmo real era la velocidad. Cada posición le cuesta más de evaluar, así que en el mismo tiempo examina menos posiciones y pierde lo que ganaba en calidad.
Y en mi caso la velocidad pesa más que en otros motores. Falcon juega en un portátil con un i5-7300U y solo 3 MiB de caché L3, que limita cuánto puede crecer la red sin que todo se vuelva lento.
Hacia r2
Con el diagnóstico claro, el camino fue ensanchar el FT y adelgazar la cabeza. Una versión con FT de 1536 ganaba a la réplica (+32,2 a 25.000 nodos, +37,5 a 100.000), pero sin optimizar corría a una quinta parte de la velocidad de la lineal. Reduje la primera capa oculta a solo 8 neuronas: 1536 → 8 → 32 → 1.
El primer entrenamiento salió roto: uno de los ocho grupos de salida de la red tenía todas sus neuronas muertas desde muy pronto, y la red perdió 594 Elo. Lo reentrené con otra semilla aleatoria y salió r2:
- +44,4 a 25.000 nodos y +62,0 a 100.000 frente a la lineal.
- +16,9 ±7,5 en SPRT a ritmo real (8 s + 0,08 s, 2572 partidas).
r2 entró en producción el 30 de septiembre, con una inferencia AVX2 optimizada que da exactamente los mismos resultados que la de referencia. Fue la primera red profunda de Falcon que ganó a la lineal donde importa: jugando de verdad. Lo de las neuronas muertas merece artículo propio, y es el siguiente de la serie.
Por qué tardé tanto
No había un defecto: había varios superpuestos. Algunos eran reales (el recorte de pesos, los márgenes, las herramientas de verificación) y estuvo bien arreglarlos, pero ninguno era la causa. Cada arreglo que no movía el Elo parecía una prueba más de que las redes profundas no funcionaban conmigo, cuando en realidad estaba midiendo mal.
Tres lecciones que me llevo:
- Comprobar que la medida mide lo que dice. Un fallo en el protocolo convirtió «nodos fijos» en «tiempo fijo» sin avisar.
- Verificar contra el entrenador, no contra otra herramienta. Si dos implementaciones comparten el mismo error, compararlas entre sí no demuestra nada.
- El árbitro es la partida. Las métricas de calidad mejoraron en redes que luego perdieron 250 Elo.
Desde entonces la cabeza profunda ha seguido mejorando, con una activación dual (+6,3) y una cola de entrenamiento más larga (+3,7). Pero esa es otra historia.