译自:How to Code Neat Machine Learning Pipelines
说明:翻译只是为了加强思考,请阅读原文或者对照阅读。
编写机器学习流水线,此乃正解。
你是否曾经编写过这样的机器学习流水线:它运行时间非常很长?或者更悲剧的,你是否到过这样的节点:你需要把中间结果保存在磁盘上以便使用检查点一次把精力集中在一个步骤上?或者更悲惨的,你是否尝试重构写得不好的机器学习代码以便把它放到线上环境,而这花费了你数月时间?好吧,我们都在搭建机器学习流水线上工作过很长时间。我们应该如何构建一个流水线,既有一定的自由度,又可以在日后轻松重构为线上代码呢?
首先,我们将定义机器学习流水线,并探索在流水线步骤间使用检查点的想法。接着,我们来看一下如何实现这样的检查点,以免在把代码放到线上时弄得搬起石头砸到自己的脚。我们也讨论了数据流,以及在流水线中选择超参数时采用面向对象编程封装的权衡。
什么的是流水线
流水线就是一系列用于转换数据的步骤。它来自于古老的“管道和过滤器”设计模式(举例来说,你可以认为它是Unix的命令中的管道符“|”和重定向操作符“>”)。只是,流水线是代码中的对象。所以,每个过滤器有一个类(或者说是每个流水线步骤),然后另外一个类把这些步骤组合最终的流水线。有些流水线会串行的或者并行的包含其他流水线,拥有多个输入或输出,等等。我把机器学习流水线视作:
- 管道和过滤器。这些流水线步骤处理数据,并且管理它们自身的状态,这些状态可以从数据中学到。
- 组合。流水线可以嵌套:比如一个流水线可以当作一个单独的流水线步骤放到另一个流水线中。一个流水线步骤不一定是一个流水线,但一个流水线自己至少是一个流水线步骤。
-
有向无环图(DAG)。一个流水线步骤的输出可以被传给许多其他的步骤,输出也可以进行组合。注意:尽管流水线是无环的,它们可以依次处理多个数据项,而一旦它们的状态发生了变化(例如:每次使用
fit_transform
方法时),它们就被视为随时序循环展开,保持它们的状态(类似于RNN)。一个非常有意思的事情是让流水线在部署到生产环境中后进行在线学习,使用更多的数据训练它们。
流水线的方法
流水线(或者流水线中的步骤)必须有下面的两个方法:
- “
fit
”用来从数据中学习,从而获取状态(例如:神经网络中的神经权重就是这样的状态) - “
transform
”(或者“predict
”)用来真正的处理数据并生成预测。
注意:如果流水线中的一个步骤不需要这里面的某个方法,它可以从NonFittableMixin
或者NonTransformableMixin
继承得到这个方法的默认实现,当然默认实现并不做什么事。
很可能,流水线或者它们的步骤会定义以下可选方法:
- “
fit_transform
”用来在一遍计算中适应并转换数据,与必须让这两种操作依次执行相比,它提供了代码优化的潜力。 - “
setup
”会调用每一步骤的setup
方法。举例来说,如果一个步骤包含TensorFlow、PyTorch、或者Keras神经网络,这些步骤可以在训练之前在setup
方法中创建它们的神经图并把它们注册到GPU中。不建议在这些步骤的构造函数中直接创建图,原因有若干,比如在一个为你搜索最佳超参数的自动化机器学习算法中,这些步骤是否会被复制后用不同的超参数多次运行。 - “
teardown
”是setup
方法的逆向操作,它负责清除资源。
为了管理超参数,下面这些方法是默认提供的:
- “
get_hyperparams
”会返回超参数的字典。如果你的流水线包含更多流水线(嵌套流水线),超参数的键是用两个下划线“__
”作分隔符拼接起来的。 - “
set_hyperparams
”允许你以获取超参数时的格式设置新的超参数。 - “
get_hyperparams_space
”允许你获取超参数的空间,如果你定义了一个超参数空间,它就不为空。它与get_hyperparams
的区别是它返回统计分布的多个值,而不是一个精确的值。例如,一个超参数表示层数的取值可能是RandInt(1, 3)
,意思是1到3层。你可以调用字典的.rvs
方法随机选取一个值传给set_hyperparams
并尝试用它来训练。 - “
set_hyperparams_space
”可以用于设置新的空间,它使用与get_hyperparams_space
相同的超参数分布类。
重训练流水线、小批量以及在线学习
对于像用于训练深度神经网络(DNN)的小批量算法,或者像强化学习(RL)算法中的在线学习算法,最理想的是,流水线或者流水线步骤能够通过一个接一个链接多次对于fit
的调用来更新他们自己,在现场在小批量上进行重训练。一些流水线和流水线步骤支持这样做,但是,有些步骤当再次被调用到fit
就会重置自己。这取决于你是怎样为你的流水线步骤编码的。理想的情况是你的流水线步骤只在调用teardown
方法时进行重置,在下次训练前再次setup
,而在每次训练之间不要重置,在转换期间也不要重置。
在流水线中使用检查点
在流水线中使用检查点是个好想法 - 直到你需要重用这些代码做其他事或者变更数据。如果你在代吗中没有使用合理的抽象,你可能会搬起石头砸了自己的脚。
在流水线中使用检查点的好处
- 检查点可以提高编写代码的速度。当编写或者调试流水线中间或结尾的那些步骤时,可以避免每次都去运行前面那些步骤。
- 当进行超参数优化时(或者是手工调参和元学习),可以避免重复计算前面的步骤。例如,你的流水线的开头部分没有超参数或者只有很少的超参数的话,在你的调参过程中开头部分很可能都是一样的。有了检查点,就能够从超参数发生改变的位置之前的检查点继续运行。
- 如果你受到硬件能计算力的限制,可能可行的办法就是一次只运行一步。你可以使用检查点,并逐步在检查点的基础上增加后面的步骤。
在流水线中使用检查点的坏处
- 如果做得不好,它会因为读写磁盘而使你的代码变慢。你可以使用RAM Disk或者把缓存文件夹映射到内存的方式来加速。
- 它会占用大量的磁盘空间。如果使用了内存映射文件的话,也会需要大量内存。
- 保存在磁盘上的状态管理起来比较困难:这给你的代码增加了复杂性,也可能会影响到代码的运行速度。毕竟在编程时,缓存失效问题是最难解的问题之一。
计算机科学中有两个难题:缓存失效和起名字。 --- Phil Karlton
流水线中合理管理状态和缓存的建议
程序框架和设计模式会强制一些设计原则,以希望通过这样简单的方式来替你管理一些事情,以免你自己犯错或者写出一堆乱糟糟的代码。这里是我对于流水线和状态管理的建议:
- 流水线步骤不应管理检查点的数据输出。
这应该由一个单独的函数库管理。
为什么?
为什么流水线步骤不应该管理它们的检查点数据输出?下面这些原因会告诉你应该使用函数库或者框架而不是自己做:
- 当部署到线上时,你可以很轻松的完全打开或者关闭检查点。
- 当你需要使用新的数据重新训练时,良好的缓存管理机制会检测到你的数据变了,并忽略之前的缓存。这并不需要你的特别注意,也会避免很多问题。
- 你不需要在流水线步骤中自己编写代码进行I/O操作与磁盘交互。大多数程序员更喜欢编写机器学习算法和构建流水线,而不是编写数据序列化方法。在Neuraxio,我们从没有听说谁更喜欢处理数据的缓存,而不是编写神经网络。
- 你将有机会为你的每一个流水线实验或者迭代起一个名字,一个新的缓存文件夹就会被创建出来,即使你使用的是相同的流水线步骤。甚至如果你的数据变了,都不需要给这个实验起名字。
- 如果你修改了流水线步骤的代码,就会检查并对比代码的摘要,从而决定缓存是否需要重新处理。真香。
- 你将有机会为中间数据结果计算摘要,并且在超参数相同而且你的流水线已经处理过这些数据(所以才有缓存)时跳过流水线对这些数据的计算。这样做,即使在某些时间中间的流水线步骤变了,也可以简化超参数调参。举个例子,前几个流水线步骤保持缓存因为它们没变,如果你流水线中接下来的几个步骤有不少超参数需要调整,而且在这些步骤后会记录多个检查点,那么这些中间流水线步骤就会有多份缓存被保存,并利用前面的摘要计算出一个唯一的名字。你可以称之为区块链,如果你愿意,因为它实际上就是一个区块链。
这很酷。使用合适的抽象,你现在能够编写这样的流水线,在超参数调参时,它可以通过缓存每次实验的中间结果,当超参数不变时跳过流水线步骤,从而大大提速。不仅如此,一旦你做好准备把代码放到线上,你现在可以很容易的完全关掉缓存,而无须花一个月时间去重构代码。避免撞墙。
关于用于竞赛的代码
很常见的,从Kaggle竞赛来的代码缺少正确的抽象用于把流水线部署到生产环境。这有其合理的原因,参加竞赛者没有为把代码部署到生产环境而作准备的动机,他们唯一的需求是获得最好的结果去赢得竞赛。大多数为发论文而研究的代码也是这样的,通常代码编写者追求的是打败基线指标。也有时候,机器学习程序员在深入机器学习之前没有学过如何恰当的编写代码,对于他们以及他们的雇用者,这会使事情变得困难。
这里有一些坏模式的例子:
- 在一个文件夹中,放一堆小的“main”文件,手工一个个依次或者并行去运行这些文件。是的,我们见过太多这样的流水线。
- 在上面说的小文件之间强制使用磁盘持久化存储,把代码和存储强耦合起来,无法只运行代码而不保存到磁盘。是的,见过太多。
- 不同的小文件使用不同的磁盘持久化机制。例如,混合使用JSON、CSV、Pickles,时不时用HDFS和或者直接输出numpy数组。更糟的是,混合使用多种数据库或者大型框架而不是保持简单或写磁盘。哦,保持简单。
- 没有说明到底怎么才能按顺序运行这些东西。把一堆狗屎当作作业留给看代码的人。
- 没有单元测试,或者单元测试是在测试算法,但是需要写磁盘或者使用已经写在磁盘上的东西。呃。当你运行这些没有测试的代码时,发现有一个依赖项版本更新了,但是这里并没有告诉需要安装的版本,至此,这些代码再也不像之前一样了,它们根本运行不了。
这些坏模式不仅仅出现在编程比赛的代码中(比如这里是我匆忙中中写的---当然,在身负重压时我还可能这样做)。这里是一些使用磁盘设置检查点的代码,以及我的一点分析:
- BERT。要有耐心---试着重构一下“run_squad.py”,你就会意识到每一层抽象都耦合在了一起。随便说几个,控制台参数解析逻辑和模型定义逻辑放在同一个层次混起来,满眼都是全局标志变量。不仅如此,模型定义逻辑跟使用云端加载和保存数据的逻辑混在一个巨大的文件里,这个大文件有一千多行代码。这其实只是一个小项目。
- FastText。Python API从磁盘加载数据,并在这些数据上进行训练,然后把结果输出到磁盘。难道不能把输出到磁盘和使用文件作输入做成可选项?
- 大多数Kaggle比赛获胜代码。我们遇到过很多这样的代码,里面没有适当的抽象。
有时候,公司可能从Kaggle代码中得到启发,我建议在产品最终上线版中自己编写流水线,使用这些比赛代码是有风险的。有一种说法是,公司不应使用比赛代码,甚至也不应雇用比赛获奖的人,因为他们会写糟糕的代码。
我不会这么极端(因为我自己在大多数时间也在编码比赛中赚积分),倒不是说比赛代码的目的只是获胜而不考虑将来使用。出乎意料的是,在那个时候去读Clean Code和Clean Coder是很重要的。使用好的流水线抽象能够帮助机器学习项目存活。
这里是当你要构建准备在产品线上使用的机器学习流水线时想要的东西:
- 理想化的,你想要一个流水线,它能够通过只调用一个函数就能处理你的数据,而不是执行一堆小文件。如果运行过程很慢,你可能想要使用缓存,但是要让缓存尽可能小,缓存也不一定是检查点。
- 要能够通过一个开关禁用所有流水线步骤之间的数据检查点。当开发、调试和训练模型时,检查点很有用,但是在生产线上,它应该能轻松关掉。
- 在一个机器集群上扩展的能力。
- 最后,你希望它具有良好的容错性以及预测能力。自动机器学习会有用。
机器学习流水线中的数据流
在并行处理理论中,流水线是一种流式处理数据的方法,这样流水线步骤就能并行运算。洗衣房的例子很好的展现了这个问题及其解决办法。举例来说,一个流式流水线的第二步能够处理第一步的部分结果,同时第一步还在处理更多数据,而不用第一步处理完全部数据第二步再开始。我们把这些特别的流水线称为流式流水线(参见 streaming 101, streaming 102)。
不要误解,scikit-learn流水线是很好用的。但是,它不允许流式处理数据。不仅仅是scikit-learn,大多数机器学习流水线程序库没有使用流式数据,尽管他们可以使用。整个Python生态系统有线程问题。在多数流水线程序库中,每一步是完全阻塞的,必须一次处理完所有数据。只有少数库允许流式处理。
使用流式数据处理很简单,只要使用StreamingPipeline类而不是Pipeline类来连接流水线步骤,并且在步骤之间提供一个小批量的大小和队列大小(避免占用太多内存,这会使线上环境更加稳定)。这也需要使用在生产者-消费者问题中描述的带信号量的多线程队列来在流水线步骤之间传递信息。
Neuraxle比scikit-learn做的好的一件事是拥有序列化的流水线,可以通过MiniBatchSequentialPipeline类来使用。它还没有线程化,但在我们的计划当中。至少,我们已经可以在训练或者处理时可以在收集结果前传递小批量数据到流水线,这允许大的流水线用小批量数据使用像scikit-learn一样的流水线。再加上我们另外的功能,例如超参数空间、初始化方法、自动机器学习等等。
我们对Python中并行数据流的解决方案
- fit和transform方法能在一行数据上被调用多次,这样在新的小批量数据上提升训练性能。
- 在流水线中使用像生产者-消费者问题中线程化的队列。在支持流式数据的流水线步骤之间需要一个队列。
- 允许流水线步骤有多个并行副本并行处理多条数据。
- 一个流水线参数可以设定是否数据在送入一个步骤前需要保持数据的顺序。
- 可以在流水线步骤之前使用障碍器。
- 我们也计划编写重复器和批量随机器,它可以重复训练数据多次,这在训练神经网络时很常见。
不仅如此,在Python使用每个对象线程化的方法会使它们可序列化和重新加载。在Neuraxle,我们很快就会实现它们。这样就可以动态发送代码到远程工作进程中去执行。
对封装的权衡
在多数机器学习流水线库中,还有一件事仍在困扰着我们。这就是如何处置超参数。拿scikit-learn做例子,超参数空间(例如超参数值的统计分布)往往需要在流水线之外在步骤与步骤之间或者流水线与流水线之间用带下划线方式指定。正如在scipy分布中定义出的,随机搜索和网格搜索能够搜索超参数网格和超参数概率空间,scikit-learn并没有为每个分类器和转换器提供默认超参数空间。这理应是流水线中每个对象的职责。这样的话,一个对象就是自包含的,也包含它的超参数,这不会破坏面向对象编程(OOP)中SOLID原则中的单一职责原则(SRP)和开放关闭原则(OCP)。使用Neuraxle是不破坏这些OOP原则的一个好办法。
兼容性和集成
在编写机器学习流水线时,最好能想着让流水线兼容尽量多的东西。Neuraxle就兼容scikit-learn、TensorFlow、Keras、PyTorch,以及许多其他机器和深度学习库。
例如,neuraxle有一个方法叫作.tosklearn()
,它允许流水线步骤或者整个流水线变成一个scikit-learn的BaseEstimator,这是一个基本的scikit-learn对象。对于其他机器学习库,也很简单,只要创建一个新的类,让它继承自Neuraxle的BaseStep,并至少重写你自己的 fit、transform,也许还有setup和teardown方法,再定义一个保存器来保存和加载你的模型。可以阅读BaseStep的文档学习怎么做,也可以阅读文档中相关的Neuraxle样例。
结论
总结一下,编写产品级别的机器学习流水线需要很多质量标准,如果在代码中使用好的设计模型和好的结构,这些问题很可能都会解决。总的来说:
- 在你的机器学习代码中使用流水线是一件好事情, 流水线的每一步可以定义成流水线步骤,例如“BaseStep”。
- 接着,当搜索最佳超参数和使用相同数据再次运行代码时,就能使用检查点来优化整个流程(也许使用不同超参数或者代码改变时)。
- 顺序的训练或者处理数据,而不是压爆内存,也是一个好主意。这样,当从顺序流水线变成流式流水线时,所有过程也可能并行化。
- 如果你要编写自己的流水线步骤,你只需要继承法BaseStep类,再实现所需的方法。