La semana que pasé persiguiendo un NaN
Un día mi loss de entrenamiento dejó de ser un número. No alto, no inestable — simplemente NaN, la pequeña bandera que una computadora levanta cuando se le pide hacer una aritmética que no tiene respuesta. Y entonces me llevó la mayor parte de una semana averiguar por qué, porque el lugar donde apareció el síntoma estaba muy lejos del lugar donde el problema realmente vivía.
El fallo más poco informativo que existe
Un NaN es, en cierto modo, casi el peor mensaje de error posible, porque apenas es un mensaje. Un crash al menos señala una línea. Una excepción lleva un tipo y un trace. Un NaN simplemente envenena en silencio todo lo que toca y sigue adelante: súmalo a cualquier cosa y obtienes NaN, multiplícalo por cualquier cosa y obtienes NaN, y con la agregación ordinaria de punto flotante, promedia un millón de números perfectamente buenos con un solo NaN entre ellos y el promedio entero sale NaN.
Así que para cuando salió a la superficie en mi loss — estridente, definitivo, deteniendo el entrenamiento — no me dijo casi nada sobre dónde había entrado el valor malo real. El síntoma estaba tan lejos como podía estarlo de la causa, con una larga cadena de aritmética entremedio, cada eslabón de la cual pasaba fielmente el veneno sin quejarse.
Depurar un NaN es arqueología
En este caso, no había una forma limpia de depurarlo hacia adelante. El NaN que puedes ver es el final de una historia; el valor malo que la inició ocurrió antes, en algún lugar aguas arriba, y desde entonces ha sido lavado a través de capa tras capa de cómputo. Así que trabajas hacia atrás. Añades comprobaciones en cada etapa — ¿es el valor finito aquí? ¿aquí? ¿aquí? — recorriendo el pipeline en reversa, buscando el primer lugar donde un número real se volvió uno no-real.
Es un trabajo lento, sin glamour, y se siente menos como programar que como excavar: cavando hacia atrás a través de estratos de transformaciones para encontrar la capa exacta donde las cosas salieron mal. Una semana de mi vida se fue en esa excavación.
El culpable: una transformación aplicada dos veces
Lo que finalmente desenterré era casi insultantemente pequeño. En algún lugar de mi preprocesamiento, una transformación se estaba aplicando a un valor — y luego, debido a un refactor que había hecho semanas antes, se le aplicaba una segunda vez. Cada aplicación era perfectamente correcta por sí sola. La primera hizo exactamente lo que se suponía que debía hacer. La segunda tomó el valor ya transformado y lo empujó a algún lugar que las matemáticas ya no podían seguir, a una región donde la operación simplemente no tiene resultado definido. Salió algo que no era un número, y se fue, aguas abajo, para aflorar una semana después como un loss muerto.
El bug no era una transformación incorrecta. Era una transformación correcta que se ejecutó una vez de más, porque dos partes diferentes del código creían cada una que eran responsables de ella.
Por qué el preprocesamiento engendra exactamente este bug
Esta clase de cosas no es mala suerte; es lo que los pipelines de preprocesamiento tienden a producir cuando los dejas extenderse. La preparación de datos tiene una forma de quedar embarrada por todo un codebase — un poco de remodelado aquí, un poco de reescalado allá, aplicado en diferentes lugares por diferentes funciones escritas en diferentes momentos. Y en el momento en que la misma transformación puede alcanzarse por dos caminos diferentes, crece el riesgo de que algún valor recorra ambos y sea transformado dos veces.
Nada en ninguno de los dos caminos está mal de forma aislada. Eso es lo que lo hace tan difícil de ver. El bug no vive en ninguna pieza individual de código; vive en la relación entre dos piezas, ninguna de las cuales sabe de la otra.
El arreglo: un solo lugar, una sola vez
La reparación no fue un parche a la línea mala. Fue estructural. Saqué las transformaciones de los lugares dispersos en los que se habían acumulado y las puse en una única etapa central con una regla clara: cada valor se transforma aquí, exactamente una vez, y en ningún otro lugar. Después de eso, no había un segundo camino que nada pudiera recorrer. El NaN específico desapareció, pero más importante, redujo la cantidad de caminos donde la aplicación inconsistente podía ocurrir siquiera — no meramente arreglando este bug, sino encogiendo el espacio en el que sus hermanos podían aparecer.
Esa es la clase de arreglo que vale la pena perseguir. Parchear el único valor malo habría hecho desaparecer este NaN. Centralizar la transformación hizo que muchas versiones futuras de este NaN fueran también mucho menos probables.
La verdadera lección: cuida la distancia entre causa y síntoma
Lo que hizo costoso a este bug no fue su dificultad. Fue la distancia. El fallo apareció en un lugar, la causa se sentaba en un lugar completamente diferente, y nada los conectaba visiblemente salvo una larga y silenciosa cadena de matemáticas que llevaba diligentemente un valor malo de un extremo al otro.
Las defensas que realmente ayudan contra esa clase de distancia no son la astucia. Son la estructura y las comprobaciones: centraliza las operaciones peligrosas para que solo haya un lugar donde puedan salir mal, y valida los valores en las fronteras — comprueba que un número es finito cerca del momento en que se produce, no una semana después cuando finalmente detona. Un valor malo atrapado donde nace puede costarte minutos. El mismo valor malo atrapado donde explota puede costarte una semana.
— Sin señales, sin retornos, no es asesoramiento de inversión.