2017年写在blog的文章,搬运过来。
年终了,终于可以在需求的夹缝中喘息一会。回望2017年,最大的成就莫过于从0到1搭建起了一套支持多业务场景、高并发访问、高时效性的新闻推荐系统。这其中自是暗坑无数,趁着还未淡忘,将系统搭建过程中遇到的困难与解决方法记录于此。
0. 概况
以我们目前的推荐系统架构为例:
推荐系统是个很复杂的工程,对于算法和工程上的能力都是一个挑战。本文只是尝试从几个大模块简述上手搭建推荐系统的过程,不会深入探讨。然而要想推荐达到可观的效果,深入挖掘每个模块,研读论文、优化架构是必不可少的。以下我会从数据、画像(内容/用户)、召回和排序几个部分分别详述。
1. 数据
推荐系统,最重要的是数据。数据决定了算法的上界,再牛逼的算法也只是逼近这个上界而已。因此搭建系统时,首要考虑完善数据。这里数据包含两类:内容数据与用户数据。
1.1. 内容数据
这个很好理解,内容指的是推荐系统要推荐的item。电商就是商品,电影网站就是电影,我搭建的是新闻推荐系统,所以内容就是新闻。获取手段可以是网站内部发文,也可以是外部抓取,基础爬虫我就不赘述了,另外内容的版权问题也是需要注意的。抓取到之后我们需要对内容落地,这一步的关键是数据格式的规范化。考虑到我们的内容很可能是从不同数据源抓取,有着不同格式,为了方便日后的利用,大致需要遵从如下步骤,对原始数据进行ETL:
1. 按推荐需求指定落地内容字段
2. 对内容字段进行标准化处理,如正文提取、一致编码
3. 选择合适的存储方式,如MySQL、MongoDB、HDFS
需要明确的是,上述系列行为都是为最终的推荐服务的。首先,需要考虑业务侧需要展现哪些属性(如标题、缩略图、摘要);其次,还需要考虑算法侧提取内容特征需要哪些属性(如正文、发布时间)。我在系统搭建的过程中,遇到最头疼的问题就是在NLP时需要依据某个内容属性而源数据没有抓取该属性,因此做抓取前尽量考虑周全,预留好一些字段是很有必要的。
以从腾讯网抓取的新闻部分属性为例:
1.2. 用户数据
搞定内容之后,我们还需要了解用户,推荐的基础也是用户的行为。在新闻网站上,最简单的行为就是点击。一名用户在网站上点击了一条新闻,我们可以认为他对这条新闻感兴趣,此时前端需要将这条记录上报到后台,后台再将log落地。这个过程可以是实时的,也可以用消息队列的方式分批处理,总之,依据场景需求以及系统架构能力。一条最简单的log如下:
user_id,news_id,timestamp
user_id可以是用户手机的IMEI标识、PC的MAC地址、浏览器的Cookie ID等等,总之是需要能唯一标识用户的序列。当然这里涉及到的一个问题是,一个用户可以在多个终端登录,所以我们还需要用户的登录态来解决一对多的问题,比如用登录QQ、微信账号来做一个关联映射。
上述列举的log只包含最简单的信息,复杂的推荐需要更多的信息,比如来源IP(用以识别用户登录地域),收藏、评论等行为(造成不同兴趣权重),曝光行为(用于之后CTR模型的训练)等等。
有了内容数据和用户数据之后,我们已经可以建立一些简单基于用户行为的推荐策略了,比如itemCF、userCF,具体实现方式我在之前的文章里写过:文章链接,这里不再赘述。但基于用户行为的策略,往往在系统冷启动时表现不会太好,我们还需要更多维的推荐策略。
2. 内容画像
众所周知,基于行为推荐需要一定的用户行为积累,而新闻生产速度很快,时效性要求又比较高,这时候我们需要一些 Content-based 方法来做推荐。内容画像是实现的基础。
2.1. 文本分类
分类,是新闻语义特征里颗粒度最粗的一个特征。根据分类可以对文本有一个基本的语义划分,可以让用户对兴趣内容有较为明显的感知,所以分类往往是内容画像的第一步。
在分类之前,我们首先要制定统一的分类体系,根据业务需求按颗粒度区分一/二级分类。这一步可以人工标注,也可通过无监督聚类的方法。总之,这对于融合多来源、多类型的内容数据至关重要。
分类的方法有很多,传统统计方法里如 Naive Bayesian、SVM,深度学习里的 CNN、LSTM 等都可以胜任。不过在大多数情况下,传统方法已经可以做到很好的效果,且实现简单,因此我们通常选择前者。
2.2. 关键词提取
分类完成之后,可以说我们的内容画像已经初见端倪。然而,仅仅精确到分类颗粒度的个性化推荐是很难满足用户的。用户对于文章的兴趣,往往精确到某个明星、某支球队,要捕捉到这种颗粒度的信息,只要依赖于关键词。
关键词提取是对于文章中出现的具有代表语义作用的词汇进行提取,并赋予权重。这类算法很多,baseline 的方法比如 tfidf、textrank,都能做到很好的效果。当然,如果我们要做到更精确,还需要结合业务数据做一些人工规则,比如将词性、实体、词出现位置等特征与 baseline 方法进行结合,或者用人工标注的方法转换为有监督学习的问题。
2.3. 主题抽取
分类和关键词,颗粒度的跨度其实是比较大的。在基于语义的个性化推荐过程中,一些冷门关键词往往比较难以命中,为了弥补这个真空,文本主题的概念就派上用场了。
图2-1 LDA示意图(来源:由Slxu.public - 自己的作品,CC BY-SA 3.0,https://commons.wikimedia.org/w/index.php?curid=7922733)
诸如 pLSA、LDA 的主题模型假设一篇文档的生成过程是这样的:
1. 作者从文档 - 主题分布 θ 中随机挑选一个主题 zi
2. 作者从主题 - 词分布 φ 中随机挑选一个词 wj
3. 重复步骤1,直到文档所有词生成完成
LDA 与 pLSA 不同之处在于 LDA 还假设这两个分布也不是固定的,而是遵循两个狄利克雷先验分布。总之,这类算法最终计算出的是文档集合中存在的“隐分类”,表征文档语义中存在的一些潜在关联。主题的维度我们一般设置为较大的数字,这样我们便拥有了一个颗粒度介于分类与关键词之间的特征。LDA 的实现方法可以参照之前的文章:文章链接
有了上述三类特征后,内容画像已经可以满足大部分需求了。须知,上文所说的方法都是比较基础的方式,像 CNN、RNN、Attention Model 都是可以尝试的方法,NLP 的研究和优化需要投入大量的精力,如果想在这上面深挖,建议系统学习 NLP 相关课程。
3. 用户画像
3.1. 兴趣画像
有了内容画像,我们再来计算用户的兴趣画像就是水到渠成的事情了。简单的方法就是根据用户的行为,检索到一定时间内用户所有有过正向行为(点击/收藏/评论)的 news,把它们看成一篇内容,对所有特征进行线性加和,作为用户在该时间窗内的兴趣画像。用户 u 的当天兴趣画像计算公式如下:
其中 m 为用户 u 在当天产生正向行为的文档集合,n 为文档 i 的特征集合。θj 表示文档 i 第 j 个特征的权重,P(θj) 表示第 j 个特征的先验概率(这一步主要是为了减弱头部文章对用户画像的影响,若某天某一类特征的新闻很热,那么有可能大多数用户画像里都会有这类特征,但它并不能真正代表用户的兴趣倾向)。
随着时间推移,用户的兴趣会发生迁移,因此我们需要加上时间的影响因素:
yt 表示 t 时刻的用户画像,yt-1 表示上一时刻的画像,λ 为时间衰减因子。
3.2. 基础画像
除了上述的用户兴趣画像外,还有一些用户的属性是我们感兴趣的,比如用户的性别、年龄、职业、所处地域,这部分可以根据业务特点来获取,这些我们称之为基础画像。基础画像虽然没有兴趣画像颗粒度细致,但在冷启动、地域强相关等业务场景也是比较重要的。
在业务实践中,我们发现用户的兴趣变化是很快的,并且很难用某一种状态涵盖住用户所有的兴趣范围。比如当我们在浏览新闻时,我们的近期浏览记录也许的确反映了我的兴趣变化,但也有可能我只是对热点感兴趣,抑或是想试探一下不同领域的阅读,再或者仅仅是手抖点错了。再比如,系统依据用户所处地域推荐内容,然而这个用户有可能只是来外地出差,他更感兴趣的可能依旧是常住地的新闻……无论如何,在计算画像的时候我们无法确保用户的意图,因此在快速反馈用户行为的同时,加上多状态的用户画像是有必要的。通常我们的做法是分别记录用户的长期和短期画像,在针对不同的画像做不同的推荐召回,以此满足用户不同状态下的阅读需求。
4. 个性化召回
说完数据的基础积累,包括用户画像和内容画像的构建,接下来我们可以正式着手开始推荐了。以新闻推荐举例来说,推荐可以有很多策略,包括基于用户兴趣画像语义的策略(兴趣关键词/分类/主题相关),基于用户行为的策略(itemCF/userCF/clusterCF),基于位置的策略(地域相关),基于社交关系的策略(SNS推荐)等等。
在一次个性化推荐中,我们通常需要同时运用多种策略。如果尝试仅仅通过某种精细化的推荐策略(如关键词/itemCF)进行推荐的话,用户往往会在初期表现得很感兴趣,而随着数量增多,用户会逐渐疲劳。毕竟用户的阅读倾向往往是多元的,特别是在新闻领域,绝大多数用户除了自己的一亩三分地外,也会比较关注当日热点新闻,或者关注一些其他领域的潜在兴趣。因此,我们通常是从多种策略拉取内容,而后依据某种规则统一进行排序。这个过程一般称作召回。
4.1. 召回策略
4.1.1. 协同过滤
协同过滤(Collaborative Filtering)可说是推荐系统里资历最老最经典的一种算法了,如 userCF、itemCF。原理是基于用户对内容的行为协同,为某一用户没有看过的某条内容作出点击预测。实现方法有很多种,如传统的 Memory-based 方法、基于矩阵分解的方法(LFM/SVD/SDV++)、基于 DNN 的方法。
Memory-based 方法很简单,是基于统计的一种算法。以 item-based CF 举例:
根据用户点击行为,我们可以统计出 item-item 的共现矩阵(矩阵单元内为 item i 与 item j 共同被用户点击的次数),再依此通过Jaccard相似度/余弦相似度/欧氏距离得出 item 相似度矩阵,最后根据用户的点击记录检索出 topK 相似的内容推荐给用户。在计算过程中需要考虑一些因素,比如热门物品对相似度计算的影响、不同倾向的用户的影响等等。
然而 Memory-based 方法不能解决的问题是,当我们的矩阵很稀疏时,大多数 item 和 item 之间是没有关联的(相似度为0),这也就造成最后我们召回的内容覆盖率很低,也许大多集中在头部内容。于是基于矩阵分解的方法诞生了。
MF(Matrix Factorization)的原理是将一个高维稀疏矩阵分解成两个低秩矩阵,其中 k 被称为隐向量维度。在原始的稀疏矩阵 R 中,大部分二阶特征的关系系数是缺失的。而通过训练模型最小化 R 和预测矩阵 R‘ 的损失(如最小二乘),可以求出任意 Ri,j 的值。
MF 可说是大部分推荐系统里协同过滤的标杆方法了,但仍然存在一些问题。比如过于稀疏的矩阵对于最后评分的预测依然有很大影响,并且当用户特征或者内容特征缺失(即冷启动)时,无法进行合理的预测。此时,基于深度学习的一些尝试开始了。如基于DNN实现,可以很轻易地将内容的一些语义特征,以及用户的固有属性与行为特征拼接在一起作为神经网络输入来训练,可以在之前行为协同的前提下加入对内容特征的学习,从而解决冷启动问题。感兴趣的同学可以阅读相关论文,在此不做展开。
4.1.2. 基于内容
基于内容的召回主要是以之前 NLP 得到的内容画像为基础,以item 对应分类/主题/关键词的权重建立召回,依据用户画像的相应权重和内容画像的距离排序召回。召回过程如下:
4.1.3. 基于用户群
其实这种策略也是协同过滤的概念,当用户的粒度扩大时,可以为处于某一群体内的单个用户在兴趣范围内带来更多样的阅读内容,在一定程度上也是一种兴趣探索。
首先我们需要对用户分群,这里我们采用的是用户画像中的 topic 兴趣(2000维),相当于对用户进行了降维。降维的方法有很多,包括 autoencoder 等深度学习方法都可以尝试。不仅仅是用户的文本兴趣,用户的人口属性、阅读记录、社交关系等等都可以拼接进来,最终的目的都是将用户 embedding 成一个向量。
完成了用户的向量化之后,接下来就是聚类了,传统的 K-means 基本可以胜任大部分场景。如果需要多分类或者体现层级关系的话,GMM和层次聚类的算法也可以做一些尝试。
最终我们聚出一批类簇,根据类簇内对不同内容的相对点击率(文章i在类簇a中点击率/文章i在所有类簇中平均点击率)排序,对类簇用户进行推荐。另外,也可以根据类簇中用户的倾向主题,给类簇打上解释性label,作为露出。
4.2. 倒排链
前文中,我们提到内容数据入库时的结构是 itemID - detail 这种形式。而在召回过程中,我们要用到内容画像中的分类、主题等属性,若要通过遍历 itemID 然后计算相似度无疑是不现实的。于是这里我们用到搜索引擎中一个常用的技术——倒排。相较于 itemID - detail 这种形式(我们称之为正排),我们再以 detailX - itemID 的形式建立倒排链,提高召回检索效率。
比如在关键词召回中,我们按如下格式建立倒排表。(这里以 json 格式实例,实际中会采用效率更高的序列化形式):
{
'tag_1':
[ { itemID: '13', weight: 0.7 }, { itemID: '2', weight: 0.53 } ],
'tag_2':
[ { itemID: '1', weight: 0.37 } ],
...
}
上述结构中,索引的key是tag的编号,每一个tagID下则对应与之相关的文章摘要(示例中只包括文章ID和tag在此文章中的权重)按相关度排序的数组。随后将倒排链加载到分布式索引里,在拿到用户画像的兴趣tag后,我们根据tagID检索出倒排链,最后再根据倒排链中的itemID去正排里拉取详情即可。
4.3. 系统架构
有了上述召回的基础,我们便可以初步搭建起推荐系统的架构。完整的推荐系统很庞大、很复杂,基于不同的业务也会有不同的实践方式,这里只谈一些基础的、公用的部分,以作参考。
接入层:接收前端请求的CGI应用,一般处理内容详情拉取、日志上报、各种人工规则干预、去重等任务,计算逻辑简单,I/O密集型任务。这里我们用 Golang 实现,看重他的goroutines处理高并发的能力。
日志采集:消息队列处理前后端的日志上报(点击/曝光/负反馈),采用Kafka,实时打到 Spark Streaming 处理实时数据,同时定期落地到 hdfs 上用以离线处理。
画像计算:用 Spark/Hadoop 按前文所述的方法批量计算长期用户画像,一天计算一次,结果存入 HDFS。
实时画像:采用 Spark Streaming 直接拉取 Kafka 的流实时进行衰减/合并计算,结果写入到 Redis,供线上使用。因为我们每天还会计算一次长期画像,因此短期画像只用保存一天即可。
召回索引:离线更新召回倒排表,定期刷新至线上召回集群的内存里,以加快检索速度。另外在召回模块中,还需要实现诸如截断、过滤等算子,用以在召回的过程中快速过滤曝光内容,截取topN的文章摘要等。
按照上述架构搭建起来后的系统投入到线上使用,QPS在单机1k左右,在召回和接入上还有一些待优化的地方。最终的信息流中,我们从个性化的多路召回中拿到了一批内容,最后根据文章质量(点击量/点击率/阅读时长)统一排序,输出到用户侧,完成推荐。这样,一个推荐系统的完整流程便完成了。
5. 排序模型
我们根据不同召回策略召回了一批文章,并统一根据文章质量排序输出。但实际上,用户的阅读兴趣还会受到很多其他因素的影响。比如用户所处的网络环境,文章点击率、时效性,用户的年龄、性别,或者多种因素交叉的影响,而排序最终决定了用户优先看到的内容(最终推荐流是召回队列的topN),因此排序过程是至关重要的。
5.1. 模型选择
排序的问题在机器学习中有很多可以使用的方法,应用到推荐系统实际上就是一个二分类问题。以用户、内容、上下文的一些特征作为输入,输出是介于0(不感兴趣)和1(感兴趣)之间的一个概率,称作 pCTR(预测点击率)。分类算法有很多,出于规模和性能的考量,业界更多的还是使用线性的方法,传统方法有 Logistic Regression 和 Factorization Machine。
5.1.1. Logistic Regression
逻辑回归是一个经久不衰的统计分析方法,它的预测公式如下:
其中g(x)为sigmoid函数,它的作用是将数值压缩到(0,1)的范围内,函数曲线如下:
有了公式,我们便可以将已知的用户特征和行为作为训练集的输入和输出,其中 x 表示输入特征向量,θ 表示每一维特征对应的权重。输入特征我们可以大致分为用户特征、行为特征和内容特征,示例如下:
这其中每一个特征即是 x 向量中的一维,也对应着预测公式中 θ 向量中的一个权重,而我们模型训练的过程则是求出 θ 向量,最终我们可以通过线上的 x 向量实际输入,代入公式最终得出预测点击率 g(x)。
5.1.2. 特征工程
当然,以上示例的特征取值如果直接使用 LR 进行训练,效果肯定是不好的。因为 LR 学到的是变量的线性关系,而有一些特征取值却并不具备线性相关。比如性别,0代表男性,1代表女性,但这里的数值大小关系并没有什么意义;再比如年龄,不一定所有年龄段内,兴趣都和年龄大小完全线性相关。因此,在训练之前我们需要对特征做一些诸如离散化、数值分桶等操作,尽量让特征与结果表现出线性关系。
另外,有些特征单独对分类影响并不大,但与其他特征交叉影响就明显了。比如年龄X性别、分类X关键词,因此,我们需要根据一些业务上的了解和经验来决定如何进行特征交叉(当然我们可以直接将所有特征的笛卡尔积扔进去训练,但对于训练效率来说这通常是不现实的),往往在特征上的工作占了模型工作的绝大部分时间,因为特征工程的质量决定了模型效果的上限。
我们也可以用一些决策树的方法来自动选择特征,比如 Facebook 在2014年提出的 GBDT+LR 的推荐模型,就是采用 GBDT(梯度提升决策树)的方法做特征选择,并将树的输出直接作为 LR 的特征输入,取得了比较好的效果。
5.1.3. Factorization Machine
特征工程是个耗时耗力,且非常考验业务理解力的过程,当我们处在项目初期,又不想花太多精力去做特征选择时,FM 算法便成了我们的另一种选择。FM 的预测公式如下:
仔细对比上述公式和 LR 公式,可以看出 FM 本质上其实就是在 LR 的基础上增加了发掘二阶特征关系的kernel,vi,vj 表示的是二阶特征经过矩阵分解后得到的低秩矩阵的值:
矩阵分解在推荐系统中很常用,实质上是将一个高维稀疏矩阵分解成两个低秩矩阵,其中 k 被称为隐向量维度。在原始的稀疏矩阵 R 中,大部分二阶特征的关系系数是缺失的。而通过训练模型最小化 R 和预测矩阵 R‘ 的损失(如最小二乘),可以求出任意 Ri,j的值。FM 的kernel在此基础上学习到任意二阶特征的非线性关系,求得它们的权重。
5.2. 模型训练
确定模型后,我们需要根据目标确认损失函数,比如回归一般使用 RMSE,二分类使用 Cross Entropy,然后我们就需要朝最小化损失函数的目的来训练参数了。
求解函数有多种,如果数据量较小,可以选择批量训练的方式,如传统的梯度下降法: Batch Gradient Descent,也可以选择拟牛顿法如 L-BFGS ,用二阶导数求得更快的训练速度。它们的优点是考虑到全部样本,模型准确,但缺点是数据量太大时训练速度很慢。我们可以考虑每次采用小批量的样本训练模型的 online learning,从而达到实时更新模型的效果。方法如 SGD、FOBOS、RDA、FTRL 等等,对比和公示详解建议阅读冯扬的《在线最优化求解》,讲得很详细,这里就不赘述了。
5.3. 在线预测
训练完成后,我们可以把模型文件(实质是所有参数的取值)保存到本地,或者为了更高的执行效率,把它们加载到内存。预测的执行步骤如下:
1. 召回内容队列
2. 线上的服务器从内存读取参数取值 θ
3. 拉取到内容/用户/上下文的实时特征 x
4. 代入预测公式,计算用户 u 对内容 i 的点击率
5. 依据点击率对召回内容排序并返回
每隔一段时间,模型更新之后需要将新训练得到的参数值刷新到预测服务器的内存中。当特征维度很大时模型文件体积也很大,此时如何按时完成更新是个问题,Parameter Server 是一类解决这类问题的框架。
5.4. Rerank
在排序完成之后,直接将排序结果呈现在用户面前可能不是一个好的选择。首先,产品需要有一些特殊的内容形式作为自己的品牌形象;另外,完全根据模型规则走可能让用户兴趣越来越窄,推荐内容同质化,久而久之则会影响用户对内容产品的黏性。
解决这个问题就是在排序之后再进行一次 rerank,我们可以用人工规则的方式,或者贪心算法来确保最后推荐给用户的 TOP10 内容的多样性,以及插入一些对于用户画像里缺失兴趣的探索。
6. 总结
推荐系统涉及到的东西很多,本文只是对各个环节作了些简单的概述。如果要完善系统并真正满足用户的需求,则需要在各个环节都做深入的研究,希望大家共勉。