特征本质上是对某个行为过程相关信息的抽象表达。特征工程就是利用工程手段从“用户信息”、“物品信息”、“场景信息”等相关信息中提取特征的过程。
尽可能地让特征工程抽取出的一组特征,能够保留推荐环境及用户行为过程中的所有“有用“信息,并且尽量摒弃冗余信息。
一般来说,推荐系统中常用的特征有五大类
用户行为数据(User Behavior Data)是推荐系统最常用,也是最关键的数据。用户的潜在兴趣、用户对物品的真实评价都包含在用户的行为历史中。用户行为在推荐系统中一般分为显性反馈行为(Explicit Feedback)和隐性反馈行为(Implicit Feedback)两种,在不同的业务场景中,它们会以不同的形式体现。
对用户行为数据的使用往往涉及对业务的理解,不同的行为在抽取特征时的权重不同,而且一些跟业务特点强相关的用户行为需要推荐工程师通过自己的观察才能发现。
在当前的推荐系统特征工程中,隐性反馈行为越来越重要,主要原因是显性反馈行为的收集难度过大,数据量小。在深度学习模型对数据量的要求越来越大的背景下,仅用显性反馈的数据不足以支持推荐系统训练过程的最终收敛。所以,能够反映用户行为特点的隐性反馈是目前特征挖掘的重点。
互联网本质上就是人与人、人与信息之间的连接。如果说用户行为数据是人与物之间的“连接”日志,那么用户关系数据(User Relationship Data)就是人与人之间连接的记录。
用户关系数据也可以分为“显性”和“隐性”两种,或者称为“强关系”和“弱关系”。如图所示,用户与用户之间可以通过“关注”“好友关系”等连接建立“强关系”,也可以通过“互相点赞”“同处一个社区”,甚至“同看一部电影”建立“弱关系”。
在推荐系统中,利用用户关系数据的方式也是多种多样的,比如可以将用户关系作为召回层的一种物品召回方式;也可以通过用户关系建立关系图,使用 Graph Embedding 的方法生成用户和物品的 Embedding;还可以直接利用关系数据,通过“好友”的特征为用户添加新的属性特征;甚至可以利用用户关系数据直接建立社会化推荐系统。
推荐系统中另外一大类特征来源是属性、标签类数据,这里我把属性类数据(Attribute Data)和标签类数据(Label Data)归为一组进行讨论,是因为它们本质上都是直接描述用户或者物品的特征。属性和标签的主体可以是用户,也可以是物品。它们的来源非常多样,大体上包含图中的几类。
用户、物品的属性、标签类数据是最重要的描述型特征。成熟的公司往往会建立一套用户和物品的标签体系,由专门的团队负责维护,典型的例子就是电商公司的商品分类体系;也可以有一些社交化的方法由用户添加。
在推荐系统中使用属性、标签类数据,一般是通过 Multi-hot 编码的方式将其转换成特征向量,一些重要的属性标签类特征也可以先转换成 Embedding,比如业界最新的做法是将标签属性类数据与其描述主体一起构建成知识图谱(Knowledge Graph),在其上施以 Graph Embedding 或者 GNN(Graph Neural Network,图神经网络)生成各节点的 Embedding,再输入推荐模型。
内容类数据(Content Data)可以看作属性标签型特征的延伸,同样是描述物品或用户的数据,但相比标签类特征,内容类数据往往是大段的描述型文字、图片,甚至视频。
一般来说,内容类数据无法直接转换成推荐系统可以“消化”的特征,需要通过自然语言处理、计算机视觉等技术手段提取关键内容特征,再输入推荐系统。例如,在图片类、视频类或是带有图片的信息流推荐场景中,我们往往会利用计算机视觉模型进行目标检测,抽取图片特征,再把这些特征(要素)转换成标签类数据供推荐系统使用。
而文字信息则更多是通过自然语言处理的方法提取关键词、主题、分类等信息,一旦这些特征被提取出来,就跟处理属性、标签类特征的方法一样,通过 Multi-hot 编码,Embedding 等方式输入推荐系统进行训练。
最后一大类是场景信息,或称为上下文信息(Context Information),它是描述推荐行为产生的场景的信息。最常用的上下文信息是“时间”和通过 GPS、IP 地址获得的“地点”信息。根据推荐场景的不同,上下文信息的范围极广,除了我们上面提到的时间和地点,还包括“当前所处推荐页面”“季节”“月份”“是否节假日”“天气”“空气质量”“社会大事件”等等。
场景特征描述的是用户所处的客观的推荐环境,广义上来讲,任何影响用户决定的因素都可以当作是场景特征的一部分。但在实际的推荐系统应用中,由于一些特殊场景特征的获取极其困难,我们更多还是利用时间、地点、推荐页面这些易获取的场景特征。
Spark 是一个分布式计算平台。所谓分布式,指的是计算节点之间不共享内存,需要通过网络通信的方式交换数据。Spark 最典型的应用方式就是建立在大量廉价的计算节点上,这些节点可以是廉价主机,也可以是虚拟的 Docker Container(Docker 容器)。
Spark 程序由 Manager Node(管理节点)进行调度组织,由 Worker Node(工作节点)进行具体的计算任务执行,最终将结果返回给 Drive Program(驱动程序)。在物理的 Worker Node 上,数据还会分为不同的 partition(数据分片),可以说 partition 是 Spark 的基础数据单元。
Spark 计算集群能够比传统的单机高性能服务器具备更强大的计算能力,就是由这些成百上千,甚至达到万以上规模的工作节点并行工作带来的。
以一个任务为例:先从本地硬盘读取文件 textFile,再从分布式文件系统 HDFS 读取文件 hadoopFile,然后分别对它们进行处理,再把两个文件按照 ID 都 join 起来得到最终的结果。
在 Spark 平台上处理这个任务的时候,会将这个任务拆解成一个子任务 DAG(Directed Acyclic Graph,有向无环图),再根据 DAG 决定程序各步骤执行的方法。从图中我们可以看到,这个 Spark 程序分别从 textFile 和 hadoopFile 读取文件,再经过一系列 map、filter 等操作后进行 join,最终得到了处理结果。
其中,最关键的过程是我们要理解哪些是可以纯并行处理的部分,哪些是必须 shuffle(混洗)和 reduce 的部分。
这里的 shuffle 指的是所有 partition 的数据必须进行洗牌后才能得到下一步的数据,最典型的操作就是图中的 groupByKey 操作和 join 操作。以 join 操作为例,我们必须对 textFile 数据和 hadoopFile 数据做全量的匹配才可以得到 join 后的 dataframe(Spark 保存数据的结构)。而 groupByKey 操作则需要对数据中所有相同的 key 进行合并,也需要全局的 shuffle 才能完成。
与之相比,map、filter 等操作仅需要逐条地进行数据处理和转换,不需要进行数据间的操作,因此各 partition 之间可以完全并行处理。
此外,在得到最终的计算结果之前,程序需要进行 reduce 的操作,从各 partition 上汇总统计结果,随着 partition 的数量逐渐减小,reduce 操作的并行程度逐渐降低,直到将最终的计算结果汇总到 master 节点(主节点)上。可以说,shuffle 和 reduce 操作的触发决定了纯并行处理阶段的边界。
shuffle 操作需要在不同计算节点之间进行数据交换,非常消耗计算、通信及存储资源,因此 shuffle 操作是 spark 程序应该尽量避免的。
Spark的计算过程可以总结为:Stage 内部数据高效并行计算,Stage 边界处进行消耗资源的 shuffle 操作或者最终的 reduce 操作。
广义上来讲,所有的特征都可以分为两大类。第一类是类别、ID 型特征(以下简称类别型特征)。拿电影推荐来说,电影的风格、ID、标签、导演演员等信息,用户看过的电影 ID、用户的性别、地理位置信息、当前的季节、时间(上午,下午,晚上)、天气等等,这些无法用数字表示的信息全都可以被看作是类别、ID 类特征。第二类是数值型特征,能用数字直接表示的特征就是数值型特征,典型的包括用户的年龄、收入、电影的播放时长、点击量、点击率等。
进行特征处理的目的,是把所有的特征全部转换成一个数值型的特征向量,对于数值型特征,这个过程非常简单,直接把这个数值放到特征向量上相应的维度上就可以了。但是对于类别、ID 类特征,就要用到 One-hot 编码(也被称为独热编码),它是将类别、ID 型特征转换成数值向量的一种最典型的编码方式。它通过把所有其他维度置为 0,单独将当前类别或者 ID 对应的维度置为 1 的方式生成特征向量。这怎么理解呢?我们举例来说,假设某样本有三个特征,分别是星期、性别和城市,我们用 [Weekday=Tuesday, Gender=Male, City=London] 来表示,用 One-hot 编码对其进行数值化的结果。
可以看到,Weekday 这个特征域有 7 个维度,Tuesday 对应第 2 个维度,所以我把对应维度置为 1。而 Gender 分为 Male 和 Female,所以对应的 One-hot 编码就有两个维度,City 特征域同理。
下面,使用 Spark 的机器学习库 MLlib 来完成 One-hot 特征的处理。
其中,最主要的步骤是,我们先创建一个负责 One-hot 编码的转换器,OneHotEncoderEstimator,然后通过它的 fit 函数完成指定特征的预处理,并利用 transform 函数将原始特征转换成 One-hot 特征。实现思路大体上就是这样,具体的步骤可以参考下面给出的源码:
def oneHotEncoderExample(samples:DataFrame): Unit ={
//samples样本集中的每一条数据代表一部电影的信息,其中movieId为电影id
val samplesWithIdNumber = samples.withColumn("movieIdNumber", col("movieId").cast(sql.types.IntegerType))
//利用Spark的机器学习库Spark MLlib创建One-hot编码器
val oneHotEncoder = new OneHotEncoderEstimator()
.setInputCols(Array("movieIdNumber"))
.setOutputCols(Array("movieIdVector"))
.setDropLast(false)
//训练One-hot编码器,并完成从id特征到One-hot向量的转换
val oneHotEncoderSamples = oneHotEncoder.fit(samplesWithIdNumber).transform(samplesWithIdNumber)
//打印最终样本的数据结构
oneHotEncoderSamples.printSchema()
//打印10条样本查看结果
oneHotEncoderSamples.show(10)
_(参考 com.wzhe.sparrowrecsys.offline.spark.featureeng.FeatureEngineering__中的oneHotEncoderExample函数)_
One-hot 编码也可以自然衍生成 Multi-hot 编码(多热编码)。比如,对于历史行为序列类、标签特征等数据来说,用户往往会与多个物品产生交互行为,或者一个物品被打上多个标签,这时最常用的特征向量生成方式就是把其转换成 Multi-hot 编码。在 SparrowRecsys 中,因为每个电影都是有多个 Genre(风格)类别的,所以我们就可以用 Multi-hot 编码完成标签到向量的转换。
主要讨论两方面问题,一是特征的尺度,二是特征的分布。
特征的尺度问题不难理解,比如在电影推荐中有两个特征,一个是电影的评价次数 fr,一个是电影的平均评分 fs。评价次数其实是一个数值无上限的特征,在 SparrowRecsys 所用 MovieLens 数据集上,fr 的范围一般在[0,10000]之间。对于电影的平均评分来说,因为我们采用了 5 分为满分的评分,所以特征 fs 的取值范围在[0,5]之间。
由于 fr 和 fs 两个特征的尺度差距太大,如果我们把特征的原始数值直接输入推荐模型,就会导致这两个特征对于模型的影响程度有显著的区别。如果模型中未做特殊处理的话,fr 这个特征由于波动范围高出 fs 几个量级,可能会完全掩盖 fs 作用,这当然是我们不愿意看到的。为此我们希望把两个特征的尺度拉平到一个区域内,通常是[0,1]范围,这就是所谓归一化。
归一化虽然能够解决特征取值范围不统一的问题,但无法改变特征值的分布。比如图 5 就显示了 Sparrow Recsys 中编号在前 1000 的电影平均评分分布。你可以很明显地看到,由于人们打分有“中庸偏上”的倾向,因此评分大量集中在 3.5 的附近,而且越靠近 3.5 的密度越大。这对于模型学习来说也不是一个好的现象,因为特征的区分度并不高。
经常会用分桶的方式来解决特征值分布极不均匀的问题。所谓“分桶(Bucketing)”,就是将样本按照某特征的值从高到低排序,然后按照桶的数量找到分位数,将样本分到各自的桶中,再用桶 ID 作为特征值。
在 Spark MLlib 中,分别提供了两个转换器 MinMaxScaler 和 QuantileDiscretizer,来进行归一化和分桶的特征处理。它们的使用方法和之前介绍的 OneHotEncoderEstimator 一样,都是先用 fit 函数进行数据预处理,再用 transform 函数完成特征转换。下面的代码就是 SparrowRecSys 利用这两个转换器完成特征归一化和分桶的过程。
def ratingFeatures(samples:DataFrame): Unit ={
samples.printSchema()
samples.show(10)
//利用打分表ratings计算电影的平均分、被打分次数等数值型特征
val movieFeatures = samples.groupBy(col("movieId"))
.agg(count(lit(1)).as("ratingCount"),
avg(col("rating")).as("avgRating"),
variance(col("rating")).as("ratingVar"))
.withColumn("avgRatingVec", double2vec(col("avgRating")))
movieFeatures.show(10)
//分桶处理,创建QuantileDiscretizer进行分桶,将打分次数这一特征分到100个桶中
val ratingCountDiscretizer = new QuantileDiscretizer()
.setInputCol("ratingCount")
.setOutputCol("ratingCountBucket")
.setNumBuckets(100)
//归一化处理,创建MinMaxScaler进行归一化,将平均得分进行归一化
val ratingScaler = new MinMaxScaler()
.setInputCol("avgRatingVec")
.setOutputCol("scaleAvgRating")
//创建一个pipeline,依次执行两个特征处理过程
val pipelineStage: Array[PipelineStage] = Array(ratingCountDiscretizer, ratingScaler)
val featurePipeline = new Pipeline().setStages(pipelineStage)
val movieProcessedFeatures = featurePipeline.fit(movieFeatures).transform(movieFeatures)
//打印最终结果
movieProcessedFeatures.show(
_(参考 com.wzhe.sparrowrecsys.offline.spark.featureeng.FeatureEngineering中的ratingFeatures函数)_
当然,对于数值型特征的处理方法还远不止于此,在经典的 YouTube 深度推荐模型中,我们就可以看到一些很有意思的处理方法。比如,在处理观看时间间隔(time since last watch)和视频曝光量(#previous impressions)这两个特征的时,YouTube 模型对它们进行归一化后,又将它们各自处理成了三个特征,分别是原特征值 x,特征值的平方x^2,以及特征值的开方。
其实,无论是平方还是开方操作,改变的还是这个特征值的分布,这些操作与分桶操作一样,都是希望通过改变特征的分布,让模型能够更好地学习到特征内包含的有价值信息。
特征处理并没有标准答案,不存在一种特征处理方式是一定好于另一种的。在实践中,需要多进行一些尝试,找到那个最能够提升模型效果的一种或一组处理方式。
简单来说,Embedding 就是用一个数值向量“表示”一个对象(Object)的方法,我这里说的对象可以是一个词、一个物品,也可以是一部电影等等。
一个物品能被向量表示,是因为这个向量跟其他物品向量之间的距离反映了这些物品的相似性。更进一步来说,两个向量间的距离向量甚至能够反映它们之间的关系。
下图是 Google 著名的论文 Word2vec 中的例子,它利用 Word2vec 这个模型把单词映射到了高维空间中。图左边的例子中,从 king 到 queen 的向量和从 man 到 woman 的向量,无论从方向还是尺度来说它们都异常接近。说明词 Embedding 向量间的运算能够揭示词之间的性别关系。比如 woman 这个词的词向量可以用下面的运算得出:
同样,图右的例子也很典型,从 walking 到 walked 和从 swimming 到 swam 的向量基本一致,这说明词向量揭示了词之间的时态关系。
Netflix 应用的电影 Embedding 向量方法,就是一个非常直接的推荐系统应用。从 Netflix 利用矩阵分解方法生成的电影和用户的 Embedding 向量示意图中,我们可以看出不同的电影和用户分布在一个二维的空间内,由于 Embedding 向量保存了它们之间的相似性关系,因此有了这个 Embedding 空间之后,我们再进行电影推荐就非常容易了。具体来说就是,我们直接找出某个用户向量周围的电影向量,然后把这些电影推荐给这个用户就可以了。这就是 Embedding 技术在推荐系统中最直接的应用。
Word2vec 是“word to vector”的简称,顾名思义,它是一个生成对“词”的向量表达的模型。
想要训练 Word2vec 模型,我们需要准备由一组句子组成的语料库。假设其中一个长度为 T 的句子包含的词有 w1,w2……wt,并且我们假定每个词都跟其相邻词的关系最密切。
根据模型假设的不同,Word2vec 模型分为两种形式,CBOW 模型(图左)和 Skip-gram 模型(图右)。其中,CBOW 模型假设句子中每个词的选取都由相邻的词决定,因此我们就看到 CBOW 模型的输入是 wt周边的词,预测的输出是 wt。Skip-gram 模型则正好相反,它假设句子中的每个词都决定了相邻词的选取,所以你可以看到 Skip-gram 模型的输入是 wt,预测的输出是 wt周边的词。按照一般的经验,Skip-gram 模型的效果会更好一些。
作为一个自然语言处理的模型,训练 Word2vec 的样本当然来自于语料库,比如我们想训练一个电商网站中关键词的 Embedding 模型,那么电商网站中所有物品的描述文字就是很好的语料库。
我们从语料库中抽取一个句子,选取一个长度为 2c+1(目标词前后各选 c 个词)的滑动窗口,将滑动窗口由左至右滑动,每移动一次,窗口中的词组就形成了一个训练样本。根据 Skip-gram 模型的理念,中心词决定了它的相邻词,我们就可以根据这个训练样本定义出 Word2vec 模型的输入和输出,输入是样本的中心词,输出是所有的相邻词。
Word2vec模型本质上是一个三层的神经网络。
它的输入层和输出层的维度都是 V,这个 V 其实就是语料库词典的大小。假设语料库一共使用了 10000 个词,那么 V 就等于 10000。根据图生成的训练样本,这里的输入向量自然就是由输入词转换而来的 One-hot 编码向量,输出向量则是由多个输出词转换而来的 Multi-hot 编码向量,显然,基于 Skip-gram 框架的 Word2vec 模型解决的是一个多分类问题。
隐层的维度是 N,N 的选择就需要一定的调参能力了,我们需要对模型的效果和模型的复杂度进行权衡,来决定最后 N 的取值,并且最终每个词的 Embedding 向量维度也由 N 来决定。
最后是激活函数的问题,这里我们需要注意的是,隐层神经元是没有激活函数的,或者说采用了输入即输出的恒等函数作为激活函数,而输出层神经元采用了 softmax 作为激活函数。
每个词对应的Embedding向量藏在输入层到隐层的权重矩阵 WVxN 中。
可以看到,输入向量矩阵 WVxN 的每一个行向量对应的就是我们要找的“词向量”。比如我们要找词典里第 i 个词对应的 Embedding,因为输入向量是采用 One-hot 编码的,所以输入向量的第 i 维就应该是 1,那么输入向量矩阵 WVxN 中第 i 行的行向量自然就是该词的 Embedding 。
在实际的使用过程中,往往会把输入向量矩阵转换成词向量查找表(Lookup table,如图所示)。例如,输入向量是 10000 个词组成的 One-hot 向量,隐层维度是 300 维,那么输入层到隐层的权重矩阵为 10000x300 维。在转换为词向量 Lookup table 后,每行的权重即成了对应词的 Embedding 向量。如果我们把这个查找表存储到线上的数据库中,就可以轻松地在推荐物品的过程中使用 Embedding 去计算相似性等重要的特征了。
微软于 2015 年提出了 Item2Vec 方法,它是对 Word2vec 方法的推广,使 Embedding 方法适用于几乎所有的序列数据。Item2Vec 模型的技术细节几乎和 Word2vec 完全一致,只要能够用序列数据的形式把我们要表达的对象表示出来,再把序列数据“喂”给 Word2vec 模型,我们就能够得到任意物品的 Embedding 了。
Item2vec 的提出对于推荐系统来说当然是至关重要的,因为它使得“万物皆 Embedding”成为了可能。对于推荐系统来说,Item2vec 可以利用物品的 Embedding 直接求得它们的相似性,或者作为重要的特征输入推荐模型进行训练,这些都有助于提升推荐系统的效果。