Неделя, которую я потратил на погоню за NaN

Однажды мой обучающий loss перестал быть числом. Не высоким, не нестабильным — просто NaN, тот маленький флаг, который компьютер поднимает, когда его просят выполнить арифметику, у которой нет ответа. А затем у меня ушла большая часть недели, чтобы выяснить почему, потому что место, где проявился симптом, находилось вовсе не там, где на самом деле жила проблема.

Самый неинформативный отказ, какой только бывает

NaN — это, в некотором смысле, почти наихудшее из возможных сообщений об ошибке, потому что это едва ли вообще сообщение. Падение хотя бы указывает на строку. Исключение несёт тип и трассировку. А NaN просто тихо отравляет всё, к чему прикасается, и продолжает дальше: прибавь его к чему угодно — и получишь NaN, умножь на что угодно — и получишь NaN, а при обычном суммировании с плавающей точкой усредни миллион совершенно нормальных чисел с одним NaN среди них — и всё среднее выйдет как NaN.

Так что к тому моменту, когда он всплыл в моём loss — громко, окончательно, останавливая обучение, — он не сказал мне почти ничего о том, где на самом деле вошло плохое значение. Симптом был настолько далёк от причины, насколько это вообще возможно, с длинной цепочкой арифметики между ними, каждое звено которой исправно передавало отраву дальше без единой жалобы.

Отладка NaN — это археология

В данном случае не было чистого способа отлаживать его вперёд. NaN, который ты видишь, — это конец истории; плохое значение, с которого она началась, возникло раньше, где-то выше по течению, и с тех пор было отмыто через слой за слоем вычислений. Поэтому ты работаешь в обратном направлении. Ты добавляешь проверки на каждой стадии — конечно ли значение здесь? здесь? здесь? — проходя конвейер задом наперёд, в поисках первого места, где настоящее число стало ненастоящим.

Это медленная, неблагодарная работа, и она ощущается не столько как программирование, сколько как раскопки: вскапывание назад сквозь пласты преобразований, чтобы найти ровно тот слой, где всё пошло не так. Неделя моей жизни ушла на эти раскопки.

Виновник: преобразование, применённое дважды

То, что я в итоге откопал, было почти оскорбительно мелким. Где-то в моём препроцессинге преобразование применялось к значению — а затем, из-за рефакторинга, который я сделал неделями раньше, применялось к нему второй раз. Каждое применение само по себе было совершенно корректным. Первое делало ровно то, что было положено. Второе брало уже преобразованное значение и заталкивало его туда, куда математика уже не могла последовать, в область, где у операции просто нет определённого результата. Наружу выходило нечто, что не было числом, и оно уходило прочь, вниз по течению, чтобы всплыть неделей позже в виде мёртвого loss.

Баг был не в неверном преобразовании. Это было верное преобразование, которое отработало на один раз больше, чем нужно, потому что две разные части кода каждая считали, что отвечают за него именно они.

Почему препроцессинг порождает именно этот баг

Подобные вещи — не невезение; это то, что конвейеры препроцессинга склонны производить, когда даёшь им разрастаться. Подготовка данных имеет свойство размазываться по кодовой базе — немного переформатирования здесь, немного перешкалирования там, применяемые в разных местах разными функциями, написанными в разное время. И в тот момент, когда одно и то же преобразование может быть достигнуто двумя разными путями, растёт риск, что какое-то значение пройдёт оба и будет преобразовано дважды.

Ни один из путей в отдельности не является неверным. Именно это и делает его таким трудным для обнаружения. Баг не живёт ни в одном отдельном куске кода; он живёт в отношении между двумя кусками, ни один из которых не знает о другом.

Исправление: одно место, один раз

Починка была не заплаткой на плохую строку. Она была структурной. Я вытащил преобразования из разрозненных мест, где они наросли, и поместил их в единую центральную стадию с одним ясным правилом: каждое значение преобразуется здесь, ровно один раз, и больше нигде. После этого не осталось второго пути, по которому что-либо могло бы пройти. Конкретный NaN исчез, но, что важнее, это сократило число путей, на которых вообще могло случиться несогласованное применение, — не просто исправив этот баг, а сжав пространство, в котором могли бы появиться его собратья.

Вот такое исправление стоит того, чтобы за ним гнаться. Залатывание одного плохого значения заставило бы этот NaN уйти. Централизация преобразования сделала и многие будущие версии этого NaN куда менее вероятными.

Настоящий урок: следи за расстоянием между причиной и симптомом

Дорогим этот баг сделала не его сложность. Дело было в расстоянии. Отказ проявился в одном месте, причина сидела в совершенно другом, и ничто видимо не связывало их, кроме длинной, безмолвной цепочки математики, которая послушно несла плохое значение от одного конца к другому.

Защиты, которые действительно помогают против такого расстояния, — это не изобретательность. Это структура и проверки: централизуй опасные операции, чтобы у них было лишь одно место, где можно ошибиться, и валидируй значения на границах — проверяй, что число конечно, близко к моменту его рождения, а не неделей позже, когда оно наконец детонирует. Плохое значение, пойманное там, где оно родилось, может стоить тебе минут. То же самое плохое значение, пойманное там, где оно взрывается, может стоить тебе недели.

— Никаких сигналов, никакой доходности, не инвестиционный совет.