NaNを追いかけて費やした一週間
ある日、私のトレーニング損失は数値であることをやめた。高くもなく、不安定でもなく——ただNaN、答えのない計算をするよう求められたときにコンピュータが掲げる小さな旗だった。そして、その理由を突き止めるのに一週間近くを費やすことになった。なぜなら、症状が現れた場所は、問題が実際に潜んでいた場所からはるかに離れていたからだ。
これ以上ないほど情報のない失敗
NaNは、ある意味で考えうる最悪のエラーメッセージに近い。なぜなら、それはほとんどメッセージですらないからだ。クラッシュなら少なくとも行を指し示す。例外は型とトレースを伴う。NaNはただ静かに、触れたものすべてを汚染しながら進み続ける。何かに足せばNaNになり、何かに掛けてもNaNになり、通常の浮動小数点の集計では、百万個の申し分ない数値を一つのNaNと一緒に平均すれば、平均値全体がNaNになって出てくる。
だから、それが私の損失に浮上した頃には——けたたましく、決定的に、トレーニングを止めながら——実際の不正な値がどこで入り込んだのかについて、ほとんど何も教えてはくれなかった。症状は原因からこれ以上ないほど遠く離れており、その間には長い計算の連鎖があって、その鎖の一つひとつが文句も言わずに忠実に毒を伝えていったのだ。
NaNのデバッグは考古学だ
このケースでは、それを前向きにデバッグするきれいな方法はなかった。目に見えるNaNは物語の終わりであり、それを始めた不正な値はもっと前に、どこか上流で起き、それ以来、層から層へと計算を経て洗い流されてきた。だから後ろ向きに作業する。各段階でチェックを加える——ここで値は有限か? ここは? ここは?——パイプラインを逆向きに辿り、実数が非実数になった最初の場所を探す。
それは遅く、華やかさのない作業で、プログラミングというよりは発掘のように感じられる。変換の地層を掘り返して、物事がうまくいかなくなった正確な層を見つけるのだ。私の人生の一週間が、その発掘に費やされた。
犯人:二度適用された変換
最終的に掘り出したものは、ほとんど侮辱的なほど小さかった。私の前処理のどこかで、ある変換が値に適用されていた——そして、数週間前に私が行ったリファクタリングのせいで、それが二度目に適用されていたのだ。各適用はそれ自体としては完全に正しかった。最初のものは、まさにやるべきことをやった。二度目のものは、すでに変換済みの値を受け取り、それを数学がもはや追えない場所へ——演算に定義された結果が存在しない領域へと押しやった。出てきたのは数値でないものであり、それは下流へと去っていき、一週間後に死んだ損失として浮上したのだ。
そのバグは、間違った変換ではなかった。それは正しい変換が一度多く実行されたものだった。なぜなら、コードの二つの異なる部分が、それぞれ自分がその責任を負っていると思い込んでいたからだ。
なぜ前処理はまさにこのバグを生むのか
この種のことは不運ではない。前処理パイプラインを際限なく広がるに任せたときに、それが生み出しがちなものなのだ。データ準備はコードベース全体に塗り広げられる傾向がある——ここで少し形を変え、あそこで少しスケールを変え、異なる時期に書かれた異なる関数によって、異なる場所で適用される。そして、同じ変換が二つの異なる経路から到達できるようになった瞬間、ある値が両方を通って二度変換されるリスクが高まる。
どちらの経路も、単独では何も間違っていない。だからこそ、それはとても見つけにくい。バグはコードのどの単一の断片にも宿らない。それは二つの断片の関係の中に宿り、そのどちらも互いの存在を知らないのだ。
修正:一か所で、一度だけ
修復は不正な行へのパッチではなかった。それは構造的なものだった。私は、散らばった場所に堆積していた変換を引き抜き、一つの明確なルールを持つ単一の中央段階にまとめた——各値はここで、ちょうど一度だけ変換され、他のどこでも変換されない。その後、何かが通る二つ目の経路はなくなった。特定のNaNは消えたが、より重要なのは、一貫性のない適用が起こりうる経路の数そのものを減らしたことだ——単にこのバグを直すだけでなく、その兄弟たちが現れうる空間を縮めたのだ。
それこそが追いかける価値のある種類の修正だ。一つの不正な値にパッチを当てれば、このNaNは消えただろう。変換を中央集約することで、このNaNの将来の多くのバージョンも、はるかに起こりにくくなった。
本当の教訓:原因と症状の間の距離に気をつけよ
このバグを高くついたものにしたのは、その難しさではなかった。それは距離だった。失敗はある場所に現れ、原因はまったく別の場所に座っており、両者を目に見えてつなぐものは、一方の端から他方の端へと不正な値を律儀に運んだ、長く沈黙した数学の連鎖以外には何もなかった。
その種の距離に対して実際に役立つ防御は、賢さではない。それは構造とチェックだ——危険な演算を中央集約して、それが間違いうる場所を一か所だけにし、境界で値を検証する——数値が生み出された瞬間の近くで、それが有限であることを確認するのであって、一週間後にそれがついに爆発するときではなく。生まれた場所で捕まえられた不正な値は、あなたに数分のコストで済む。同じ不正な値を爆発する場所で捕まえると、あなたに一週間のコストがかかりうる。
— シグナルなし、リターンなし、投資助言ではない。