最近看到侯晓迪的一个回答,提到一个看待Learning问题的三层角度,分别是theory,formulation,implementation,其中最关键的是formulation。所谓formulation需要取舍模型的复杂度,针对具体数据设计模型假设,依据假设选择模型,分析算法失效的根本原因等。而这篇博客则关注low level的implementation问题,同时也是受《Hidden Technical Debt in Machine Learning Systems》的启发,结合最近所做项目做一些工程上的思考。这篇论文整体上风格类似软工,其实读起来并不容易,需要后续结合实践,深入思考和理解。

一个实际的机器学习系统如下图所示,关键是机器学习代码在一个完整的系统中只会占据很少很少的一部分!!!(少却精!!!)

ML System

1.正向和逆向思考

对于一个完整的Pipeline,某个stage处理之后,结果不符合预期怎么办?假设该stage阶段时间成本巨高呢?比如耗费一周时间进行特征生成,发现生成的某个特征存在一些不符合预期的错误。面对这种问题,正向处理的手段是定位错误,重新生成;考虑到时间成本,更合理的方式是逆向处理,错误修正。但是要考虑到修正操作的时间成本和重新生成的时间成本,同时对于一个系统工程,新的操作引入是否会引入新的错误同时引发连锁反应,是是否采用错误修正要考虑的问题。很简单的观点,是吗?发现错误的时候,大多数人会立刻产生对重新生成时间的重重担忧(WTF!!!),也许没有必要呢。

例如,花了一天时间,生成了三个预处理后的train/dev/test文件,但是为了保证i.i.d,需要shuffle并重新划分,这个时候显然不是将shuffle操作放在预处理过程,而是写一个只有几行代码的shell脚本完成。很多时候,Linux不会让你失望!

2.规模效应

任何一个复杂度很高的操作,在面对小规模数据时都不是问题,我们很难觉察到复杂度的问题,这种体验是和复杂度的概念定义相符合的。但是任何一个我们认为复杂度较低的操作,在面临大规模数据时,都会产生规模问题。

例如,在特征生成阶段,创造的规则是各个特征集合的笛卡尔积,首先规则的添加会导致组合爆炸,姑且称之为策略规模;其次数据量增加的时候,会遇到数据规模问题。一种简单的解决方式是多进程处理。

例如,对于两个for-loop,loop的max_size都很大,内部for需要解决一个类型转换的问题,如list(data),其中data是ndarray类型。当数据规模较大时,通过将类型转换问题移出inner-loop,大大减少了时间消耗。这里涉及一个程序的局部性的问题,局部性问题可以体现在软硬件上的方方面面,可以参考《深入理解计算机系统》这本书。不过这里更有趣的一个问题是时间消耗,实际上上述的一个简单操作,最终时间消耗变为原来的1/30而已,但是考虑到base,就很显著了。原来需要30个小时处理,也就是一天多的时间,现在只需要1个小时;假设原来需要30年,现在只需要1年了。只有深入体验这种时间消耗,才能体会出规模带来的诸多问题。一天多和30年,会发生很多意想不到的事情。

例如,一个XML文件包含1000W个文本段,每个文本段用两个DOC标签包住。现在遇到的一个问题是,其中有两个文本段各少了一个DOC标签,需要找到文本段并补全。规模的问题这里如何体现呢?假设只有10个文本段,人肉搜索就OK了;100个文本段,线上XML格式检错系统;1000W个呢?可能用VIM能不能打开都是问题,更不要说去复制文本,然后将文本粘贴在线上输入框,线上检错系统是否会Down掉。解决的方式,小心设计一个合理的策略定位文本段。

类似规模问题,遇到的太多了。一个好用,扩展性好的代码或者系统,不能忽略规模问题。评估一个操作的好坏,要考虑到规模问题,其实是时间复杂度的考察。同时从另外一方面,在完成一个机器学习模块的时候,第一位是数据,数据的规模是很重要的一个考察因素,这个因素直接影响到后续的诸多操作和设计。实际上,多数情况下我们不会按照数据,特征和模型的步骤去走流程,而是不断的推翻,反馈,重来。那么每个阶段都会遇到不同的问题,问题在每个阶段都会以不同的方式和面貌呈现出来。这些其实是软件工程的内容了。

3.The Devil is in the Details

从数据角度聊聊这个问题。大多数时候学术界和工业界面对的数据是不太相同的。学术界的刷分是针对于特定数据集,这些数据集通常经过很多年的迭代,被很多人,各种模型检验修正过,可以认为基本没有错误。这里的错误的含义是指:较少的标注错误,较少的格式错误,较少的异常数据等。其实很多时候数据竞赛圈面临的数据要比学术圈的更加真实,但是新比赛出来的时候,往往都会以各种方式发现数据的错误。工业界的数据最为复杂,这种复杂体现在多个方面,来源,类型,格式等。

例如,我的模型NaN了。可能的一种情形是针对检测任务,训练数据中BBox打成一条线了,而不是一个框。在ECCV2018的一个比赛VisDrone Challenge-Image Detection中,就遇到了这种问题。

例如,在1100W的XML文件中,解析的时候挂了。发现文本段中有<>和Y&Y之类的字符。

大多数时候,我们对于数据的假定,起码在格式上是正确的。因此,当出现错误的时候,可能不会怀疑到数据本身,且不说模型假设和数据分布的一致性问题。我猜,大多数人的第一反应是一定是我的模型参数有问题。这种直觉带来的一个巨大的问题是它会将解决的方式和思路引上歧途,定位不到问题,更不要谈及问题的解决。

4.手动和机器

合理评估待解决问题的规模,选择手动或者机器方式解决。人类的惰性决定了人类可能对手动会更加自信,实际上,当你决定手动修正问题的时候,将预估时间乘上50倍(或许这个系数太小了)就是你的实际时间了。在手动修正的过程中,修正单个错误的心理耗费也会越来越贵,直到将自己逼疯。疯过,所以懂得。

5.设计合适的Debug策略

单行Debug多数时候能够解决问题,但是一不定能够高效解决问题。遇到问题时,冷静评估一下问题的难度,设计一个合适的Debug策略,很多时候可以高效解决问题,或许在有些时候,策略设计是必须的。

6.系统边界和工具极限

大多数情况下,我们处理的问题距离系统边界足够远,距离工具极限也是足够的远。但是,距离远并不意味着边界和极限不存在。

例如,原来的一个训练集是30000样本,测试集是5000样本;现在将训练集增加到60W样本,扩大20倍,测试集扩大2倍,使用Tensorflow,原有的Pipeline并未发生改变,出现如下错误:

[libprotobuf FATAL external/protobuf_archive/src/google/protobuf/message_lite.cc:68] CHECK failed:
(byte_size_before_serialization) == (byte_size_after_serialization): tensorflow.GraphDef was modified concurrently during serialization.                                    
terminate called after throwing an instance of 'google::protobuf::FatalException' what():  CHECK failed:
(byte_size_before_serialization) == (byte_size_after_serialization): tensorflow.GraphDef was modified concurrently during serialization.

好吧,将训练集改为30000样本,出现如下错误:

Hint: If you want to see a list of allocated tensors when OOM happens,
add report_tensor_allocations_upon_oom to RunOptions for current allocation info.

这其实也是一个规模问题。我们在一个复杂的软硬件体系下工作,相比下来,触及软件极限可能是个非常有挑战性的问题,然而机器学习尤其是深度学习模块多数场景下触及的是硬件极限。

总结:虽然同属软工,但是机器学习系统的开发有其自身的特点,这些特点决定了开发过程的不同体验。从原型到实际部署上线,后期维护,产品迭代等,一个完整的周期细节较多,希望你玩儿的开心!