【推荐系统】系统架构与流式训练

 

前面介绍了推荐系统中一个请求到来时的链路,这里介绍推荐系统的完整架构,以及流式训练的算法和相关问题。

推荐系统架构

Client-Server架构

上图展示了常规的互联网服务架构图。当用户在浏览器输入链接打开一个网页时,浏览器会根据链接对应的地址向服务器请求index.html文件,此时nginx这样的反向代理程序会直接返回它(反向代理指nginx代理的是后端程序,因为正向代理是代理客户端)。随后,浏览器会继续请求index.html所依赖的其它html、css、javascript文件等资源显示成一个网页。这些前端页面的请求不涉及后端的服务程序,因为现在前后端一般是分离的。

当用户在网页上浏览并采取一些行为时,网页的javascript程序会向服务端请求对应的数据。例如浏览b站时,会获取用户的数据展示在右上角,并触发推荐系统获取个性化的视频列表展示在首页。推荐系统的架构非常复杂,一方面涉及线上的服务,例如一些数据库、KV服务、模型推理等等,另一方面也涉及离线的模型训练平台,训练平台一般使用参数服务器架构进行流式训练。除此之外,模型训练需要样本,样本里的标签来源于用户行为,所以会有专门的数据流收集用户行为,记录在数据库里,并转化为训练样本的标签。

召粗精架构

上图展示了常见的召回+排序范式的推荐系统架构。当用户浏览app或网页时,会发送一个请求获取推荐列表。这个请求主要包含了用户的uid(user id)以及一些context信息,例如手机型号、时间、地点等等。这个请求到达服务端后会经过以下步骤:

  • 首先会经过一系列处理,例如给请求加上AB实验的参数,随后到达召回环节
  • 在召回环节,系统会根据用户uid获取一些用户信息、特征去做多路召回,得到用户可能感兴趣的几千个item的ID
  • 随后在排序环节,会获取特征、进行模型推理、排序、重排,最后得到几十个item的ID
  • 混排部分会根据业务需求混合不同业务的推荐内容,例如在推荐列表里插入一些广告等等,最后推送给用户

参数服务器

参数服务器架构是推荐系统常用的训练和推理架构,其特点是考虑了推荐系统中Embedding层参数量巨大但每次推理只会用到一小部分的特点(稀疏性),将整个训练结构分为“参数服务器”和一些“worker”。其中参数服务器是一个分布式集群用来存储大量的参数和对应的梯度,而worker负责计算(使用CPU或GPU),每次读取样本后,向参数服务器请求这个样本的embedding和dense部分的参数作为输入,并进行推理和反向传播,最后将梯度传给参数服务器进行异步的模型更新。

数据流

特征链路

用户的所有行为都会以日志等形式写入kafka,之后根据日志类型写入不同的队列,比如点赞、点踩是一类,用户展现日志等等。用户日志非常庞杂,涉及很多数据库,时不时为了数据分析就会用SQL产生一些新表,但是统一都叫数据源。

模型用到的特征来源于数据源,需要经过比较复杂的处理流程变成Key-value服务存储到线上,比如key是uid、item id,然后value是对应user或item到一大堆特征,也是key-value。这里的处理流程通常是访问数据源获取相应的数据(比如年龄等特征),以及一些聚合操作(访问过的item序列)等等。

批式特征指定定期更新的特征,通常是每天凌晨出发对应的任务,对数据源进行处理并更新到key-value服务上。实时特征则是每来一个数据就变一下。通常来说,批式特征是用户画像、长期行为序列一类的东西,实时特征是用户短期的行为序列、地理位置这一类。

  • 批式特征在进入数据库后进行处理,每天凌晨12点定时开始提取,由Hive处理变成Key-Value服务
  • 实时特征则是在进入数据库前还会走另一条Flink支路,由Flink处理后直接KV化。

Flink和Hive的处理可以由SQL统一,实现流批一体的特征处理平台。

数据流

训练样本

训练样本来源于请求。用户的每个请求都会触发召回、排序的流程,在这些流程中一方面模型会进行预测,另一方面会把它作为样本存起来。注意此时的样本是没有标签的,因为这些item都还没发送出去。之后等标签回流之后(用户点击、广告主回传的转化也被存进数据库里),这些样本和标签会拼接成训练样本(类似于SQL到join操作,根据请求的id等方法把二者对应起来),经过消息队列给模型训练。样本有一个很重要的属性是这个请求对应的时间,这些样本会按这个时间存起来,方便后续模型按天训练。

推荐系统的模型会进行批式训练和流式训练,为此需要两条数据流给两种训练准备数据,一是实时数据流,二是批式数据流。样本在拼接之后开始分流,实时数据流需要打散,然后写入kafka最后训练;批式数据写入feature store训练。

一个样本通常带有非常多的特征,这样后续就可以很方便的分析模型在不同样本上的表现,比如不同年龄段样本的表现、某类item上的表现等等。

One-Epoch现象

搜广推模型一般只训练一个epoch,从第二个epoch开始模型performance就会下降。有研究表明,第二个epoch的embedding梯度很小,基本就不会更新了,而MLP还会继续更新。个人的理解是稀疏特征的embedding对输入的表示能力太强、太容易过拟合了,所以MLP继续更新后很容易过拟合,导致performance下降。实际上很多学术界的数据集由于特征都很稠密,所以一般还是会训练多个epoch,这是和工业界一个比较大的区别。

One-Epoch也有个好处,就是使用流式训练不需要担心由于没有对样本过很多次导致性能损失,同时训练和测试可以一起做了,不需要划分训练集和测试集,因为反正模型也只见这个样本一次。

标签回流延迟

上述介绍的训练样本用于训练模型,同样分为批式训练和实时训练。通常每天进行一次批式训练,实时的样本达到一定batch后进行一次在线更新。对于实时训练,由于推荐系统样本是点击、点赞、转化等行为,这些行为作为标签回传和send时间相比是有延迟的,尤其是转化行为,通常需要几天时间才能获取标签。由于无法立即获得标签,如何判断一个样本是正样本还是负样本?常用的办法是窗口拼接,设置一个窗口时间,例如3天,如果3天内没有发生转化,就认为这个样本是负样本,发生了就是正样本。但是这样又会带来两个新的问题:首先是,超过3天才转化的正样本被丢弃,而正样本又是十分宝贵的,丢弃会导致模型效果下降;其次,这样做让所有样本都delay的3天,对于实时训练来说实时性有损。一种能解决这两个问题的办法是Fast Emit。

Fast Emit

Fast Emit做法很简单:用一个模型预测样本标签回流的时间,让每个样本根据预测的时间delay,delay后直接作为负样本发送。如果收到一个回传的正样本,则立即发送。这样避免了窗口外的正样本被抛弃,同时尽可能让负样本的时间和正样本对齐,保证正负样本比例的稳定性。但这样也引入了新的问题,一个样本同时被作为正样本和负样本发送了两次,存在伪负例,需要专门纠偏解决。

一个concern是把一个样本同时告诉模型是正且是负,会不会影响效果。答案是不会,因为一条样本的label是对其概率分布的采样,不用过分在意“伪负例”问题,并且实验结果也说明不会。

正例打散

由于转化回传是批量进行的,一次回传太多会导致正样本比例快速提升,带歪模型。为了解决这个问题需要对正样本进行打散,只需要把正样本delay随机的一段时间即可,delay时间可以均匀采样,均匀采样的区间宽度取决于正样本回传的频率。如果把正样本的回传理解为一系列的冲激信号,正例打散就可以理解为对这个冲激信号用窗口函数做滤波,滤波后的信号高频分类更少,更平滑。

在线优化算法

OGD算法

其实就是SGD算法的特例:

\[w_{t+1}=w_t-\eta_tg_t\]

其中$\eta_t = 1/\sqrt{t}$。OGD在准确率上表现正常,但是在sparsity上表现不佳,即使加上了 L1 正则也很难使大量的参数变零。一个原因是浮点运算很难让最后的参数出现绝对零值;另一个原因是不同于批处理模式,online 场景下每次$w$的更新并不是沿着全局梯度进行下降,而是沿着某个样本的产生的梯度方向进行下降,整个寻优过程变得像是一个“随机” 查找的过程,这样 online 最优化求解即使采用L1正则化的方式, 也很难产生稀疏解。正因为 OGD 存在这样的问题,FTRL 才致力于在准确率不降低的前提下提高稀疏性。

FTRL算法

FTRL算法希望学到的参数尽可能稀疏。FTRL引入了累计梯度,这样可以避免在线学习随机性大的问题,同时引入正则化项保证稀疏性:

\[w_{t+1}=\text{argmin}_{w} (g_{1:t}^Tw+\frac12\sum_{s=1}^t\|w-w_s\|^2+\lambda_1 \|w\|_1+\frac12\lambda_2\|w\|^2)\]

AdaMom算法

\[\begin{aligned} g_t & \leftarrow \nabla_{\theta} J(\theta_t) + \alpha \theta_t \\ m_t & \leftarrow \beta_1 m_{t - 1} + (1 - \beta_1) g_t \\ v_t & \leftarrow \beta_2 v_{t - 1} + g_t\odot g_t \\ c_t & \leftarrow \beta_2 c_{t - 1} + 1 \\ \theta_{t + 1} & \leftarrow \theta_t - \eta \dfrac{1}{\sqrt{\dfrac{v_t}{c_t} + \epsilon}} m_t \end{aligned}\]

Adamom是字节跳动基于Adam设计的优化算法,相较于Adam,主要改动在于:二阶动量的移动平均改为上面的直接累加然后除以$c_t$,也就是取平均。这样做可以让近期梯度占比更大,更适合在线训练场景。同时在线训练的学习率也会取得很小。

异步优化算法

参数服务器是分布式的训练架构,需要异步的优化算法。异步优化算法的问题是,第$t$时刻的梯度$g_t$可能会被用来更新$\theta_{t+\tau}$,但是正确的梯度是$g_{t+\tau}$,两个梯度存在差异。Delay Compensation提出通过泰勒展开,用 $g_t$ 近似 $g_{t+\tau}$:

\[g_{t+\tau}\approx g_t+\nabla^2 g_t(\theta_{t+\tau}-\theta_t)\]

这里$\nabla^2 g_t$是海森矩阵,比较难计算,可以用下面的结论去近似海森矩阵:

  1. $g_tg_t^T$ 是它的一个无偏近似($g_t$本身由于SGD的随机原因,是随机变量)

  2. $\lambda g_tg_t^T$($\lambda$是超参数,0到1之间),可以用来trade-off偏差和方差($\lambda $越小偏差越大,方差越小)

  3. 对角化trick:假设海森矩阵的非对角线元素都是0,实验效果也很好

于是用 $\lambda\text{diag}(g_t\odot g_t)$ 近似海森矩阵即可,把$g_t$变成:

\[g_t+\lambda g_t\odot g_t\odot(\theta_{t+\tau}-\theta_t)\]

就是引入了Delay Compensation的梯度,用它作为梯度使用各种优化算法进行模型的更新即可。

优化算法的选择

优化算法优化的是模型的参数,而推荐系统模型的参数分为2部分:Embedding层的参数和Dense模型的参数。Dense模型的参数使用类似Adam的算法即可,但Embedding参数量太大,它通常占据了整个模型的绝大部分,会有千亿、万亿级别的参数量,一般使用AdaGrad算法,因为AdaGrad相较于Adam不需要维护一阶动量,减少了内存占用,这对于参数量巨大的embedding层是很重要的。

老汤模型

老模型训练太久,训练数据远远多于新模型,导致新模型很难超过老模型。一种解决办法是warm up,load以前的权重,但是这样限制了模型不能改动太大,否则load的参数作用没有那么大;另一种解决办法是频繁的迭代,不允许出现老汤模型,或者直接每隔一段时间,重新训练基线模型来和新模型比较。