我追查一个 NaN 的那一周
某一天,我的训练损失不再是一个数字了。不是很高,也不是不稳定——而就是 NaN,计算机被要求去做一道没有答案的算术时所举起的那面小旗。然后我花了将近一周才弄清楚原因,因为症状出现的地方,离问题真正所在的地方相去甚远。
这是最不提供信息的失败
某种意义上,NaN 几乎是可能存在的最糟糕的错误消息,因为它几乎算不上是一条消息。崩溃至少会指向某一行。异常会带着一个类型和一段调用栈。而 NaN 只是悄无声息地污染它所触及的一切,然后继续往下走:把它加到任何东西上,得到的是 NaN;把它乘以任何东西,得到的还是 NaN;而以普通的浮点聚合,把一百万个完全没问题的数字、再加上其中混进的一个 NaN 一起取平均,整个平均值算出来就是 NaN。
所以等到它在我的损失里浮现出来时——响亮、终结、让训练停下——它几乎没告诉我那个真正的坏值是从哪里进来的。症状离起因差不多已经是最远的距离了,中间隔着一长串算术,每一环都忠实地把这份毒素往下传,毫无怨言。
调试一个 NaN 就是考古
在这件事里,没有一种干净的办法能正向地调试它。你能看到的那个 NaN 是一个故事的结尾;引发它的那个坏值发生在更早的时候,在上游的某处,并且从那以后已经被一层又一层的计算洗白了。所以你只能反向工作。你在每一个阶段加上检查——这里的值是有限的吗?这里呢?这里呢?——逆着管道一路走回去,寻找一个真实的数字第一次变成非真实数字的地方。
这是缓慢、不光彩的活儿,它给人的感觉与其说像编程,不如说像挖掘:往回挖穿一层层变换的地层,去找出事情出错的那个确切的层。我生命中的一周就投进了那场挖掘。
罪魁祸首:一个被应用了两次的变换
我最终挖出来的东西小得几乎让人觉得受了侮辱。在我的预处理里某个地方,有一个变换被应用到了某个值上——然后,由于我几周前做过的一次重构,它又被第二次应用到了这个值上。每一次应用单独看都完全正确。第一次精确地做了它该做的事。第二次则拿着那个已经被变换过的值,把它推到了数学再也跟不上的地方,推进了一个该运算根本没有定义结果的区域。出来的是某个不是数字的东西,然后它一路往下游而去,在一周之后以一个死掉的损失的形式浮出水面。
这个 bug 不是一个错误的变换。它是一个正确的变换,只是多运行了一次,因为代码中两个不同的部分各自都以为自己负责它。
为什么预处理偏偏会孕育出这种 bug
这类事情不是运气不好;当你任由预处理管道四处蔓延时,它就是会产生这样的结果。数据准备有一种把自己抹散到整个代码库里的本事——这里一点重塑,那里一点缩放,由不同的函数、在不同的时间写就、在不同的地方应用。而一旦同一个变换可以通过两条不同的路径被触及,某个值同时走过这两条路、从而被变换两次的风险就增大了。
孤立地看,这两条路径都没有任何问题。这正是它如此难以察觉的原因。这个 bug 并不住在任何单独的一段代码里;它住在两段代码之间的关系里,而这两段代码彼此都不知道对方的存在。
修复:一个地方,一次
这次修复并不是给那行坏代码打个补丁。它是结构性的。我把那些变换从它们散落、堆积的各个地方拽了出来,放进一个单一的、集中的阶段,并立下一条清晰的规则:每个值都在这里被变换,恰好一次,别的地方都不行。在那之后,任何东西都没有第二条路径可走了。那个特定的 NaN 消失了,但更重要的是,它减少了那些可能发生不一致应用的路径的数量——不只是修好了这个 bug,而是缩小了它的同类得以出现的空间。
那才是值得去追的那种修复。给那一个坏值打补丁本可以让这个 NaN 消失。而把变换集中起来,则让这个 NaN 在未来的许多个版本也变得不那么可能发生了。
真正的教训:留意起因与症状之间的距离
让这个 bug 代价高昂的,不是它的难度。是它的距离。失败出现在一个地方,起因坐在一个完全不同的地方,而除了一长串沉默的、尽职尽责地把一个坏值从这一端运到另一端的数学之外,没有任何东西明显地把它们连起来。
真正能对抗那种距离的防御,不是聪明。它们是结构和检查:把危险的操作集中起来,这样它们只有一个地方可能出错;并在边界处校验值——在一个数字被产生出来的那一刻附近就检查它是不是有限的,而不是等到一周后它终于引爆。一个在出生处就被抓住的坏值可能花掉你几分钟。同一个坏值若是在它爆炸处才被抓住,则可能花掉你一周。
— 没有信号,没有收益,不构成投资建议。