3-4 样本拼接&模型训练
前两篇文章我们讲了online的召回模块和排序模块,解决了模型如何进行应用的问题。这篇文章我们来详细聊聊,这些在线模块使用样本是如何产生的,模型是如何训练以及更新的,以及这个过程与自己单机跑的模型有什么异同。
对于缺少实际场景工作经验的同学来说,对于模型训练的认识会是如下的流程:在一个大的数据表中,比如一个2万行的一个csv表格,这个表格中有相关的feature和label。我们这时候使用某些软件包,比如pandas,直接把所有的数据全部读入到内存中,然后调用类似xgb.train()
这样的方法,就可以得到效果还不错的模型。大家的模型相关的调优工作主要体现在,模型相关的参数是如何选择。
但是,在实际的工作应用中,一个最大的问题就是,样本不是从天而降的,而是需要生成的。而且它的数据量通常会比较大,可能无法被直接读入到单机的内存中,这时候就需要分布式计算,进行参数的更新和调优了。
所以这篇文章主要想解决以下三个问题:
- 样本是如何产生的?
- 模型参数是怎么进行更新的?
- 对在校同学可能更感兴趣的是,分布式计算的相关理论,是如何在实践中发挥作用的。
样本生成
我们首先来看样本生成的问题。
推荐系统的目标是拟合用户的喜好,而用户的喜好则是通过用户的各种行为表达出来的。所以我们的label自然是各种各样用户的行为。
用户行为是用户在手机的App上进行进行各种交互的结果,常见的用户行为包括比如点击,停留时长,点赞,评论等等。在用户在手机App(我们把它叫做客户端)上进行了这些操作的时候,客户端会发送一条事件来记录这件事的发生:即某一个用户(UserID),在某一个时刻(time),对某一篇item(itemID),产生了某一种用户行为(Action)。
在用户量级比较小的情况下,我们可以把这个事件直接写入某一个数据库中,比如MySQL,作为真实的Label存储下来。但是,因为写操作通常比较耗时,且写的并发数通常不能太大。为了保证客户端上的行为记录的准确性和稳定性,我们通常要把时间生成与写入事件分离。通常会选择通过消息队列的方式来实现:客户端因为事件产生而发送消息,服务端接收这个消息,负责存储。目前用的最常见的分布式消息队列,是kafka。
于是我们现在就有了比较准确的用户label。
Feature大家应该都比较熟悉了,我们在线做预估的时候,已经生成了这些feature。出于与事件生成与事件写入分离类似的考虑,我们也通过消息队列,把feature存储下来。
这时候我们就有了一份完整的feature和完整的label,然后我们就可以根据它的UserID和itemID,对这两者进行拼接生成完整的样本了。
所以在线系统的完整的流程就是:我们先对一批item进行预估,排序得到最好的item返回给用户,用户再根据自己的喜好,对这些item进行反馈。然后收集这些用户行为,生成新的样本,更新模型,帮助我们对下一批的item进行预估。这个过程有点自我改进迭代的味道。
到这里,可能大家会觉得不过如此:好像一切都还挺在掌控之中的?
那可能是因为大家遗漏了比较关键的一个点:推荐系统的实时性。
在用户在客户端进行了一次反馈之后,我们总是希望模型可以尽快的学到这一种偏好。比如用户之前可能是一个看做菜比较多的用户,他可能出于某种原因突然关注了美国大选的结果,我们希望模型能够尽快的学到用户的这个偏好,短时间内给他多出一些美国大选相关的新闻,这样可以更好的吸引用户。
这就意味着我们的样本生成要尽可能的实时。但是这个在技术上是有难点的,在样本的角度考虑的主要问题在于:等多久?
feature的生成是实时的,在进行预估的时候,feature实际上已经产生了。而label是什么时候产生的呢?这涉及到用户通常需要多少时间对推荐出来的item做出反馈。此外,通常用户一次请求返回的item条数通常不止一个,意味着我们要等用户对所有的item都做出反馈才行。
所以,在生成样本的时候,通常是一个feature等待label(用户行为)的状态。
- 等一天?用户行为肯定是准确的,但对模型来说,实时性太差了。
- 等五分钟?对模型来说,实时性是够的,但是用户行为真的准确吗?可能还有3个item用户都没来得及看呢,如果我们在五分钟内做结算,这三个item肯定会因为用户没有足够的事件做反馈而被当做负例。用户后面如果真的看了,用负例结算就是非常不符合预期了。
- 等一个小时?看起来好像可以,但是怎么衡量这个数值对不对呢?
这时候我们通常需要确定用户的行为需要要在多少时间内回收。这实际上是一个模型实时性与样本准确性做的trade-off。这个通常需要根据推荐系统的应用场景来做决定。
此外,因为我们要保证推荐系统的实时性,我们可能需要使用分布式的流式计算框架来进行样本的拼接,现在比较流行的计算框架是storm和flink。
模型的参数如何进行更新
在有了样本之后,接下来的问题就是模型如何进行更新了。
有同学会说,模型的更新还不简单:直接tf.Session.run()
,一把搞定。
在单机情况下可以这么搞,所有的参数全部存储在内存中。但当样本量非常巨大的时候,意味着我们需要管理和维护的参数空间会非常的巨大,比如可能单个模型就需要10T左右。这时候单机显然是吃不消的,这时候就需要分布式的计算框架来做操作。
我们来看训练模型需要做哪些事情:
- 根据给定的feature,先得到网络中参数
- 做forward操作,得到预估值
- 根据预估值与实际的label,得到梯度
- 梯度backward
- 根据梯度更新网络中的参数
其中234是我们相对比较熟悉的,主流的深度学习计算框架比如tensorflow和pytorch都可以做相关的操作。1和5,尤其是5,是我们这里需要提一下的。5的点主要在于,在分布式的情况下,怎么样可以汇总所有分布式计算worker中的梯度。在有了梯度之后,其实具体的更新方法是大家比较熟悉的,其实是优化算法的实现,比如FTRL, Adam等等。
为了解决1和5的问题,我们需要一个参数管理器,主要支持两个操作:
- 给定feature快速读取网络参数
- 支持参数的梯度更新,尤其是梯度的计算
为了解决这两个问题,李沐在2014年提出了Parameter Server,是机器学习分布式训练的一个重要解决方案。
Parameter Server是什么呢?首先它是一个key-value的分布式的存储单元,可以支持给定feature快速读取网络参数。其次他提出了一种“异步非阻断式”的梯度更新方法,可以快速的提升梯度更新效果。
这个梯度更新方法有啥玄乎的呢?我们要从分布式计算的基本原理讲起。
我们知道,分布式计算中有一个重要的理论叫做CAP定理,它是指在一个分部式系统中,以下三个要素最多只能实现两点:
- C(Consistency,一致性):多个分布式的节点中存储的数据是一致的。
- A(Availability,可用性):主要是指可以实时取到节点中的数据。
- P(Partition tolerance,分区容错性):能够容忍节点通信之间的网络故障。
CAP的原理,即为什么只能实现三点中的其中两点,我们在这里不多展开了。感兴趣的同学可以下面再了解一下。
我们要说明的是,在实际的梯度更新计算的场景,我们首先是必须要选择分区容错性(P)的,因为在工业界单机故障可能是一个比较常见会发生的事情,通常我们通过多副本来实现;其次在我们的场景,可用性(A)肯定是也要选择的:毕竟我们每次梯度更新forward和backward的核心就是要拿到已有的参数取值。这就意味着在一致性(C)这个维度,我们可能得做一些舍弃。
一致性对梯度更新意味着什么呢?我们在单机进行梯度计算的时候,每一个minibatch产生梯度之后,可以直接更新原始的模型参数。但是在分布式环境下,在多个计算节点(也就是worker)中,每一个worker都会产生当前worker接收的样本的对应的梯度,如果直接对参数进行更新,因为缺乏一致性的保障,可能会导致多个副本中的参数完全不一致,直接导致网络就会错乱。
为了避免这种情况,人们引入了一个中间层Server,用来接收所有worker产生的梯度。先让所有的worker读一个minibatch的数据,然后全局进行等待,等所有的worker把梯度计算完了之后,把梯度做一个汇总,统一更新所有的模型参数。然后所有的worker再统一读入下一个minibatch,进行计算。这其实就是Spark MLlib的实现方式,叫做“同步阻断式”的实现。
这个实现方式比较直接,也是最能保证一致性的实现:这个计算结果会和串行的计算结果完全一致。但是有一个问题严重的问题就是:慢。实际的运算过程中,worker的计算有快有慢,大量的时间都浪费在等待慢的worker的梯度计算了。
Parameter Server在这个基础上提出了一个改进:叫做“异步非阻断式”,相当于是在Spark MLlib和完全自由的梯度下降之间取了个折中:Server还是存在,不过相对spark MLlib的实现方式,server不仅仅是做梯度汇总的工作,同时还做指挥调度worker的作用。通常情况下,worker读取minibatch计算,梯度传递给Server,Server端做统一的处理和调度,决定worker是否需要继续计算下一个minibatch。server允许快的worker多计算几个minibatch的样本,即使可能上一个minibatch的计算结果还没有来得及进行参数的更新。当然,Server侧如果发现,某一些worker计算实在是太快了,比如落下后面的worker10个minibatch,server就让他们等一等。避免参数完全错乱的情况出现。
大家发现,这种实现方式,实际上是模型参数一致性和计算效率之间的取舍。这种方式在实践过程中被证明是:一致性上虽然有损失,但是计算效率和模型收敛速度上,都是极大的优于之前的实现方式的。
同时,Parameter还使用了一致性哈希等等一些技巧,最大化的利用了带宽,避免计算瓶颈的出现。这个就留给感兴趣的同学进一步了解吧。
总结
这篇文章主要给大家讲解了样本生成和模型更新的一些知识。
某种程度上说,有点偏“工程”,不那么“算法”。但工程属于是算法的基础,一个算法工程师首先是一个工程师。对于一个想在工业界发展的算法工程师而言,熟悉并掌握工程基础是必要的。
很多准备做算法或者刚刚开始做算法的同学会有一种错觉:觉得自己训练一个特别NB的模型,就可以解决业界90%的问题,受到大家的顶礼膜拜,自己也会因为这个模型而名满天下。
但实际上,在日常的工作中,纯“算法”需要的开发其实并不多,日常的有很多不那么“算法”的工作需要细致的开发和探索。熟悉并掌握线上的系统,是做好算法工作的前提。很多时候,你精巧训练的一个模型带来的增益,可能很大程度上不及把样本生成搞对,或者是模型训练参数设置正确带来的收益更大。可以说,工程能力,是为算法创造价值打下的基础。这也是大家在面试算法工程师的时候,为什么把以代码能力为主的计算机能力看的这么重的原因所在。
在业界工作与学校的最大区别在于,学校看重创新,会鼓励新的想法和点子;业界看重产出,希望能够带来实际的效果和增益。一个好的模型的idea,在学校可能会受到追捧,但是如果不能落在业界的产出上,是不会在业界的评价体系上占到优势的。而取得实际的效果和增益,如果可以通过工程上的一些技巧快速的达到,可能会比一个精巧但耗时的模型更能取得业界的青睐。这也是我们需要重视工程能力。
这并不是说作为算法工程师,算法不重要。掌握工程架构,理解业务,在这个基础上发掘数据的价值,定义算法目标,最终解决实际的业务目标才是算法工程师的价值所在。